chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled
Enforce Pull-Request Rules / check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// Internal alias mapping for default and 3rd party modules.
|
||||
// Provides short require identifiers: "logger" and "node_helper".
|
||||
// For a future ESM migration, replace this with a public export/import surface.
|
||||
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
|
||||
// Keep this list minimal; do not add new aliases without architectural review.
|
||||
const ALIASES = {
|
||||
logger: "js/logger.js",
|
||||
node_helper: "js/node_helper.js"
|
||||
};
|
||||
|
||||
// Resolve to absolute paths now.
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(ALIASES).map(([k, rel]) => [k, path.join(root, rel)])
|
||||
);
|
||||
|
||||
// Prevent multiple patching if this file is required more than once.
|
||||
if (!Module._mmAliasPatched) {
|
||||
const origResolveFilename = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, parent, isMain, options) {
|
||||
if (Object.prototype.hasOwnProperty.call(resolved, request)) {
|
||||
return resolved[request];
|
||||
}
|
||||
return origResolveFilename.call(this, request, parent, isMain, options);
|
||||
};
|
||||
Module._mmAliasPatched = true; // non-enumerable marker would be overkill here
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/* enumeration of animations in Array **/
|
||||
|
||||
const AnimateCSSIn = [
|
||||
// Attention seekers
|
||||
"bounce",
|
||||
"flash",
|
||||
"pulse",
|
||||
"rubberBand",
|
||||
"shakeX",
|
||||
"shakeY",
|
||||
"headShake",
|
||||
"swing",
|
||||
"tada",
|
||||
"wobble",
|
||||
"jello",
|
||||
"heartBeat",
|
||||
// Back entrances
|
||||
"backInDown",
|
||||
"backInLeft",
|
||||
"backInRight",
|
||||
"backInUp",
|
||||
// Bouncing entrances
|
||||
"bounceIn",
|
||||
"bounceInDown",
|
||||
"bounceInLeft",
|
||||
"bounceInRight",
|
||||
"bounceInUp",
|
||||
// Fading entrances
|
||||
"fadeIn",
|
||||
"fadeInDown",
|
||||
"fadeInDownBig",
|
||||
"fadeInLeft",
|
||||
"fadeInLeftBig",
|
||||
"fadeInRight",
|
||||
"fadeInRightBig",
|
||||
"fadeInUp",
|
||||
"fadeInUpBig",
|
||||
"fadeInTopLeft",
|
||||
"fadeInTopRight",
|
||||
"fadeInBottomLeft",
|
||||
"fadeInBottomRight",
|
||||
// Flippers
|
||||
"flip",
|
||||
"flipInX",
|
||||
"flipInY",
|
||||
// Lightspeed
|
||||
"lightSpeedInRight",
|
||||
"lightSpeedInLeft",
|
||||
// Rotating entrances
|
||||
"rotateIn",
|
||||
"rotateInDownLeft",
|
||||
"rotateInDownRight",
|
||||
"rotateInUpLeft",
|
||||
"rotateInUpRight",
|
||||
// Specials
|
||||
"jackInTheBox",
|
||||
"rollIn",
|
||||
// Zooming entrances
|
||||
"zoomIn",
|
||||
"zoomInDown",
|
||||
"zoomInLeft",
|
||||
"zoomInRight",
|
||||
"zoomInUp",
|
||||
// Sliding entrances
|
||||
"slideInDown",
|
||||
"slideInLeft",
|
||||
"slideInRight",
|
||||
"slideInUp"
|
||||
];
|
||||
|
||||
const AnimateCSSOut = [
|
||||
// Back exits
|
||||
"backOutDown",
|
||||
"backOutLeft",
|
||||
"backOutRight",
|
||||
"backOutUp",
|
||||
// Bouncing exits
|
||||
"bounceOut",
|
||||
"bounceOutDown",
|
||||
"bounceOutLeft",
|
||||
"bounceOutRight",
|
||||
"bounceOutUp",
|
||||
// Fading exits
|
||||
"fadeOut",
|
||||
"fadeOutDown",
|
||||
"fadeOutDownBig",
|
||||
"fadeOutLeft",
|
||||
"fadeOutLeftBig",
|
||||
"fadeOutRight",
|
||||
"fadeOutRightBig",
|
||||
"fadeOutUp",
|
||||
"fadeOutUpBig",
|
||||
"fadeOutTopLeft",
|
||||
"fadeOutTopRight",
|
||||
"fadeOutBottomRight",
|
||||
"fadeOutBottomLeft",
|
||||
// Flippers
|
||||
"flipOutX",
|
||||
"flipOutY",
|
||||
// Lightspeed
|
||||
"lightSpeedOutRight",
|
||||
"lightSpeedOutLeft",
|
||||
// Rotating exits
|
||||
"rotateOut",
|
||||
"rotateOutDownLeft",
|
||||
"rotateOutDownRight",
|
||||
"rotateOutUpLeft",
|
||||
"rotateOutUpRight",
|
||||
// Specials
|
||||
"hinge",
|
||||
"rollOut",
|
||||
// Zooming exits
|
||||
"zoomOut",
|
||||
"zoomOutDown",
|
||||
"zoomOutLeft",
|
||||
"zoomOutRight",
|
||||
"zoomOutUp",
|
||||
// Sliding exits
|
||||
"slideOutDown",
|
||||
"slideOutLeft",
|
||||
"slideOutRight",
|
||||
"slideOutUp"
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an animation with Animate CSS
|
||||
* @param {string} [element] div element to animate.
|
||||
* @param {string} [animation] animation name.
|
||||
* @param {number} [animationTime] animation duration.
|
||||
*/
|
||||
function addAnimateCSS (element, animation, animationTime) {
|
||||
const animationName = `animate__${animation}`;
|
||||
const node = document.getElementById(element);
|
||||
if (!node) {
|
||||
// don't execute animate: we don't find div
|
||||
Log.warn("node not found for adding", element);
|
||||
return;
|
||||
}
|
||||
node.style.setProperty("--animate-duration", `${animationTime}s`);
|
||||
node.classList.add("animate__animated", animationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an animation with Animate CSS
|
||||
* @param {string} [element] div element to animate.
|
||||
* @param {string} [animation] animation name.
|
||||
*/
|
||||
function removeAnimateCSS (element, animation) {
|
||||
const animationName = `animate__${animation}`;
|
||||
const node = document.getElementById(element);
|
||||
if (!node) {
|
||||
// don't execute animate: we don't find div
|
||||
Log.warn("node not found for removing", element);
|
||||
return;
|
||||
}
|
||||
node.classList.remove("animate__animated", animationName);
|
||||
node.style.removeProperty("--animate-duration");
|
||||
}
|
||||
if (typeof window === "undefined") module.exports = { AnimateCSSIn, AnimateCSSOut, addAnimateCSS, removeAnimateCSS };
|
||||
@@ -0,0 +1,348 @@
|
||||
// Load lightweight internal alias resolver
|
||||
require("./alias-resolver");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Spawn = require("node:child_process").spawn;
|
||||
const Log = require("logger");
|
||||
|
||||
// global absolute root path
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
|
||||
// used to control fetch timeout for node_helpers
|
||||
const { setGlobalDispatcher, Agent } = require("undici");
|
||||
|
||||
const Server = require("./server");
|
||||
const Utils = require("./utils");
|
||||
const { ConfigError } = require("./utils");
|
||||
|
||||
const { getEnvVarsAsObj } = require("#server_functions");
|
||||
// common timeout value, provide environment override in case
|
||||
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
|
||||
|
||||
// Get version number.
|
||||
global.version = require(`${global.root_path}/package.json`).version;
|
||||
global.mmTestMode = process.env.mmTestMode === "true";
|
||||
Log.log(`Starting MagicMirror: v${global.version}`);
|
||||
|
||||
// Log system information in a subprocess so it is shown even on early startup failures.
|
||||
Spawn("node ./js/systeminformation.js", {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_VERSION: `${process.versions.electron}`,
|
||||
USED_NODE_VERSION: `${process.versions.node}`
|
||||
},
|
||||
cwd: this.root_path,
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
if (process.env.MM_CONFIG_FILE) {
|
||||
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
|
||||
}
|
||||
|
||||
// FIXME: Hotfix Pull Request
|
||||
// https://github.com/MagicMirrorOrg/MagicMirror/pull/673
|
||||
if (process.env.MM_PORT) {
|
||||
global.mmPort = process.env.MM_PORT;
|
||||
}
|
||||
|
||||
// The next part is here to prevent a major exception when there
|
||||
// is no internet connection. This could probable be solved better.
|
||||
process.on("uncaughtException", function (err) {
|
||||
// ignore strange exceptions under aarch64 coming from systeminformation:
|
||||
if (!err.stack.includes("node_modules/systeminformation")) {
|
||||
Log.error("Whoops! There was an uncaught exception...");
|
||||
Log.error(err);
|
||||
Log.error("MagicMirror² will not quit, but it might be a good idea to check why this happened. Maybe no internet connection?");
|
||||
Log.error("If you think this really is an issue, please open an issue on GitHub: https://github.com/MagicMirrorOrg/MagicMirror/issues");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The core app.
|
||||
* @class
|
||||
*/
|
||||
function App () {
|
||||
let nodeHelpers = [];
|
||||
let httpServer;
|
||||
let defaultModules;
|
||||
let env;
|
||||
|
||||
/**
|
||||
* Loads a specific module.
|
||||
* @param {string} module The name of the module (including subpath).
|
||||
*/
|
||||
function loadModule (module) {
|
||||
const elements = module.split("/");
|
||||
const moduleName = elements[elements.length - 1];
|
||||
let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);
|
||||
|
||||
if (defaultModules.includes(moduleName)) {
|
||||
const defaultModuleFolder = path.resolve(`${global.root_path}/${global.defaultModulesDir}/`, module);
|
||||
if (!global.mmTestMode) {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
// running in test mode, allow defaultModules placed under moduleDir for testing
|
||||
if (env.modulesDir === "modules" || env.modulesDir === "tests/mocks") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moduleFile = `${moduleFolder}/${moduleName}.js`;
|
||||
|
||||
try {
|
||||
fs.accessSync(moduleFile, fs.constants.R_OK);
|
||||
} catch {
|
||||
Log.warn(`No ${moduleFile} found for module: ${moduleName}.`);
|
||||
}
|
||||
|
||||
const helperPath = `${moduleFolder}/node_helper.js`;
|
||||
|
||||
let loadHelper = true;
|
||||
try {
|
||||
fs.accessSync(helperPath, fs.constants.R_OK);
|
||||
} catch {
|
||||
loadHelper = false;
|
||||
Log.log(`No helper found for module: ${moduleName}.`);
|
||||
}
|
||||
|
||||
// if the helper was found
|
||||
if (loadHelper) {
|
||||
let Module;
|
||||
try {
|
||||
Module = require(helperPath);
|
||||
} catch (e) {
|
||||
Log.error(`Error when loading ${moduleName}:`, e.message);
|
||||
return;
|
||||
}
|
||||
let m = new Module();
|
||||
|
||||
if (m.requiresVersion) {
|
||||
Log.log(`Check MagicMirror² version for node helper '${moduleName}' - Minimum version: ${m.requiresVersion} - Current version: ${global.version}`);
|
||||
if (cmpVersions(global.version, m.requiresVersion) >= 0) {
|
||||
Log.log("Version is ok!");
|
||||
} else {
|
||||
Log.warn(`Version is incorrect. Skip module: '${moduleName}'`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
m.setName(moduleName);
|
||||
m.setPath(path.resolve(moduleFolder));
|
||||
nodeHelpers.push(m);
|
||||
|
||||
m.loaded();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all modules.
|
||||
* @param {Module[]} modules All modules to be loaded
|
||||
* @returns {Promise} A promise that is resolved when all modules been loaded
|
||||
*/
|
||||
async function loadModules (modules) {
|
||||
Log.log("Loading module helpers ...");
|
||||
|
||||
for (let module of modules) {
|
||||
await loadModule(module);
|
||||
}
|
||||
|
||||
Log.log("All module helpers loaded.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version numbers and return the difference.
|
||||
* @param {string} a Version number a.
|
||||
* @param {string} b Version number b.
|
||||
* @returns {number} A positive number if a is larger than b, a negative
|
||||
* number if a is smaller and 0 if they are the same
|
||||
*/
|
||||
function cmpVersions (a, b) {
|
||||
let i, diff;
|
||||
const regExStrip0 = /(\.0+)+$/;
|
||||
const segmentsA = a.replace(regExStrip0, "").split(".");
|
||||
const segmentsB = b.replace(regExStrip0, "").split(".");
|
||||
const l = Math.min(segmentsA.length, segmentsB.length);
|
||||
|
||||
for (i = 0; i < l; i++) {
|
||||
diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10);
|
||||
if (diff) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return segmentsA.length - segmentsB.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the core app.
|
||||
*
|
||||
* It loads the config, then it loads all modules.
|
||||
* @async
|
||||
* @returns {Promise<object>} the config used
|
||||
*/
|
||||
this.start = async function () {
|
||||
try {
|
||||
const configObj = Utils.loadConfig();
|
||||
global.config = configObj.fullConf;
|
||||
// Keep a copy of the redacted config to later verify module secret permissions
|
||||
global.configRedacted = configObj.redactedConf;
|
||||
const config = global.config;
|
||||
Utils.checkConfigFile(configObj);
|
||||
|
||||
global.defaultModulesDir = config.defaultModulesDir;
|
||||
defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`);
|
||||
|
||||
Log.setLogLevel(config.logLevel);
|
||||
|
||||
env = getEnvVarsAsObj();
|
||||
// check for deprecated css/custom.css and move it to new location
|
||||
if ((!fs.existsSync(`${global.root_path}/${env.customCss}`)) && (fs.existsSync(`${global.root_path}/css/custom.css`))) {
|
||||
try {
|
||||
fs.renameSync(`${global.root_path}/css/custom.css`, `${global.root_path}/${env.customCss}`);
|
||||
Log.warn(`WARNING! Your custom css file was moved from ${global.root_path}/css/custom.css to ${global.root_path}/${env.customCss}`);
|
||||
} catch {
|
||||
Log.warn("WARNING! Your custom css file is currently located in the css folder. Please move it to the config folder!");
|
||||
}
|
||||
}
|
||||
|
||||
// get the used module positions
|
||||
Utils.getModulePositions();
|
||||
|
||||
let modules = [];
|
||||
for (const module of config.modules) {
|
||||
if (module.disabled) continue;
|
||||
if (module.module) {
|
||||
if (Utils.moduleHasValidPosition(module.position) || typeof (module.position) === "undefined") {
|
||||
// Only add this module to be loaded if it is not a duplicate (repeated instance of the same module)
|
||||
if (!modules.includes(module.module)) {
|
||||
modules.push(module.module);
|
||||
}
|
||||
} else {
|
||||
Log.warn("Invalid module position found for this configuration:" + `\n${JSON.stringify(module, null, 2)}`);
|
||||
}
|
||||
} else {
|
||||
Log.warn("No module name found for this configuration:" + `\n${JSON.stringify(module, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalDispatcher(new Agent({ connect: { timeout: fetch_timeout } }));
|
||||
|
||||
await loadModules(modules);
|
||||
|
||||
httpServer = new Server(configObj);
|
||||
const { app, io } = await httpServer.open();
|
||||
Log.log("Server started ...");
|
||||
|
||||
const nodePromises = [];
|
||||
for (let nodeHelper of nodeHelpers) {
|
||||
nodeHelper.setExpressApp(app);
|
||||
nodeHelper.setSocketIO(io);
|
||||
|
||||
try {
|
||||
nodePromises.push(nodeHelper.start());
|
||||
} catch (error) {
|
||||
Log.error(`Error when starting node_helper for module ${nodeHelper.name}:`);
|
||||
Log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(nodePromises);
|
||||
|
||||
// Log errors that happened during async node_helper startup
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
Log.error(result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
Log.log("Sockets connected & modules started ...");
|
||||
|
||||
return global.config;
|
||||
} catch (err) {
|
||||
// planned ConfigErrors already logged their message before throwing
|
||||
if (!(err instanceof ConfigError)) {
|
||||
Log.error("Unexpected error during startup:", err);
|
||||
}
|
||||
|
||||
const int32 = new Int32Array(new SharedArrayBuffer(4));
|
||||
// wait 1000ms before exiting so that child processes (e.g. systeminformation) have some additional time
|
||||
Atomics.wait(int32, 0, 0, 1000);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the core app. This calls each node_helper's STOP() function, if it
|
||||
* exists.
|
||||
*
|
||||
* Added to fix #1056
|
||||
* @returns {Promise} A promise that is resolved when all node_helpers and
|
||||
* the http server has been closed
|
||||
*/
|
||||
this.stop = async function () {
|
||||
const nodePromises = [];
|
||||
for (let nodeHelper of nodeHelpers) {
|
||||
try {
|
||||
if (typeof nodeHelper.stop === "function") {
|
||||
nodePromises.push(nodeHelper.stop());
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(`Error when stopping node_helper for module ${nodeHelper.name}:`);
|
||||
Log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(nodePromises);
|
||||
|
||||
// Log errors that happened during async node_helper stopping
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
Log.error(result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
Log.log("Node_helpers stopped ...");
|
||||
|
||||
// To be able to stop the app even if it hasn't been started (when
|
||||
// running with Electron against another server)
|
||||
if (!httpServer) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return httpServer.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen for SIGINT signal and call stop() function.
|
||||
*
|
||||
* Added to fix #1056
|
||||
* Note: this is only used if running `server-only`. Otherwise
|
||||
* this.stop() is called by app.on("before-quit"... in `electron.js`
|
||||
*/
|
||||
process.on("SIGINT", async () => {
|
||||
Log.log("[SIGINT] Received. Shutting down server...");
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 3000); // Force quit after 3 seconds
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Listen to SIGTERM signals so we can stop everything when we
|
||||
* are asked to stop by the OS.
|
||||
*/
|
||||
process.on("SIGTERM", async () => {
|
||||
Log.log("[SIGTERM] Received. Shutting down server...");
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 3000); // Force quit after 3 seconds
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = new App();
|
||||
@@ -0,0 +1,16 @@
|
||||
// Ensure internal require aliases (e.g., "logger") resolve when this file is run as a standalone script
|
||||
require("./alias-resolver");
|
||||
|
||||
const path = require("node:path");
|
||||
const Log = require("logger");
|
||||
|
||||
const rootPath = path.resolve(`${__dirname}/../`);
|
||||
const Utils = require(`${rootPath}/js/utils.js`);
|
||||
|
||||
try {
|
||||
Utils.checkConfigFile();
|
||||
} catch (error) {
|
||||
const message = error && error.message ? error.message : error;
|
||||
Log.error(`Unexpected error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* global mmPort */
|
||||
|
||||
const address = "localhost";
|
||||
let port = 8080;
|
||||
if (typeof mmPort !== "undefined") {
|
||||
port = mmPort;
|
||||
}
|
||||
const defaults = {
|
||||
address: address,
|
||||
port: port,
|
||||
basePath: "/",
|
||||
useHttps: false, // Support HTTPS or not, default "false" will use HTTP
|
||||
httpsPrivateKey: "", // HTTPS private key path, only required when useHttps is true
|
||||
httpsCertificate: "", // HTTPS Certificate path, only required when useHttps is true
|
||||
tls: null, // Legacy compatibility option for Electron URL selection (prefer useHttps)
|
||||
electronOptions: {},
|
||||
electronSwitches: [],
|
||||
ignoreXOriginHeader: false, // Remove X-Frame-Options response header in Electron
|
||||
ignoreContentSecurityPolicy: false, // Remove Content-Security-Policy response header in Electron
|
||||
ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"],
|
||||
cors: "disabled", // or "allowAll" or "allowWhitelist"
|
||||
corsDomainWhitelist: [], // example: ["api.mapbox.com"]
|
||||
watchTargets: [],
|
||||
|
||||
language: "en",
|
||||
logLevel: ["INFO", "LOG", "WARN", "ERROR"],
|
||||
timeFormat: 24,
|
||||
units: "metric",
|
||||
zoom: 1,
|
||||
customCss: "config/custom.css",
|
||||
foreignModulesDir: "modules",
|
||||
defaultModulesDir: "defaultmodules",
|
||||
hideConfigSecrets: false,
|
||||
// httpHeaders used by helmet, see https://helmetjs.github.io/. You can add other/more object values by overriding this in config.js,
|
||||
// e.g. you need to add `frameguard: false` for embedding MagicMirror in another website, see https://github.com/MagicMirrorOrg/MagicMirror/issues/2847
|
||||
httpHeaders: { contentSecurityPolicy: false, crossOriginOpenerPolicy: false, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, originAgentCluster: false },
|
||||
|
||||
// properties for checking if server is alive and has same startup-timestamp, the check is per default enabled
|
||||
// (interval 30 seconds). If startup-timestamp has changed the client reloads the magicmirror webpage.
|
||||
checkServerInterval: 30 * 1000,
|
||||
reloadAfterServerRestart: false,
|
||||
|
||||
modules: [
|
||||
{
|
||||
module: "updatenotification",
|
||||
position: "top_center"
|
||||
},
|
||||
{
|
||||
module: "helloworld",
|
||||
position: "upper_third",
|
||||
classes: "large thin",
|
||||
config: {
|
||||
text: "MagicMirror²"
|
||||
}
|
||||
},
|
||||
{
|
||||
module: "helloworld",
|
||||
position: "middle_center",
|
||||
config: {
|
||||
text: "Please create a config file or check the existing one for errors."
|
||||
}
|
||||
},
|
||||
{
|
||||
module: "helloworld",
|
||||
position: "middle_center",
|
||||
classes: "small dimmed",
|
||||
config: {
|
||||
text: "See README for more information."
|
||||
}
|
||||
},
|
||||
{
|
||||
module: "helloworld",
|
||||
position: "middle_center",
|
||||
classes: "xsmall",
|
||||
config: {
|
||||
text: "If you get this message while your config file is already created,<br>" + "it probably contains an error. To validate your config file run in your MagicMirror² directory<br>" + "<pre>node --run config:check</pre>"
|
||||
}
|
||||
},
|
||||
{
|
||||
module: "helloworld",
|
||||
position: "bottom_bar",
|
||||
classes: "xsmall dimmed",
|
||||
config: {
|
||||
text: "https://magicmirror.builders/"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*************** DO NOT EDIT THE LINE BELOW ***************/
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = defaults;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
configs: [],
|
||||
clock: ["secondsColor"]
|
||||
};
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
"use strict";
|
||||
|
||||
const electron = require("electron");
|
||||
const core = require("./app");
|
||||
const Log = require("./logger");
|
||||
const { applyElectronSwitches } = require("./electron_helper");
|
||||
|
||||
// Config
|
||||
let config = process.env.config ? JSON.parse(process.env.config) : {};
|
||||
// Module to control application life.
|
||||
const app = electron.app;
|
||||
|
||||
/*
|
||||
* Per default electron is started with --disable-gpu flag, if you want the gpu enabled,
|
||||
* you must set the env var ELECTRON_ENABLE_GPU=1 on startup.
|
||||
* See https://www.electronjs.org/docs/latest/tutorial/offscreen-rendering for more info.
|
||||
*/
|
||||
if (process.env.ELECTRON_ENABLE_GPU !== "1") {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
// Module to create native browser window.
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
|
||||
/*
|
||||
* Keep a global reference of the window object, if you don't, the window will
|
||||
* be closed automatically when the JavaScript object is garbage collected.
|
||||
*/
|
||||
let mainWindow;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function createWindow () {
|
||||
|
||||
/*
|
||||
* see https://www.electronjs.org/docs/latest/api/screen
|
||||
* Create a window that fills the screen's available work area.
|
||||
*/
|
||||
let electronSize = { width: 800, height: 600 };
|
||||
try {
|
||||
electronSize = electron.screen.getPrimaryDisplay().workAreaSize;
|
||||
} catch {
|
||||
Log.warn("Could not get display size, using defaults ...");
|
||||
}
|
||||
|
||||
applyElectronSwitches(app.commandLine, config.electronSwitches);
|
||||
let electronOptionsDefaults = {
|
||||
width: electronSize.width,
|
||||
height: electronSize.height,
|
||||
icon: "favicon.svg",
|
||||
x: 0,
|
||||
y: 0,
|
||||
darkTheme: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
zoomFactor: config.zoom
|
||||
},
|
||||
backgroundColor: "#000000"
|
||||
};
|
||||
|
||||
electronOptionsDefaults.show = false;
|
||||
electronOptionsDefaults.frame = false;
|
||||
electronOptionsDefaults.transparent = true;
|
||||
electronOptionsDefaults.hasShadow = false;
|
||||
electronOptionsDefaults.fullscreen = true;
|
||||
|
||||
const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions);
|
||||
|
||||
if (process.env.MOCK_DATE !== undefined) {
|
||||
// if we are running tests and we want to mock the current date
|
||||
const fakeNow = new Date(process.env.MOCK_DATE).valueOf();
|
||||
Date = class extends Date {
|
||||
constructor (...args) {
|
||||
if (args.length === 0) {
|
||||
super(fakeNow);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
const __DateNowOffset = fakeNow - Date.now();
|
||||
const __DateNow = Date.now;
|
||||
Date.now = () => __DateNow() + __DateNowOffset;
|
||||
}
|
||||
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow(electronOptions);
|
||||
|
||||
/*
|
||||
* and load the index.html of the app.
|
||||
* If config.address is not defined or is an empty string (listening on all interfaces), connect to localhost
|
||||
*/
|
||||
|
||||
let prefix;
|
||||
if ((config.tls !== null && config.tls) || config.useHttps) {
|
||||
prefix = "https://";
|
||||
} else {
|
||||
prefix = "http://";
|
||||
}
|
||||
|
||||
let address = (config.address === void 0) | (config.address === "") | (config.address === "0.0.0.0") | (config.address === "::") ? (config.address = "localhost") : config.address;
|
||||
const port = process.env.MM_PORT || config.port;
|
||||
mainWindow.loadURL(`${prefix}${address}:${port}`);
|
||||
|
||||
// Open the DevTools if run with "node --run start:dev"
|
||||
if (process.argv.includes("dev")) {
|
||||
if (process.env.mmTestMode) {
|
||||
// if we are running tests
|
||||
const devtools = new BrowserWindow(electronOptions);
|
||||
mainWindow.webContents.setDevToolsWebContents(devtools.webContents);
|
||||
}
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// simulate mouse move to hide black cursor on start
|
||||
mainWindow.webContents.on("dom-ready", () => {
|
||||
mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: 0, y: 0 });
|
||||
});
|
||||
|
||||
// Set responders for window events.
|
||||
mainWindow.on("closed", function () {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
//remove response headers that prevent sites of being embedded into iframes if configured
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
let curHeaders = details.responseHeaders;
|
||||
if (config.ignoreXOriginHeader || false) {
|
||||
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/x-frame-options/i).test(header[0])));
|
||||
}
|
||||
|
||||
if (config.ignoreContentSecurityPolicy || false) {
|
||||
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/content-security-policy/i).test(header[0])));
|
||||
}
|
||||
|
||||
callback({ responseHeaders: curHeaders });
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on("window-all-closed", function () {
|
||||
if (process.env.mmTestMode) {
|
||||
// if we are running tests
|
||||
app.quit();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", function () {
|
||||
|
||||
/*
|
||||
* On OS X it's common to re-create a window in the app when the
|
||||
* dock icon is clicked and there are no other windows open.
|
||||
*/
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* This method will be called when SIGINT is received and will call
|
||||
* each node_helper's stop function if it exists. Added to fix #1056
|
||||
*
|
||||
* Note: this is only used if running Electron. Otherwise
|
||||
* core.stop() is called by process.on("SIGINT"... in `app.js`
|
||||
*/
|
||||
app.on("before-quit", async (event) => {
|
||||
Log.log("Shutting down server...");
|
||||
event.preventDefault();
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 3000); // Force-quit after 3 seconds.
|
||||
await core.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle errors from self-signed certificates
|
||||
*/
|
||||
app.on("certificate-error", (event, webContents, url, error, certificate, callback) => {
|
||||
event.preventDefault();
|
||||
callback(true);
|
||||
});
|
||||
|
||||
if (process.env.clientonly) {
|
||||
app.whenReady().then(() => {
|
||||
Log.log("Launching client viewer application.");
|
||||
createWindow();
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Start the core application if server is run on localhost
|
||||
* This starts all node helpers and starts the webserver.
|
||||
*/
|
||||
if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].includes(config.address)) {
|
||||
core.start().then((c) => {
|
||||
config = c;
|
||||
app.whenReady().then(() => {
|
||||
Log.log("Launching application.");
|
||||
createWindow();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const Log = require("./logger");
|
||||
|
||||
/**
|
||||
* Applies Electron command-line switches from config.
|
||||
* @param {object} commandLine Electron commandLine API
|
||||
* @param {Array<string|object>} [electronSwitches] User-configured switches
|
||||
*/
|
||||
function applyElectronSwitches (commandLine, electronSwitches) {
|
||||
if (electronSwitches === undefined) return;
|
||||
if (!Array.isArray(electronSwitches)) {
|
||||
Log.error(`electronSwitches must be an array of strings or objects, got: ${JSON.stringify(electronSwitches)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sw of electronSwitches) {
|
||||
if (typeof sw === "string") {
|
||||
commandLine.appendSwitch(sw);
|
||||
Log.debug(`Activated switch: ${sw}`);
|
||||
} else if (sw && typeof sw === "object" && !Array.isArray(sw)) {
|
||||
for (const [name, value] of Object.entries(sw)) {
|
||||
commandLine.appendSwitch(name, String(value));
|
||||
Log.debug(`Activated switch: ${name}=${value}`);
|
||||
}
|
||||
} else {
|
||||
Log.error(`Invalid electronSwitches entry: ${JSON.stringify(sw)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { applyElectronSwitches };
|
||||
@@ -0,0 +1,352 @@
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { fetch: undiciFetch, Agent } = require("undici");
|
||||
const Log = require("logger");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
const MAX_SERVER_BACKOFF = 3;
|
||||
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Maps errorType to MagicMirror translation keys.
|
||||
* This allows HTTPFetcher to provide ready-to-use translation keys,
|
||||
* eliminating the need to call NodeHelper.checkFetchError().
|
||||
*/
|
||||
const ERROR_TYPE_TO_TRANSLATION = {
|
||||
AUTH_FAILURE: "MODULE_ERROR_UNAUTHORIZED",
|
||||
RATE_LIMITED: "MODULE_ERROR_RATE_LIMITED",
|
||||
SERVER_ERROR: "MODULE_ERROR_SERVER_ERROR",
|
||||
CLIENT_ERROR: "MODULE_ERROR_CLIENT_ERROR",
|
||||
NETWORK_ERROR: "MODULE_ERROR_NO_CONNECTION",
|
||||
UNKNOWN_ERROR: "MODULE_ERROR_UNSPECIFIED"
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTPFetcher - Centralized HTTP fetching with intelligent error handling
|
||||
*
|
||||
* Features:
|
||||
* - Automatic retry strategies based on HTTP status codes
|
||||
* - Exponential backoff for server errors
|
||||
* - Retry-After header parsing for rate limiting
|
||||
* - Authentication support (Basic, Bearer)
|
||||
* - Self-signed certificate support
|
||||
* @augments EventEmitter
|
||||
* @fires HTTPFetcher#response - When fetch succeeds (including 304 Not Modified)
|
||||
* @fires HTTPFetcher#error - When fetch fails or returns non-ok response
|
||||
* @example
|
||||
* const fetcher = new HTTPFetcher(url, { reloadInterval: 60000 });
|
||||
* fetcher.on('response', (response) => { ... });
|
||||
* fetcher.on('error', (errorInfo) => { ... });
|
||||
* fetcher.startPeriodicFetch();
|
||||
*/
|
||||
class HTTPFetcher extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Calculates exponential backoff delay for retries
|
||||
* @param {number} attempt - Attempt number (1-based)
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number} [options.baseDelay] - Initial delay in ms (default: 15s)
|
||||
* @param {number} [options.maxDelay] - Maximum delay in ms (default: 5min)
|
||||
* @returns {number} Delay in milliseconds
|
||||
* @example
|
||||
* HTTPFetcher.calculateBackoffDelay(1) // 15000 (15s)
|
||||
* HTTPFetcher.calculateBackoffDelay(2) // 30000 (30s)
|
||||
* HTTPFetcher.calculateBackoffDelay(3) // 60000 (60s)
|
||||
* HTTPFetcher.calculateBackoffDelay(6) // 300000 (5min, capped)
|
||||
*/
|
||||
static calculateBackoffDelay (attempt, { baseDelay = 15000, maxDelay = 300000 } = {}) {
|
||||
return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HTTPFetcher instance
|
||||
* @param {string} url - The URL to fetch
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min)
|
||||
* @param {object} [options.auth] - Authentication options
|
||||
* @param {string} [options.auth.method] - 'basic' or 'bearer'
|
||||
* @param {string} [options.auth.user] - Username for basic auth
|
||||
* @param {string} [options.auth.pass] - Password or token
|
||||
* @param {boolean} [options.selfSignedCert] - Accept self-signed certificates
|
||||
* @param {object} [options.headers] - Additional headers to send
|
||||
* @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3)
|
||||
* @param {number} [options.timeout] - Request timeout in ms (default: 30000)
|
||||
* @param {string} [options.logContext] - Optional context for log messages (e.g., provider name)
|
||||
*/
|
||||
constructor (url, options = {}) {
|
||||
super();
|
||||
|
||||
this.url = url;
|
||||
this.reloadInterval = options.reloadInterval || 5 * 60 * 1000;
|
||||
this.auth = options.auth || null;
|
||||
this.selfSignedCert = options.selfSignedCert || false;
|
||||
this.customHeaders = options.headers || {};
|
||||
this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF;
|
||||
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||
this.logContext = options.logContext ? `[${options.logContext}] ` : "";
|
||||
|
||||
this.reloadTimer = null;
|
||||
this.serverErrorCount = 0;
|
||||
this.networkErrorCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending reload timer
|
||||
*/
|
||||
clearTimer () {
|
||||
if (this.reloadTimer) {
|
||||
clearTimeout(this.reloadTimer);
|
||||
this.reloadTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next fetch.
|
||||
* If no delay is provided, uses reloadInterval.
|
||||
* If delay is provided but very short (< 1 second), clamps to reloadInterval
|
||||
* to prevent hammering servers.
|
||||
* @param {number} [delay] - Delay in milliseconds
|
||||
*/
|
||||
scheduleNextFetch (delay) {
|
||||
let nextDelay = delay ?? this.reloadInterval;
|
||||
|
||||
// Only clamp if delay is unreasonably short (< 1 second)
|
||||
// This allows respecting Retry-After headers while preventing abuse
|
||||
if (nextDelay < 1000) {
|
||||
nextDelay = this.reloadInterval;
|
||||
}
|
||||
|
||||
// Don't schedule in test mode
|
||||
if (process.env.mmTestMode === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadTimer = setTimeout(() => this.fetch(), nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts periodic fetching
|
||||
*/
|
||||
startPeriodicFetch () {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the options object for fetch
|
||||
* @returns {object} Options object containing headers (and dispatcher if needed)
|
||||
*/
|
||||
getRequestOptions () {
|
||||
const headers = {
|
||||
"User-Agent": getUserAgent(),
|
||||
...this.customHeaders
|
||||
};
|
||||
const options = { headers };
|
||||
|
||||
if (this.selfSignedCert) {
|
||||
options.dispatcher = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.auth) {
|
||||
if (this.auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${this.auth.pass}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value
|
||||
* @param {string} retryAfter - The Retry-After header value
|
||||
* @returns {number|null} Milliseconds to wait or null if parsing failed
|
||||
*/
|
||||
#parseRetryAfter (retryAfter) {
|
||||
// Try parsing as seconds
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
// Try parsing as HTTP-date
|
||||
const retryDate = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(retryDate)) {
|
||||
return Math.max(0, retryDate - Date.now());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shortened version of the URL for log messages.
|
||||
* @returns {string} Shortened URL
|
||||
*/
|
||||
#shortenUrl () {
|
||||
try {
|
||||
const urlObj = new URL(this.url);
|
||||
return `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`;
|
||||
} catch {
|
||||
return this.url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the retry delay for a non-ok response
|
||||
* @param {Response} response - The fetch Response object
|
||||
* @returns {{delay: number, errorInfo: object}} Computed retry delay and error info
|
||||
*/
|
||||
#getDelayForResponse (response) {
|
||||
const { status } = response;
|
||||
let delay = this.reloadInterval;
|
||||
let message;
|
||||
let errorType = "UNKNOWN_ERROR";
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
errorType = "AUTH_FAILURE";
|
||||
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
|
||||
message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`;
|
||||
Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`);
|
||||
} else if (status === 429) {
|
||||
errorType = "RATE_LIMITED";
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const parsed = retryAfter ? this.#parseRetryAfter(retryAfter) : null;
|
||||
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`;
|
||||
Log.warn(`${this.logContext}${this.#shortenUrl()} - ${message}`);
|
||||
} else if (status >= 500) {
|
||||
errorType = "SERVER_ERROR";
|
||||
this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries);
|
||||
if (this.serverErrorCount >= this.maxRetries) {
|
||||
delay = this.reloadInterval;
|
||||
message = `Server error (${status}). Max retries reached, retrying at configured interval (${Math.round(delay / 1000)}s).`;
|
||||
} else {
|
||||
delay = HTTPFetcher.calculateBackoffDelay(this.serverErrorCount, {
|
||||
maxDelay: this.reloadInterval
|
||||
});
|
||||
message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 1000)}s.`;
|
||||
}
|
||||
Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`);
|
||||
} else if (status >= 400) {
|
||||
errorType = "CLIENT_ERROR";
|
||||
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`;
|
||||
Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`);
|
||||
} else {
|
||||
message = `Unexpected HTTP status ${status}.`;
|
||||
Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
delay,
|
||||
errorInfo: this.#createErrorInfo(message, status, errorType, delay)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error info object
|
||||
* @param {string} message - Error message
|
||||
* @param {number|null} status - HTTP status code or null for network errors
|
||||
* @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR
|
||||
* @param {number} retryAfter - Delay until next retry in ms
|
||||
* @param {Error} [originalError] - The original error if any
|
||||
* @returns {object} Error info object with translationKey for direct use
|
||||
*/
|
||||
#createErrorInfo (message, status, errorType, retryAfter, originalError = null) {
|
||||
return {
|
||||
message,
|
||||
status,
|
||||
errorType,
|
||||
translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED",
|
||||
retryAfter,
|
||||
retryCount: errorType === "NETWORK_ERROR" ? this.networkErrorCount : this.serverErrorCount,
|
||||
url: this.url,
|
||||
originalError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the HTTP fetch and emits appropriate events
|
||||
* @fires HTTPFetcher#response
|
||||
* @fires HTTPFetcher#error
|
||||
*/
|
||||
async fetch () {
|
||||
this.clearTimer();
|
||||
|
||||
let nextDelay = this.reloadInterval;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const requestOptions = this.getRequestOptions();
|
||||
// Use undici.fetch when a custom dispatcher is present (e.g. selfSignedCert),
|
||||
// because Node's global fetch and npm undici@8 Agents are incompatible.
|
||||
// For regular requests, use globalThis.fetch so MSW and other interceptors work.
|
||||
const fetchFn = requestOptions.dispatcher ? undiciFetch : globalThis.fetch;
|
||||
const response = await fetchFn(this.url, {
|
||||
...requestOptions,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const isSuccessfulResponse = response.ok || response.status === 304;
|
||||
|
||||
if (isSuccessfulResponse) {
|
||||
// Reset error counts on success
|
||||
this.serverErrorCount = 0;
|
||||
this.networkErrorCount = 0;
|
||||
|
||||
/**
|
||||
* Response event - fired when fetch succeeds (including 304)
|
||||
* @event HTTPFetcher#response
|
||||
* @type {Response}
|
||||
*/
|
||||
this.emit("response", response);
|
||||
} else {
|
||||
const { delay, errorInfo } = this.#getDelayForResponse(response);
|
||||
nextDelay = delay;
|
||||
this.emit("error", errorInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
const isTimeout = error.name === "AbortError";
|
||||
const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`;
|
||||
|
||||
this.networkErrorCount = Math.min(this.networkErrorCount + 1, this.maxRetries);
|
||||
const exhausted = this.networkErrorCount >= this.maxRetries;
|
||||
|
||||
if (exhausted) {
|
||||
nextDelay = this.reloadInterval;
|
||||
Log.error(`${this.logContext}${this.#shortenUrl()} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`);
|
||||
} else {
|
||||
nextDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, {
|
||||
maxDelay: this.reloadInterval
|
||||
});
|
||||
const retryMsg = `${this.logContext}${this.#shortenUrl()} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`;
|
||||
if (this.networkErrorCount <= 2) {
|
||||
Log.warn(retryMsg);
|
||||
} else {
|
||||
Log.error(retryMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = this.#createErrorInfo(
|
||||
message,
|
||||
null,
|
||||
"NETWORK_ERROR",
|
||||
nextDelay,
|
||||
error
|
||||
);
|
||||
this.emit("error", errorInfo);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
this.scheduleNextFetch(nextDelay);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HTTPFetcher;
|
||||
@@ -0,0 +1,109 @@
|
||||
const ipaddr = require("ipaddr.js");
|
||||
const Log = require("logger");
|
||||
|
||||
/**
|
||||
* Checks if a client IP matches any entry in the whitelist
|
||||
* @param {string} clientIp - The IP address to check
|
||||
* @param {string[]} whitelist - Array of IP addresses or CIDR ranges
|
||||
* @returns {boolean} True if IP is allowed
|
||||
*/
|
||||
function isAllowed (clientIp, whitelist) {
|
||||
try {
|
||||
const addr = ipaddr.process(clientIp);
|
||||
|
||||
return whitelist.some((entry) => {
|
||||
try {
|
||||
// CIDR notation
|
||||
if (entry.includes("/")) {
|
||||
const [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry);
|
||||
return addr.match(rangeAddr, prefixLen);
|
||||
}
|
||||
|
||||
// Single IP address - let ipaddr.process normalize both
|
||||
const allowedAddr = ipaddr.process(entry);
|
||||
return addr.toString() === allowedAddr.toString();
|
||||
} catch {
|
||||
Log.warn(`Invalid whitelist entry: ${entry}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
Log.warn(`Failed to parse client IP: ${clientIp}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a client IP for both Express and Socket.IO requests.
|
||||
* If the direct peer is loopback, trust the first X-Forwarded-For value (local reverse proxy case).
|
||||
* Otherwise ignore X-Forwarded-For to prevent spoofing.
|
||||
* @param {object} req - Incoming request object (Express request or Socket.IO handshake request)
|
||||
* @returns {string} The resolved client IP address
|
||||
*/
|
||||
function resolveClientIp (req) {
|
||||
const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || req.ip;
|
||||
const LOOPBACK_WHITELIST = ["127.0.0.1", "::ffff:127.0.0.1", "::1"];
|
||||
|
||||
if (isAllowed(directIp, LOOPBACK_WHITELIST)) {
|
||||
const forwardedFor = req.headers?.["x-forwarded-for"];
|
||||
if (typeof forwardedFor === "string" && forwardedFor.trim().length > 0) {
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return directIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express middleware for IP whitelisting
|
||||
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
|
||||
* @returns {import("express").RequestHandler} Express middleware function
|
||||
*/
|
||||
function ipAccessControl (whitelist) {
|
||||
// Empty whitelist means allow all
|
||||
if (!Array.isArray(whitelist) || whitelist.length === 0) {
|
||||
return function (req, res, next) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return function (req, res, next) {
|
||||
const clientIp = resolveClientIp(req);
|
||||
|
||||
if (isAllowed(clientIp, whitelist)) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
next();
|
||||
} else {
|
||||
Log.warn(`IP ${clientIp} is not allowed to access the mirror`);
|
||||
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Socket.IO `allowRequest` handler that enforces the same IP whitelist as the HTTP middleware.
|
||||
* This closes the gap where Socket.IO handshakes bypassed the Express-only `ipAccessControl` middleware.
|
||||
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
|
||||
* @returns {(req: object, callback: (err: string | null, success: boolean) => void) => void} Socket.IO allowRequest handler
|
||||
*/
|
||||
function socketIpAccessControl (whitelist) {
|
||||
// Empty whitelist means allow all
|
||||
if (!Array.isArray(whitelist) || whitelist.length === 0) {
|
||||
return function (req, callback) {
|
||||
callback(null, true); // allow the connection
|
||||
};
|
||||
}
|
||||
|
||||
return function (req, callback) {
|
||||
const clientIp = resolveClientIp(req);
|
||||
if (isAllowed(clientIp, whitelist)) {
|
||||
callback(null, true); // allow the connection
|
||||
} else {
|
||||
Log.warn(`IP ${clientIp} is not allowed to connect to the mirror socket`);
|
||||
callback("This device is not allowed to access your mirror.", false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { ipAccessControl, socketIpAccessControl };
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/* global defaultModules, vendor */
|
||||
|
||||
/* Module state */
|
||||
|
||||
const loadedModuleFiles = [];
|
||||
const loadedFiles = [];
|
||||
const moduleObjects = [];
|
||||
|
||||
/**
|
||||
* Get environment variables from config.
|
||||
* @returns {object} Env vars with modulesDir and customCss paths from config.
|
||||
*/
|
||||
function getEnvVarsFromConfig () {
|
||||
return {
|
||||
modulesDir: config.foreignModulesDir || "modules",
|
||||
defaultModulesDir: config.defaultModulesDir || "defaultmodules",
|
||||
customCss: config.customCss || "config/custom.css"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve object of env variables.
|
||||
* @returns {object} with key: values as assembled in js/server_functions.js
|
||||
*/
|
||||
async function getEnvVars () {
|
||||
// In test mode, skip server fetch and use config values directly
|
||||
if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") {
|
||||
return getEnvVarsFromConfig();
|
||||
}
|
||||
|
||||
// In production, fetch env vars from server
|
||||
try {
|
||||
const res = await fetch(new URL("env", `${location.origin}${config.basePath}`));
|
||||
return JSON.parse(await res.text());
|
||||
} catch (error) {
|
||||
// Fallback to config values if server fetch fails
|
||||
Log.error("Unable to retrieve env configuration", error);
|
||||
return getEnvVarsFromConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops through all modules and requests start for every module.
|
||||
*/
|
||||
async function startModules () {
|
||||
const modulePromises = [];
|
||||
for (const module of moduleObjects) {
|
||||
try {
|
||||
modulePromises.push(module.start());
|
||||
} catch (error) {
|
||||
Log.error(`Error when starting node_helper for module ${module.name}:`);
|
||||
Log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(modulePromises);
|
||||
|
||||
// Log errors that happened during async node_helper startup
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
Log.error(result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// Notify core of loaded modules.
|
||||
MM.modulesStarted(moduleObjects);
|
||||
|
||||
// Starting modules also hides any modules that have requested to be initially hidden
|
||||
for (const thisModule of moduleObjects) {
|
||||
if (thisModule.data.hiddenOnStartup) {
|
||||
Log.info(`Initially hiding ${thisModule.name}`);
|
||||
thisModule.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve list of all modules.
|
||||
* @returns {object[]} module data as configured in config
|
||||
*/
|
||||
function getAllModules () {
|
||||
const AllModules = config.modules.filter((module) => (module.module !== undefined) && (MM.getAvailableModulePositions.indexOf(module.position) > -1 || typeof (module.position) === "undefined"));
|
||||
return AllModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate array with module information including module paths.
|
||||
* @returns {object[]} Module information.
|
||||
*/
|
||||
async function getModuleData () {
|
||||
const modules = getAllModules();
|
||||
const moduleFiles = [];
|
||||
const envVars = await getEnvVars();
|
||||
|
||||
modules.forEach(function (moduleData, index) {
|
||||
const module = moduleData.module;
|
||||
|
||||
const elements = module.split("/");
|
||||
const moduleName = elements[elements.length - 1];
|
||||
let moduleFolder = `${envVars.modulesDir}/${module}`;
|
||||
|
||||
if (defaultModules.indexOf(moduleName) !== -1) {
|
||||
const defaultModuleFolder = `${envVars.defaultModulesDir}/${module}`;
|
||||
if (window.name !== "jsdom") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
// running in test mode, allow defaultModules placed under moduleDir for testing
|
||||
if (envVars.modulesDir === "modules") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleData.disabled === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
moduleFiles.push({
|
||||
index: index,
|
||||
identifier: `module_${index}_${module}`,
|
||||
name: moduleName,
|
||||
path: `${moduleFolder}/`,
|
||||
file: `${moduleName}.js`,
|
||||
position: moduleData.position,
|
||||
animateIn: moduleData.animateIn,
|
||||
animateOut: moduleData.animateOut,
|
||||
hiddenOnStartup: moduleData.hiddenOnStartup,
|
||||
header: moduleData.header,
|
||||
configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false,
|
||||
config: moduleData.config,
|
||||
classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module,
|
||||
order: (typeof moduleData.order === "number" && Number.isInteger(moduleData.order)) ? moduleData.order : 0
|
||||
});
|
||||
});
|
||||
|
||||
return moduleFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load modules via ajax request and create module objects.
|
||||
* @param {object} module Information about the module we want to load.
|
||||
* @returns {Promise<void>} resolved when module is loaded
|
||||
*/
|
||||
async function loadModule (module) {
|
||||
const url = module.path + module.file;
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function afterLoad () {
|
||||
const moduleObject = Module.create(module.name);
|
||||
if (moduleObject) {
|
||||
await bootstrapModule(module, moduleObject);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedModuleFiles.indexOf(url) !== -1) {
|
||||
await afterLoad();
|
||||
} else {
|
||||
await loadFile(url);
|
||||
loadedModuleFiles.push(url);
|
||||
await afterLoad();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap modules by setting the module data and loading the scripts & styles.
|
||||
* @param {object} module Information about the module we want to load.
|
||||
* @param {Module} mObj Modules instance.
|
||||
*/
|
||||
async function bootstrapModule (module, mObj) {
|
||||
Log.info(`Bootstrapping module: ${module.name}`);
|
||||
mObj.setData(module);
|
||||
|
||||
await mObj.loadScripts();
|
||||
Log.log(`Scripts loaded for: ${module.name}`);
|
||||
|
||||
await mObj.loadStyles();
|
||||
Log.log(`Styles loaded for: ${module.name}`);
|
||||
|
||||
await mObj.loadTranslations();
|
||||
Log.log(`Translations loaded for: ${module.name}`);
|
||||
|
||||
moduleObjects.push(mObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a script or stylesheet by adding it to the dom.
|
||||
* @param {string} fileName Path of the file we want to load.
|
||||
* @returns {Promise} resolved when the file is loaded
|
||||
*/
|
||||
function loadFile (fileName) {
|
||||
const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1);
|
||||
let script, stylesheet;
|
||||
|
||||
switch (extension.toLowerCase()) {
|
||||
case "js":
|
||||
return new Promise((resolve) => {
|
||||
Log.log(`Load script: ${fileName}`);
|
||||
script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = fileName;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
Log.error("Error on loading script:", fileName);
|
||||
script.remove();
|
||||
resolve();
|
||||
};
|
||||
document.getElementsByTagName("body")[0].appendChild(script);
|
||||
});
|
||||
case "mjs":
|
||||
return new Promise((resolve) => {
|
||||
Log.log(`Load module script: ${fileName}`);
|
||||
script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.src = fileName;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
Log.error("Error on loading module script:", fileName);
|
||||
script.remove();
|
||||
resolve();
|
||||
};
|
||||
document.getElementsByTagName("body")[0].appendChild(script);
|
||||
});
|
||||
case "css":
|
||||
return new Promise((resolve) => {
|
||||
Log.log(`Load stylesheet: ${fileName}`);
|
||||
|
||||
stylesheet = document.createElement("link");
|
||||
stylesheet.rel = "stylesheet";
|
||||
stylesheet.type = "text/css";
|
||||
stylesheet.href = fileName;
|
||||
stylesheet.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
stylesheet.onerror = function () {
|
||||
Log.error("Error on loading stylesheet:", fileName);
|
||||
stylesheet.remove();
|
||||
resolve();
|
||||
};
|
||||
document.getElementsByTagName("head")[0].appendChild(stylesheet);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Public Methods */
|
||||
|
||||
export const Loader = {
|
||||
|
||||
/**
|
||||
* Load all modules as defined in the config.
|
||||
*/
|
||||
async loadModules () {
|
||||
const moduleData = await getModuleData();
|
||||
const envVars = await getEnvVars();
|
||||
const customCss = envVars.customCss;
|
||||
|
||||
// Load all modules
|
||||
for (const module of moduleData) {
|
||||
await loadModule(module);
|
||||
}
|
||||
|
||||
// Load custom.css
|
||||
// Since this happens after loading the modules,
|
||||
// it overwrites the default styles.
|
||||
await loadFile(customCss);
|
||||
|
||||
// Start all modules.
|
||||
await startModules();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a file (script or stylesheet).
|
||||
* Prevent double loading and search for files defined in js/vendor.js.
|
||||
* @param {string} fileName Path of the file we want to load.
|
||||
* @param {Module} module The module that calls the loadFile function.
|
||||
* @returns {Promise} resolved when the file is loaded
|
||||
*/
|
||||
loadFileForModule (fileName, module) {
|
||||
if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) {
|
||||
Log.log(`File already loaded: ${fileName}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) {
|
||||
// This is an absolute or relative path.
|
||||
// Load it and then return.
|
||||
loadedFiles.push(fileName.toLowerCase());
|
||||
return loadFile(fileName);
|
||||
}
|
||||
|
||||
if (vendor[fileName] !== undefined) {
|
||||
// This file is defined in js/vendor.js.
|
||||
// Load it from its location.
|
||||
loadedFiles.push(fileName.toLowerCase());
|
||||
return loadFile(`${vendor[fileName]}`);
|
||||
}
|
||||
|
||||
// File not loaded yet.
|
||||
// Load it based on the module path.
|
||||
loadedFiles.push(fileName.toLowerCase());
|
||||
return loadFile(module.file(fileName));
|
||||
}
|
||||
};
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Logger for MagicMirror² — works both in Node.js (CommonJS) and the browser (global).
|
||||
(function () {
|
||||
if (typeof module !== "undefined") {
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
const { styleText } = require("node:util");
|
||||
|
||||
const LABEL_COLORS = { error: "red", warn: "yellow", debug: "bgBlue", info: "blue" };
|
||||
const MSG_COLORS = { error: "red", warn: "yellow", info: "blue" };
|
||||
|
||||
const formatTimestamp = () => {
|
||||
const d = new Date();
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
const pad3 = (n) => String(n).padStart(3, "0");
|
||||
const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
|
||||
return `[${date} ${time}]`;
|
||||
};
|
||||
|
||||
const getCallerPrefix = () => {
|
||||
try {
|
||||
const lines = new Error().stack.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.includes("node:") || line.includes("js/logger.js") || line.includes("node_modules")) continue;
|
||||
const match = line.match(/\((.+?\.js):\d+:\d+\)/) || line.match(/at\s+(.+?\.js):\d+:\d+/);
|
||||
if (match) {
|
||||
const file = match[1];
|
||||
const baseName = file.replace(/.*\/(.*)\.js/, "$1");
|
||||
const parentDir = file.replace(/.*\/(.*)\/.*\.js/, "$1");
|
||||
return styleText("gray", parentDir === "js" ? `[${baseName}]` : `[${parentDir}]`);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return styleText("gray", "[unknown]");
|
||||
};
|
||||
|
||||
// Patch console methods to prepend timestamp, level label, and caller prefix.
|
||||
for (const method of ["debug", "log", "info", "warn", "error"]) {
|
||||
const original = console[method].bind(console);
|
||||
const labelRaw = `[${method.toUpperCase()}]`.padEnd(7);
|
||||
const label = LABEL_COLORS[method] ? styleText(LABEL_COLORS[method], labelRaw) : labelRaw;
|
||||
console[method] = (...args) => {
|
||||
const prefix = `${formatTimestamp()} ${label} ${getCallerPrefix()}`;
|
||||
const msgColor = MSG_COLORS[method];
|
||||
if (msgColor && args.length > 0 && typeof args[0] === "string") {
|
||||
original(prefix, styleText(msgColor, args[0]), ...args.slice(1));
|
||||
} else {
|
||||
original(prefix, ...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Node, CommonJS
|
||||
module.exports = makeLogger();
|
||||
} else {
|
||||
// Browser globals
|
||||
window.Log = makeLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the logger object. Logging is disabled when running in test mode
|
||||
* (Node.js) or inside jsdom (browser).
|
||||
* @returns {object} The logger object with log level methods.
|
||||
*/
|
||||
function makeLogger () {
|
||||
const enableLog = typeof module !== "undefined"
|
||||
? process.env.mmTestMode !== "true"
|
||||
: typeof window === "object" && window.name !== "jsdom";
|
||||
|
||||
let logLevel;
|
||||
|
||||
if (enableLog) {
|
||||
logLevel = {
|
||||
debug: console.debug.bind(console),
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
group: console.group.bind(console),
|
||||
groupCollapsed: console.groupCollapsed.bind(console),
|
||||
groupEnd: console.groupEnd.bind(console),
|
||||
time: console.time.bind(console),
|
||||
timeEnd: console.timeEnd.bind(console),
|
||||
timeStamp: console.timeStamp.bind(console)
|
||||
};
|
||||
|
||||
// Only these methods are affected by setLogLevel.
|
||||
// Utility methods (group, time, etc.) are always active.
|
||||
logLevel.setLogLevel = function (newLevel) {
|
||||
for (const key of ["debug", "log", "info", "warn", "error"]) {
|
||||
const disabled = newLevel && !newLevel.includes(key.toUpperCase());
|
||||
logLevel[key] = disabled ? function () {} : console[key].bind(console);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
logLevel = {
|
||||
debug () {},
|
||||
log () {},
|
||||
info () {},
|
||||
warn () {},
|
||||
error () {},
|
||||
group () {},
|
||||
groupCollapsed () {},
|
||||
groupEnd () {},
|
||||
time () {},
|
||||
timeEnd () {},
|
||||
timeStamp () {}
|
||||
};
|
||||
|
||||
logLevel.setLogLevel = function () {};
|
||||
}
|
||||
|
||||
return logLevel;
|
||||
}
|
||||
}());
|
||||
+714
@@ -0,0 +1,714 @@
|
||||
/* global addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */
|
||||
|
||||
// eslint-disable-next-line import-x/extensions
|
||||
import { Loader } from "./loader.js";
|
||||
|
||||
let modules = [];
|
||||
|
||||
/**
|
||||
* Create dom objects for all modules that are configured for a specific position.
|
||||
*/
|
||||
async function createDomObjects () {
|
||||
const domCreationPromises = [];
|
||||
|
||||
modules.forEach(function (module) {
|
||||
if (typeof module.data.position !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
let haveAnimateIn = null;
|
||||
// check if have valid animateIn in module definition (module.data.animateIn)
|
||||
if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateIn = module.data.animateIn;
|
||||
|
||||
const wrapper = selectWrapper(module.data.position);
|
||||
|
||||
const dom = document.createElement("div");
|
||||
dom.id = module.identifier;
|
||||
dom.className = module.name;
|
||||
|
||||
if (typeof module.data.classes === "string") {
|
||||
dom.className = `module ${dom.className} ${module.data.classes}`;
|
||||
}
|
||||
|
||||
dom.style.order = (typeof module.data.order === "number" && Number.isInteger(module.data.order)) ? module.data.order : 0;
|
||||
|
||||
dom.opacity = 0;
|
||||
wrapper.appendChild(dom);
|
||||
|
||||
const moduleHeader = document.createElement("header");
|
||||
moduleHeader.innerHTML = module.getHeader();
|
||||
moduleHeader.className = "module-header";
|
||||
dom.appendChild(moduleHeader);
|
||||
|
||||
if (typeof module.getHeader() === "undefined" || module.getHeader() !== "") {
|
||||
moduleHeader.style.display = "none;";
|
||||
} else {
|
||||
moduleHeader.style.display = "block;";
|
||||
}
|
||||
|
||||
const moduleContent = document.createElement("div");
|
||||
moduleContent.className = "module-content";
|
||||
dom.appendChild(moduleContent);
|
||||
|
||||
domCreationPromises.push(createModuleDom(module, haveAnimateIn));
|
||||
});
|
||||
|
||||
updateWrapperStates();
|
||||
|
||||
try {
|
||||
await Promise.all(domCreationPromises);
|
||||
_sendNotification("DOM_OBJECTS_CREATED");
|
||||
} catch (error) {
|
||||
Log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and render a module DOM, then notify the module.
|
||||
* @param {Module} module The module to render.
|
||||
* @param {string|null} haveAnimateIn Optional animateIn animation name.
|
||||
* @returns {Promise<void>} Resolved when module DOM is created.
|
||||
*/
|
||||
async function createModuleDom (module, haveAnimateIn) {
|
||||
if (haveAnimateIn) {
|
||||
await _updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true);
|
||||
} else {
|
||||
await _updateDom(module, 0);
|
||||
}
|
||||
|
||||
_sendNotification("MODULE_DOM_CREATED", null, null, module);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the wrapper dom object for a specific position.
|
||||
* @param {string} position The name of the position.
|
||||
* @returns {HTMLElement | void} the wrapper element
|
||||
*/
|
||||
function selectWrapper (position) {
|
||||
const classes = position.replace("_", " ");
|
||||
const parentWrapper = document.getElementsByClassName(classes);
|
||||
if (parentWrapper.length > 0) {
|
||||
const wrapper = parentWrapper[0].getElementsByClassName("container");
|
||||
if (wrapper.length > 0) {
|
||||
return wrapper[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
* @param {Module} [sendTo] The (optional) module to send the notification to.
|
||||
*/
|
||||
function _sendNotification (notification, payload, sender, sendTo) {
|
||||
for (const m in modules) {
|
||||
const module = modules[m];
|
||||
if (module !== sender && (!sendTo || module === sendTo)) {
|
||||
module.notificationReceived(notification, payload, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the dom for a specific module.
|
||||
* @param {Module} module The module that needs an update.
|
||||
* @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates)
|
||||
* @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start of MagicMirror)
|
||||
* @returns {Promise<void>} Resolved when the dom is fully updated.
|
||||
*/
|
||||
async function _updateDom (module, updateOptions, createAnimatedDom = false) {
|
||||
let speed = updateOptions;
|
||||
let animateOut = null;
|
||||
let animateIn = null;
|
||||
if (typeof updateOptions === "object") {
|
||||
if (typeof updateOptions.options === "object" && updateOptions.options.speed !== undefined) {
|
||||
speed = updateOptions.options.speed;
|
||||
Log.debug(`updateDom: ${module.identifier} Has speed in object: ${speed}`);
|
||||
if (typeof updateOptions.options.animate === "object") {
|
||||
animateOut = updateOptions.options.animate.out;
|
||||
animateIn = updateOptions.options.animate.in;
|
||||
Log.debug(`updateDom: ${module.identifier} Has animate in object: out->${animateOut}, in->${animateIn}`);
|
||||
}
|
||||
} else {
|
||||
Log.debug(`updateDom: ${module.identifier} Has no speed in object`);
|
||||
speed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const newHeader = module.getHeader();
|
||||
const newContent = await module.getDom();
|
||||
await updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the dom with the specified content
|
||||
* @param {Module} module The module that needs an update.
|
||||
* @param {number} [speed] The (optional) number of milliseconds for the animation.
|
||||
* @param {string} newHeader The new header that is generated.
|
||||
* @param {HTMLElement} newContent The new content that is generated.
|
||||
* @param {string} [animateOut] AnimateCss animation name before hidden
|
||||
* @param {string} [animateIn] AnimateCss animation name on show
|
||||
* @param {boolean} [createAnimatedDom] If true, apply content and trigger only animateIn (used on first start).
|
||||
* @returns {Promise<void>} Resolved after the module DOM update is applied or hide/show transition is scheduled.
|
||||
*/
|
||||
async function updateDomWithContent (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) {
|
||||
if (module.hidden || !speed) {
|
||||
updateModuleContent(module, newHeader, newContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!moduleNeedsUpdate(module, newHeader, newContent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (createAnimatedDom && animateIn !== null) {
|
||||
Log.debug(`${module.identifier} createAnimatedDom (${animateIn})`);
|
||||
updateModuleContent(module, newHeader, newContent);
|
||||
if (!module.hidden) {
|
||||
_showModule(module, speed, null, { animate: animateIn });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => _hideModule(module, speed / 2, resolve, { animate: animateOut }));
|
||||
updateModuleContent(module, newHeader, newContent);
|
||||
if (!module.hidden) {
|
||||
await new Promise((resolve) => _showModule(module, speed / 2, resolve, { animate: animateIn }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the content has changed.
|
||||
* @param {Module} module The module to check.
|
||||
* @param {string} newHeader The new header that is generated.
|
||||
* @param {HTMLElement} newContent The new content that is generated.
|
||||
* @returns {boolean} True if the module need an update, false otherwise
|
||||
*/
|
||||
function moduleNeedsUpdate (module, newHeader, newContent) {
|
||||
const moduleWrapper = document.getElementById(module.identifier);
|
||||
if (moduleWrapper === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentWrapper = moduleWrapper.getElementsByClassName("module-content");
|
||||
const headerWrapper = moduleWrapper.getElementsByClassName("module-header");
|
||||
|
||||
let headerNeedsUpdate = false;
|
||||
let contentNeedsUpdate;
|
||||
|
||||
if (headerWrapper.length > 0) {
|
||||
headerNeedsUpdate = newHeader !== headerWrapper[0].innerHTML;
|
||||
}
|
||||
|
||||
const tempContentWrapper = document.createElement("div");
|
||||
tempContentWrapper.appendChild(newContent);
|
||||
contentNeedsUpdate = tempContentWrapper.innerHTML !== contentWrapper[0].innerHTML;
|
||||
|
||||
return headerNeedsUpdate || contentNeedsUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content of a module on screen.
|
||||
* @param {Module} module The module to check.
|
||||
* @param {string} newHeader The new header that is generated.
|
||||
* @param {HTMLElement} newContent The new content that is generated.
|
||||
*/
|
||||
function updateModuleContent (module, newHeader, newContent) {
|
||||
const moduleWrapper = document.getElementById(module.identifier);
|
||||
if (moduleWrapper === null) {
|
||||
return;
|
||||
}
|
||||
const headerWrapper = moduleWrapper.getElementsByClassName("module-header");
|
||||
const contentWrapper = moduleWrapper.getElementsByClassName("module-content");
|
||||
|
||||
contentWrapper[0].innerHTML = "";
|
||||
contentWrapper[0].appendChild(newContent);
|
||||
|
||||
headerWrapper[0].innerHTML = newHeader;
|
||||
if (headerWrapper.length > 0 && newHeader) {
|
||||
headerWrapper[0].style.display = "block";
|
||||
} else {
|
||||
headerWrapper[0].style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the module.
|
||||
* @param {Module} module The module to hide.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
function _hideModule (module, speed, callback, options = {}) {
|
||||
// set lockString if set in options.
|
||||
if (options.lockString) {
|
||||
if (module.lockStrings.indexOf(options.lockString) === -1) {
|
||||
module.lockStrings.push(options.lockString);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleWrapper = document.getElementById(module.identifier);
|
||||
if (moduleWrapper !== null) {
|
||||
clearTimeout(module.showHideTimer);
|
||||
// reset all animations if needed
|
||||
if (module.hasAnimateOut) {
|
||||
removeAnimateCSS(module.identifier, module.hasAnimateOut);
|
||||
Log.debug(`${module.identifier} Force remove animateOut (in hide): ${module.hasAnimateOut}`);
|
||||
module.hasAnimateOut = false;
|
||||
}
|
||||
if (module.hasAnimateIn) {
|
||||
removeAnimateCSS(module.identifier, module.hasAnimateIn);
|
||||
Log.debug(`${module.identifier} Force remove animateIn (in hide): ${module.hasAnimateIn}`);
|
||||
module.hasAnimateIn = false;
|
||||
}
|
||||
// haveAnimateName for verify if we are using AnimateCSS library
|
||||
// we check AnimateCSSOut Array for validate it
|
||||
// and finally return the animate name or `null` (for default MM² animation)
|
||||
let haveAnimateName = null;
|
||||
// check if have valid animateOut in module definition (module.data.animateOut)
|
||||
if (module.data.animateOut && AnimateCSSOut.indexOf(module.data.animateOut) !== -1) haveAnimateName = module.data.animateOut;
|
||||
// can't be override with options.animate
|
||||
else if (options.animate && AnimateCSSOut.indexOf(options.animate) !== -1) haveAnimateName = options.animate;
|
||||
|
||||
if (haveAnimateName) {
|
||||
// with AnimateCSS
|
||||
Log.debug(`${module.identifier} Has animateOut: ${haveAnimateName}`);
|
||||
module.hasAnimateOut = haveAnimateName;
|
||||
addAnimateCSS(module.identifier, haveAnimateName, speed / 1000);
|
||||
module.showHideTimer = setTimeout(function () {
|
||||
removeAnimateCSS(module.identifier, haveAnimateName);
|
||||
Log.debug(`${module.identifier} Remove animateOut: ${module.hasAnimateOut}`);
|
||||
// AnimateCSS is now done
|
||||
moduleWrapper.style.opacity = 0;
|
||||
moduleWrapper.classList.add("hidden");
|
||||
moduleWrapper.style.position = "fixed";
|
||||
module.hasAnimateOut = false;
|
||||
|
||||
updateWrapperStates();
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, speed);
|
||||
} else {
|
||||
// default MM² Animate
|
||||
moduleWrapper.style.transition = `opacity ${speed / 1000}s`;
|
||||
moduleWrapper.style.opacity = 0;
|
||||
moduleWrapper.classList.add("hidden");
|
||||
module.showHideTimer = setTimeout(function () {
|
||||
// To not take up any space, we just make the position absolute.
|
||||
// since it's fade out anyway, we can see it lay above or
|
||||
// below other modules. This works way better than adjusting
|
||||
// the .display property.
|
||||
moduleWrapper.style.position = "fixed";
|
||||
|
||||
updateWrapperStates();
|
||||
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
} else {
|
||||
// invoke callback even if no content, issue 1308
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the module.
|
||||
* @param {Module} module The module to show.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
function _showModule (module, speed, callback, options = {}) {
|
||||
// remove lockString if set in options.
|
||||
if (options.lockString) {
|
||||
const index = module.lockStrings.indexOf(options.lockString);
|
||||
if (index !== -1) {
|
||||
module.lockStrings.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are no more lockStrings set, or the force option is set.
|
||||
// Otherwise cancel show action.
|
||||
if (module.lockStrings.length !== 0 && options.force !== true) {
|
||||
Log.log(`Will not show ${module.name}. LockStrings active: ${module.lockStrings.join(",")}`);
|
||||
if (typeof options.onError === "function") {
|
||||
options.onError(new Error("LOCK_STRING_ACTIVE"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// reset all animations if needed
|
||||
if (module.hasAnimateOut) {
|
||||
removeAnimateCSS(module.identifier, module.hasAnimateOut);
|
||||
Log.debug(`${module.identifier} Force remove animateOut (in show): ${module.hasAnimateOut}`);
|
||||
module.hasAnimateOut = false;
|
||||
}
|
||||
if (module.hasAnimateIn) {
|
||||
removeAnimateCSS(module.identifier, module.hasAnimateIn);
|
||||
Log.debug(`${module.identifier} Force remove animateIn (in show): ${module.hasAnimateIn}`);
|
||||
module.hasAnimateIn = false;
|
||||
}
|
||||
|
||||
module.hidden = false;
|
||||
|
||||
// If forced show, clean current lockStrings.
|
||||
if (module.lockStrings.length !== 0 && options.force === true) {
|
||||
Log.log(`Force show of module: ${module.name}`);
|
||||
module.lockStrings = [];
|
||||
}
|
||||
|
||||
const moduleWrapper = document.getElementById(module.identifier);
|
||||
if (moduleWrapper !== null) {
|
||||
clearTimeout(module.showHideTimer);
|
||||
|
||||
// haveAnimateName for verify if we are using AnimateCSS library
|
||||
// we check AnimateCSSIn Array for validate it
|
||||
// and finally return the animate name or `null` (for default MM² animation)
|
||||
let haveAnimateName = null;
|
||||
// check if have valid animateOut in module definition (module.data.animateIn)
|
||||
if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateName = module.data.animateIn;
|
||||
// can't be override with options.animate
|
||||
else if (options.animate && AnimateCSSIn.indexOf(options.animate) !== -1) haveAnimateName = options.animate;
|
||||
|
||||
if (!haveAnimateName) moduleWrapper.style.transition = `opacity ${speed / 1000}s`;
|
||||
// Restore the position. See _hideModule() for more info.
|
||||
moduleWrapper.style.position = "static";
|
||||
moduleWrapper.classList.remove("hidden");
|
||||
|
||||
updateWrapperStates();
|
||||
|
||||
// Waiting for DOM-changes done in updateWrapperStates before we can start the animation.
|
||||
void moduleWrapper.parentElement.parentElement.offsetHeight;
|
||||
moduleWrapper.style.opacity = 1;
|
||||
|
||||
if (haveAnimateName) {
|
||||
// with AnimateCSS
|
||||
Log.debug(`${module.identifier} Has animateIn: ${haveAnimateName}`);
|
||||
module.hasAnimateIn = haveAnimateName;
|
||||
addAnimateCSS(module.identifier, haveAnimateName, speed / 1000);
|
||||
module.showHideTimer = setTimeout(function () {
|
||||
removeAnimateCSS(module.identifier, haveAnimateName);
|
||||
Log.debug(`${module.identifier} Remove animateIn: ${haveAnimateName}`);
|
||||
module.hasAnimateIn = false;
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, speed);
|
||||
} else {
|
||||
// default MM² Animate
|
||||
module.showHideTimer = setTimeout(function () {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
} else {
|
||||
// invoke callback
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for all positions if it has visible content.
|
||||
* If not, if will hide the position to prevent unwanted margins.
|
||||
* This method should be called by the show and hide methods.
|
||||
*
|
||||
* Example:
|
||||
* If the top_bar only contains the update notification. And no update is available,
|
||||
* the update notification is hidden. The top bar still occupies space making for
|
||||
* an ugly top margin. By using this function, the top bar will be hidden if the
|
||||
* update notification is not visible.
|
||||
*/
|
||||
function updateWrapperStates () {
|
||||
modulePositions.forEach(function (position) {
|
||||
const wrapper = selectWrapper(position);
|
||||
const moduleWrappers = wrapper.getElementsByClassName("module");
|
||||
|
||||
let showWrapper = false;
|
||||
Array.prototype.forEach.call(moduleWrappers, function (moduleWrapper) {
|
||||
if (moduleWrapper.style.position === "" || moduleWrapper.style.position === "static") {
|
||||
showWrapper = true;
|
||||
}
|
||||
});
|
||||
|
||||
// move container definitions to main CSS
|
||||
wrapper.className = showWrapper ? "container" : "container hidden";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the core config from the server (already combined with the system defaults).
|
||||
*/
|
||||
async function loadConfig () {
|
||||
try {
|
||||
const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`));
|
||||
|
||||
// The server tags functions as { __mmFunction: "<source>" } because
|
||||
// JSON.stringify can't serialise live functions. This reviver turns
|
||||
// those tagged objects back into callable functions.
|
||||
config = JSON.parse(await res.text(), (key, value) => {
|
||||
if (value && typeof value === "object" && typeof value.__mmFunction === "string") {
|
||||
try {
|
||||
return new Function(`return (${value.__mmFunction})`)();
|
||||
} catch {
|
||||
Log.warn(`Failed to revive function for config key "${key}".`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
});
|
||||
} catch (error) {
|
||||
Log.error("Unable to retrieve config", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds special selectors on a collection of modules.
|
||||
* @param {Module[]} modules Array of modules.
|
||||
*/
|
||||
function setSelectionMethodsForModules (modules) {
|
||||
|
||||
/**
|
||||
* Filter modules with the specified classes.
|
||||
* @param {string|string[]} className one or multiple classnames (array or space divided).
|
||||
* @returns {Module[]} Filtered collection of modules.
|
||||
*/
|
||||
function withClass (className) {
|
||||
return modulesByClass(className, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter modules without the specified classes.
|
||||
* @param {string|string[]} className one or multiple classnames (array or space divided).
|
||||
* @returns {Module[]} Filtered collection of modules.
|
||||
*/
|
||||
function exceptWithClass (className) {
|
||||
return modulesByClass(className, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a collection of modules based on classname(s).
|
||||
* @param {string|string[]} className one or multiple classnames (array or space divided).
|
||||
* @param {boolean} include if the filter should include or exclude the modules with the specific classes.
|
||||
* @returns {Module[]} Filtered collection of modules.
|
||||
*/
|
||||
function modulesByClass (className, include) {
|
||||
let searchClasses = className;
|
||||
if (typeof className === "string") {
|
||||
searchClasses = className.split(" ");
|
||||
}
|
||||
|
||||
const newModules = modules.filter(function (module) {
|
||||
const classes = module.data.classes.toLowerCase().split(" ");
|
||||
|
||||
for (const searchClass of searchClasses) {
|
||||
if (classes.indexOf(searchClass.toLowerCase()) !== -1) {
|
||||
return include;
|
||||
}
|
||||
}
|
||||
|
||||
return !include;
|
||||
});
|
||||
|
||||
setSelectionMethodsForModules(newModules);
|
||||
return newModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a module instance from the collection.
|
||||
* @param {object} module The module instance to remove from the collection.
|
||||
* @returns {Module[]} Filtered collection of modules.
|
||||
*/
|
||||
function exceptModule (module) {
|
||||
const newModules = modules.filter(function (mod) {
|
||||
return mod.identifier !== module.identifier;
|
||||
});
|
||||
|
||||
setSelectionMethodsForModules(newModules);
|
||||
return newModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks thru a collection of modules and executes the callback with the module as an argument.
|
||||
* @param {module} callback The function to execute with the module as an argument.
|
||||
*/
|
||||
function enumerate (callback) {
|
||||
modules.map(function (module) {
|
||||
callback(module);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof modules.withClass === "undefined") {
|
||||
Object.defineProperty(modules, "withClass", { value: withClass, enumerable: false });
|
||||
}
|
||||
if (typeof modules.exceptWithClass === "undefined") {
|
||||
Object.defineProperty(modules, "exceptWithClass", { value: exceptWithClass, enumerable: false });
|
||||
}
|
||||
if (typeof modules.exceptModule === "undefined") {
|
||||
Object.defineProperty(modules, "exceptModule", { value: exceptModule, enumerable: false });
|
||||
}
|
||||
if (typeof modules.enumerate === "undefined") {
|
||||
Object.defineProperty(modules, "enumerate", { value: enumerate, enumerable: false });
|
||||
}
|
||||
}
|
||||
|
||||
export const MM = {
|
||||
|
||||
/* Public Methods */
|
||||
|
||||
/**
|
||||
* Main init method.
|
||||
*/
|
||||
async init () {
|
||||
Log.info("Initializing MagicMirror².");
|
||||
await loadConfig();
|
||||
|
||||
Log.setLogLevel(config.logLevel);
|
||||
|
||||
await globalThis.Translator.loadCoreTranslations(config.language);
|
||||
await Loader.loadModules();
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets called when all modules are started.
|
||||
* @param {Module[]} moduleObjects All module instances.
|
||||
*/
|
||||
modulesStarted (moduleObjects) {
|
||||
modules = [];
|
||||
let startUp = "";
|
||||
|
||||
moduleObjects.forEach((module) => modules.push(module));
|
||||
|
||||
Log.info("All modules started!");
|
||||
_sendNotification("ALL_MODULES_STARTED");
|
||||
|
||||
createDomObjects();
|
||||
|
||||
// Setup global socket listener for RELOAD event (watch mode)
|
||||
if (typeof io !== "undefined") {
|
||||
const socket = io("/", {
|
||||
path: `${config.basePath || "/"}socket.io`
|
||||
});
|
||||
|
||||
socket.on("RELOAD", () => {
|
||||
Log.warn("Reload notification received from server");
|
||||
window.location.reload(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (config.reloadAfterServerRestart) {
|
||||
setInterval(async () => {
|
||||
// if server startup time has changed (which means server was restarted)
|
||||
// the client reloads the mm page
|
||||
try {
|
||||
const res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`);
|
||||
const curr = await res.text();
|
||||
if (startUp === "") startUp = curr;
|
||||
if (startUp !== curr) {
|
||||
startUp = "";
|
||||
window.location.reload(true);
|
||||
Log.warn("Refreshing Website because server was restarted");
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error(`MagicMirror not reachable: ${err}`);
|
||||
}
|
||||
}, config.checkServerInterval);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
*/
|
||||
sendNotification (notification, payload, sender) {
|
||||
if (arguments.length < 3) {
|
||||
Log.error("sendNotification: Missing arguments.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof notification !== "string") {
|
||||
Log.error("sendNotification: Notification should be a string.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(sender instanceof Module)) {
|
||||
Log.error("sendNotification: Sender should be a module.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Further implementation is done in the private method.
|
||||
_sendNotification(notification, payload, sender);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the dom for a specific module.
|
||||
* @param {Module} module The module that needs an update.
|
||||
* @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates)
|
||||
*/
|
||||
async updateDom (module, updateOptions) {
|
||||
if (!(module instanceof Module)) {
|
||||
Log.error("updateDom: Sender should be a module.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!module.data.position) {
|
||||
Log.warn("module tries to update the DOM without being displayed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Further implementation is done in the private method.
|
||||
await _updateDom(module, updateOptions);
|
||||
// Once the update is complete and rendered, send a notification to the module that the DOM has been updated
|
||||
_sendNotification("MODULE_DOM_UPDATED", null, null, module);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a collection of all modules currently active.
|
||||
* @returns {Module[]} A collection of all modules currently active.
|
||||
*/
|
||||
getModules () {
|
||||
setSelectionMethodsForModules(modules);
|
||||
return modules;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the module.
|
||||
* @param {Module} module The module to hide.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
hideModule (module, speed, callback, options) {
|
||||
module.hidden = true;
|
||||
_hideModule(module, speed, callback, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the module.
|
||||
* @param {Module} module The module to show.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
showModule (module, speed, callback, options) {
|
||||
// do not change module.hidden yet, only if we really show it later
|
||||
_showModule(module, speed, callback, options);
|
||||
},
|
||||
|
||||
// Return all available module positions.
|
||||
getAvailableModulePositions: modulePositions
|
||||
};
|
||||
|
||||
// Legacy global bridge for third-party modules that reference window.MM directly.
|
||||
if (!globalThis.MM) globalThis.MM = MM;
|
||||
|
||||
MM.init();
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
/* global nunjucks */
|
||||
|
||||
// eslint-disable-next-line import-x/extensions
|
||||
import { Loader } from "./loader.js";
|
||||
// eslint-disable-next-line import-x/extensions
|
||||
import { MMSocket } from "./socketclient.js";
|
||||
|
||||
/*
|
||||
* Module Blueprint.
|
||||
* @typedef {Object} Module
|
||||
*/
|
||||
export class Module {
|
||||
|
||||
/**
|
||||
* Initializes per-instance mutable state.
|
||||
*/
|
||||
constructor () {
|
||||
// Timer reference used for showHide animation callbacks.
|
||||
this.showHideTimer = null;
|
||||
|
||||
/*
|
||||
* Array to store lockStrings. These strings are used to lock
|
||||
* visibility when hiding and showing module.
|
||||
*/
|
||||
this.lockStrings = [];
|
||||
|
||||
/*
|
||||
* Storage of the nunjucks Environment.
|
||||
* This should not be referenced directly.
|
||||
* Use the nunjucksEnvironment() method to get it.
|
||||
*/
|
||||
this._nunjucksEnvironment = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*********************************************************
|
||||
* All methods (and properties) below can be overridden. *
|
||||
*********************************************************
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called when the module is instantiated.
|
||||
*/
|
||||
init () {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the module is started.
|
||||
*/
|
||||
start () {
|
||||
Log.info(`Starting module: ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of scripts the module requires to be loaded.
|
||||
* @returns {string[]} An array with filenames.
|
||||
*/
|
||||
getScripts () {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of stylesheets the module requires to be loaded.
|
||||
* @returns {string[]} An array with filenames.
|
||||
*/
|
||||
getStyles () {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of translation files the module requires to be loaded.
|
||||
*
|
||||
* return Map<String, String> -
|
||||
* @returns {Map} A map with langKeys and filenames.
|
||||
*/
|
||||
getTranslations () {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the dom which needs to be displayed. This method is called by the MagicMirror² core.
|
||||
* This method can to be overridden if the module wants to display info on the mirror.
|
||||
* Alternatively, the getTemplate method could be overridden.
|
||||
* @returns {HTMLElement|Promise} The dom or a promise with the dom to display.
|
||||
*/
|
||||
getDom () {
|
||||
return new Promise((resolve) => {
|
||||
const div = document.createElement("div");
|
||||
const template = this.getTemplate();
|
||||
const templateData = this.getTemplateData();
|
||||
|
||||
// Check to see if we need to render a template string or a file.
|
||||
if ((/^.*((\.html)|(\.njk))$/).test(template)) {
|
||||
// the template is a filename
|
||||
this.nunjucksEnvironment().render(template, templateData, function (err, res) {
|
||||
if (err) {
|
||||
Log.error(err);
|
||||
}
|
||||
|
||||
div.innerHTML = res;
|
||||
|
||||
resolve(div);
|
||||
});
|
||||
} else {
|
||||
// the template is a template string.
|
||||
div.innerHTML = this.nunjucksEnvironment().renderString(template, templateData);
|
||||
|
||||
resolve(div);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the header string which needs to be displayed if a user has a header configured for this module.
|
||||
* This method is called by the MagicMirror² core, but only if the user has configured a default header for the module.
|
||||
* This method needs to be overridden if the module wants to display modified headers on the mirror.
|
||||
* @returns {string} The header to display above the header.
|
||||
*/
|
||||
getHeader () {
|
||||
return this.data.header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the template for the module which is used by the default getDom implementation.
|
||||
* This method needs to be overridden if the module wants to use a template.
|
||||
* It can either return a template string, or a template filename.
|
||||
* If the string ends with '.html' it's considered a file from within the module's folder.
|
||||
* @returns {string} The template string of filename.
|
||||
*/
|
||||
getTemplate () {
|
||||
return `<div class="normal">${this.name}</div><div class="small dimmed">${this.identifier}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data to be used in the template.
|
||||
* This method needs to be overridden if the module wants to use a custom data.
|
||||
* @returns {object} The data for the template
|
||||
*/
|
||||
getTemplateData () {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the MagicMirror² core when a notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
*/
|
||||
notificationReceived (notification, payload, sender) {
|
||||
if (sender) {
|
||||
Log.debug(`${this.name} received a module notification: ${notification} from sender: ${sender.name}`);
|
||||
} else {
|
||||
Log.debug(`${this.name} received a system notification: ${notification}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the nunjucks environment for the current module.
|
||||
* The environment is checked in the _nunjucksEnvironment instance variable.
|
||||
* @returns {object} The Nunjucks Environment
|
||||
*/
|
||||
nunjucksEnvironment () {
|
||||
if (this._nunjucksEnvironment !== null) {
|
||||
return this._nunjucksEnvironment;
|
||||
}
|
||||
|
||||
this._nunjucksEnvironment = new nunjucks.Environment(new nunjucks.WebLoader(this.file(""), { async: true }), {
|
||||
trimBlocks: true,
|
||||
lstripBlocks: true
|
||||
});
|
||||
|
||||
this._nunjucksEnvironment.addFilter("translate", (str, variables) => {
|
||||
return nunjucks.runtime.markSafe(this.translate(str, variables));
|
||||
});
|
||||
|
||||
return this._nunjucksEnvironment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a socket notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
socketNotificationReceived (notification, payload) {
|
||||
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the module is hidden.
|
||||
*/
|
||||
suspend () {
|
||||
Log.log(`${this.name} is suspended.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the module is shown.
|
||||
*/
|
||||
resume () {
|
||||
Log.log(`${this.name} is resumed.`);
|
||||
}
|
||||
|
||||
/**
|
||||
***********************************************
|
||||
* The methods below should not be overridden. *
|
||||
***********************************************
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set the module data.
|
||||
* @param {object} data The module data
|
||||
*/
|
||||
setData (data) {
|
||||
this.data = data;
|
||||
this.name = data.name;
|
||||
this.identifier = data.identifier;
|
||||
this.hidden = false;
|
||||
this.hasAnimateIn = false;
|
||||
this.hasAnimateOut = false;
|
||||
|
||||
this.setConfig(data.config, data.configDeepMerge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the module config and combine it with the module defaults.
|
||||
* @param {object} config The combined module config.
|
||||
* @param {boolean} deep Merge module config in deep.
|
||||
*/
|
||||
setConfig (config, deep) {
|
||||
this.config = deep ? configMerge({}, this.defaults, config) : Object.assign({}, this.defaults, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a socket object. If it doesn't exist, it's created.
|
||||
* It also registers the notification callback.
|
||||
* @returns {MMSocket} a socket object
|
||||
*/
|
||||
socket () {
|
||||
if (typeof this._socket === "undefined") {
|
||||
this._socket = new MMSocket(this.name);
|
||||
}
|
||||
|
||||
this._socket.setNotificationCallback((notification, payload) => {
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
});
|
||||
|
||||
return this._socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the path to a module file.
|
||||
* @param {string} file Filename
|
||||
* @returns {string} the file path
|
||||
*/
|
||||
file (file) {
|
||||
return `${this.data.path}/${file}`.replace("//", "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all required stylesheets by requesting the MM object to load the files.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadStyles () {
|
||||
return this.loadDependencies("getStyles");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all required scripts by requesting the MM object to load the files.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
loadScripts () {
|
||||
return this.loadDependencies("getScripts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to load all dependencies.
|
||||
* @param {string} funcName Function name to call to get scripts or styles.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadDependencies (funcName) {
|
||||
let dependencies = this[funcName]();
|
||||
|
||||
const loadNextDependency = async () => {
|
||||
if (dependencies.length > 0) {
|
||||
const nextDependency = dependencies[0];
|
||||
await Loader.loadFileForModule(nextDependency, this);
|
||||
dependencies = dependencies.slice(1);
|
||||
await loadNextDependency();
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
await loadNextDependency();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all translations.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadTranslations () {
|
||||
const translations = this.getTranslations() || {};
|
||||
const language = config.language.toLowerCase();
|
||||
|
||||
const languages = Object.keys(translations);
|
||||
const fallbackLanguage = languages[0];
|
||||
|
||||
if (languages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const translationFile = translations[language];
|
||||
const translationsFallbackFile = translations[fallbackLanguage];
|
||||
|
||||
if (!translationFile) {
|
||||
return Translator.load(this, translationsFallbackFile, true);
|
||||
}
|
||||
|
||||
await Translator.load(this, translationFile, false);
|
||||
|
||||
if (translationFile !== translationsFallbackFile) {
|
||||
return Translator.load(this, translationsFallbackFile, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the translation for a given key with optional variables and default value.
|
||||
* @param {string} key The key of the string to translate
|
||||
* @param {string|object} [defaultValueOrVariables] The default value or variables for translating.
|
||||
* @param {string} [defaultValue] The default value with variables.
|
||||
* @returns {string} the translated key
|
||||
*/
|
||||
translate (key, defaultValueOrVariables, defaultValue) {
|
||||
if (typeof defaultValueOrVariables === "object") {
|
||||
return Translator.translate(this, key, defaultValueOrVariables) || defaultValue || "";
|
||||
}
|
||||
return Translator.translate(this, key) || defaultValueOrVariables || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an (animated) update of the module.
|
||||
* @param {number|object} [updateOptions] The speed of the animation or object with for updateOptions (speed/animates)
|
||||
*/
|
||||
updateDom (updateOptions) {
|
||||
MM.updateDom(this, updateOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
sendNotification (notification, payload) {
|
||||
MM.sendNotification(notification, payload, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a socket notification to the node helper.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
sendSocketNotification (notification, payload) {
|
||||
this.socket().sendNotification(notification, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide this module.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
hide (speed, callback, options = {}) {
|
||||
let usedCallback = callback || function () {};
|
||||
let usedOptions = options;
|
||||
|
||||
if (typeof callback === "object") {
|
||||
Log.error("Parameter mismatch in module.hide: callback is not an optional parameter!");
|
||||
usedOptions = callback;
|
||||
usedCallback = function () {};
|
||||
}
|
||||
|
||||
MM.hideModule(
|
||||
this,
|
||||
speed,
|
||||
() => {
|
||||
this.suspend();
|
||||
usedCallback();
|
||||
},
|
||||
usedOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show this module.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {() => void} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
show (speed, callback, options) {
|
||||
let usedCallback = callback || function () {};
|
||||
let usedOptions = options;
|
||||
|
||||
if (typeof callback === "object") {
|
||||
Log.error("Parameter mismatch in module.show: callback is not an optional parameter!");
|
||||
usedOptions = callback;
|
||||
usedCallback = function () {};
|
||||
}
|
||||
|
||||
MM.showModule(
|
||||
this,
|
||||
speed,
|
||||
() => {
|
||||
this.resume();
|
||||
usedCallback();
|
||||
},
|
||||
usedOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Module = Module;
|
||||
|
||||
/**
|
||||
* Merging MagicMirror² (or other) default/config script by `@bugsounet`
|
||||
* Merge 2 objects or/with array
|
||||
*
|
||||
* Usage:
|
||||
* -------
|
||||
* this.config = configMerge({}, this.defaults, this.config)
|
||||
* -------
|
||||
* arg1: initial object
|
||||
* arg2: config model
|
||||
* arg3: config to merge
|
||||
* -------
|
||||
* why using it ?
|
||||
* Object.assign() function don't to all job
|
||||
* it don't merge all thing in deep
|
||||
* -> object in object and array is not merging
|
||||
* -------
|
||||
*
|
||||
* Todo: idea of Mich determinate what do you want to merge or not
|
||||
* @param {object} result the initial object
|
||||
* @returns {object} the merged config
|
||||
*/
|
||||
function configMerge (result) {
|
||||
const stack = Array.prototype.slice.call(arguments, 1);
|
||||
let item, key;
|
||||
|
||||
while (stack.length) {
|
||||
item = stack.shift();
|
||||
for (key in item) {
|
||||
if (item.hasOwnProperty(key)) {
|
||||
if (typeof result[key] === "object" && result[key] && Object.prototype.toString.call(result[key]) !== "[object Array]") {
|
||||
if (typeof item[key] === "object" && item[key] !== null) {
|
||||
result[key] = configMerge({}, result[key], item[key]);
|
||||
} else {
|
||||
result[key] = item[key];
|
||||
}
|
||||
} else {
|
||||
result[key] = item[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Module.definitions = {};
|
||||
|
||||
Module.create = function (name) {
|
||||
// Make sure module definition is available.
|
||||
if (!Module.definitions[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleDefinition = Module.definitions[name];
|
||||
const clonedDefinition = cloneObject(moduleDefinition);
|
||||
const className = typeof name === "string" && name.trim() ? name : "AnonymousModule";
|
||||
|
||||
// Note that we clone the definition. Otherwise the objects are shared, which gives problems.
|
||||
const SubClass = {
|
||||
[className]: class extends Module {
|
||||
constructor () {
|
||||
super();
|
||||
Object.assign(this, clonedDefinition);
|
||||
if (typeof this.init === "function") {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
}[className];
|
||||
|
||||
return new SubClass();
|
||||
};
|
||||
|
||||
Module.register = function (name, moduleDefinition) {
|
||||
if (moduleDefinition.requiresVersion) {
|
||||
Log.log(`Check MagicMirror² version for module '${name}' - Minimum version: ${moduleDefinition.requiresVersion} - Current version: ${window.mmVersion}`);
|
||||
if (cmpVersions(window.mmVersion, moduleDefinition.requiresVersion) >= 0) {
|
||||
Log.log("Version is ok!");
|
||||
} else {
|
||||
Log.warn(`Version is incorrect. Skip module: '${name}'`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Log.log(`Module registered: ${name}`);
|
||||
Module.definitions[name] = moduleDefinition;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two semantic version numbers and return the difference.
|
||||
* @param {string} a Version number a.
|
||||
* @param {string} b Version number b.
|
||||
* @returns {number} A positive number if a is larger than b, a negative
|
||||
* number if a is smaller and 0 if they are the same
|
||||
*/
|
||||
export function cmpVersions (a, b) {
|
||||
const regExStrip0 = /(\.0+)+$/;
|
||||
const segmentsA = a.replace(regExStrip0, "").split(".");
|
||||
const segmentsB = b.replace(regExStrip0, "").split(".");
|
||||
const l = Math.min(segmentsA.length, segmentsB.length);
|
||||
|
||||
for (let i = 0; i < l; i++) {
|
||||
let diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10);
|
||||
if (diff) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return segmentsA.length - segmentsB.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the clone method for later use. Helper Method.
|
||||
* @param {object} obj Object to be cloned
|
||||
* @returns {object} the cloned object
|
||||
*/
|
||||
export function cloneObject (obj) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => cloneObject(item));
|
||||
}
|
||||
|
||||
const tag = Object.prototype.toString.call(obj);
|
||||
|
||||
if (tag === "[object RegExp]") {
|
||||
return new RegExp(obj);
|
||||
}
|
||||
|
||||
if (tag === "[object Date]") {
|
||||
return new Date(obj.getTime());
|
||||
}
|
||||
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
const isPlainObject = proto === null || Object.getPrototypeOf(proto) === null;
|
||||
|
||||
// Avoid calling class constructors without "new". Preserve unknown objects by reference.
|
||||
if (!isPlainObject) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const temp = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
temp[key] = cloneObject(obj[key]);
|
||||
}
|
||||
|
||||
return temp;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
const express = require("express");
|
||||
const Log = require("logger");
|
||||
const { replaceSecretPlaceholder } = require("#server_functions");
|
||||
|
||||
/**
|
||||
* Determine which secrets a module is allowed to restore. A module may only
|
||||
* restore the `**SECRET_***` placeholders that appear in its own config — the
|
||||
* exact inverse of how the config is redacted before it is sent to the browser.
|
||||
* @param {string} moduleName - Name of the module.
|
||||
* @returns {Set<string>} The secret names the module may restore.
|
||||
*/
|
||||
function getAllowedSecrets (moduleName) {
|
||||
const modules = global.configRedacted?.modules || [];
|
||||
const moduleConfig = modules.find((m) => m.module === moduleName);
|
||||
const allowed = new Set();
|
||||
if (moduleConfig) {
|
||||
// Stringify the config to easily find all expected **SECRET_*** placeholders
|
||||
for (const [, secretName] of JSON.stringify(moduleConfig).matchAll(/\*\*(SECRET_[^*]+)\*\*/g)) {
|
||||
allowed.add(secretName);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
class NodeHelper {
|
||||
init () {
|
||||
Log.log("Initializing new module helper ...");
|
||||
}
|
||||
|
||||
loaded () {
|
||||
Log.log(`Module helper loaded: ${this.name}`);
|
||||
}
|
||||
|
||||
start () {
|
||||
Log.log(`Starting module helper: ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the MagicMirror² server receives a `SIGINT`
|
||||
* Close any open connections, stop any sub-processes and
|
||||
* gracefully exit the module.
|
||||
*/
|
||||
stop () {
|
||||
Log.log(`Stopping module helper: ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called when a socket notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
socketNotificationReceived (notification, payload) {
|
||||
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the module name.
|
||||
* @param {string} name Module name.
|
||||
*/
|
||||
setName (name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the module path.
|
||||
* @param {string} path Module path.
|
||||
*/
|
||||
setPath (path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/*
|
||||
* sendSocketNotification(notification, payload)
|
||||
* Send a socket notification to the node helper.
|
||||
*
|
||||
* argument notification string - The identifier of the notification.
|
||||
* argument payload mixed - The payload of the notification.
|
||||
*/
|
||||
sendSocketNotification (notification, payload) {
|
||||
this.io.of(this.name).emit(notification, payload);
|
||||
}
|
||||
|
||||
/*
|
||||
* setExpressApp(app)
|
||||
* Sets the express app object for this module.
|
||||
* This allows you to host files from the created webserver.
|
||||
*
|
||||
* argument app Express app - The Express app object.
|
||||
*/
|
||||
setExpressApp (app) {
|
||||
this.expressApp = app;
|
||||
|
||||
app.use(`/${this.name}`, express.static(`${this.path}/public`));
|
||||
}
|
||||
|
||||
/*
|
||||
* setSocketIO(io)
|
||||
* Sets the socket io object for this module.
|
||||
* Binds message receiver.
|
||||
*
|
||||
* argument io Socket.io - The Socket io object.
|
||||
*/
|
||||
setSocketIO (io) {
|
||||
this.io = io;
|
||||
|
||||
Log.log(`Connecting socket for: ${this.name}`);
|
||||
|
||||
io.of(this.name).on("connection", (socket) => {
|
||||
// register catch all.
|
||||
socket.onAny((notification, payload) => {
|
||||
if (config?.hideConfigSecrets && payload && typeof payload === "object") {
|
||||
try {
|
||||
// Calculate exactly which secrets this module is allowed to receive
|
||||
const allowedSecrets = getAllowedSecrets(this.name);
|
||||
// Expand only these safe, module-specific secrets in the payload
|
||||
const payloadStr = replaceSecretPlaceholder(JSON.stringify(payload), allowedSecrets);
|
||||
this.socketNotificationReceived(notification, JSON.parse(payloadStr));
|
||||
} catch (e) {
|
||||
Log.error("Error substituting variables in payload: ", e);
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
}
|
||||
} else {
|
||||
this.socketNotificationReceived(notification, payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a fetch response.
|
||||
* @param {Response} response The fetch response.
|
||||
* @returns {Response} The fetch response if ok.
|
||||
*/
|
||||
static checkFetchStatus (response) {
|
||||
// response.status >= 200 && response.status < 300
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else {
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look at the specified error and return an appropriate error type, that
|
||||
* can be translated to a detailed error message
|
||||
* @param {Error} error the error from fetching something
|
||||
* @returns {string} the string of the detailed error message in the translations
|
||||
*/
|
||||
static checkFetchError (error) {
|
||||
let error_type = "MODULE_ERROR_UNSPECIFIED";
|
||||
if (error.code === "EAI_AGAIN") {
|
||||
error_type = "MODULE_ERROR_NO_CONNECTION";
|
||||
} else {
|
||||
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||
if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) {
|
||||
error_type = "MODULE_ERROR_UNAUTHORIZED";
|
||||
}
|
||||
}
|
||||
return error_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new NodeHelper subclass with the given module definition.
|
||||
* @param {object} moduleDefinition Methods and properties for the helper.
|
||||
* @returns {typeof NodeHelper} A new subclass of NodeHelper.
|
||||
*/
|
||||
static create (moduleDefinition) {
|
||||
return class extends NodeHelper {
|
||||
constructor () {
|
||||
super();
|
||||
Object.assign(this, moduleDefinition);
|
||||
this.init();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NodeHelper;
|
||||
@@ -0,0 +1,198 @@
|
||||
/* eslint no-console: "off" */
|
||||
const util = require("node:util");
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
const fs = require("node:fs");
|
||||
|
||||
const createReleaseNotes = async () => {
|
||||
let repoName = "MagicMirrorOrg/MagicMirror";
|
||||
if (process.env.GITHUB_REPOSITORY) {
|
||||
repoName = process.env.GITHUB_REPOSITORY;
|
||||
}
|
||||
const baseUrl = `https://api.github.com/repos/${repoName}`;
|
||||
|
||||
const getOptions = (type) => {
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
return { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } };
|
||||
} else {
|
||||
return { method: `${type}` };
|
||||
}
|
||||
};
|
||||
|
||||
const execShell = async (command) => {
|
||||
const { stdout = "", stderr = "" } = await exec(command);
|
||||
if (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
// Check Draft Release
|
||||
const draftReleases = [];
|
||||
const jsonReleases = await fetch(`${baseUrl}/releases`, getOptions("GET")).then((res) => res.json());
|
||||
for (const rel of jsonReleases) {
|
||||
if (rel.draft && rel.tag_name === "" && rel.published_at === null && rel.name === "unreleased") draftReleases.push(rel);
|
||||
}
|
||||
|
||||
let draftReleaseId = 0;
|
||||
if (draftReleases.length > 1) {
|
||||
throw new Error("More than one draft release found, exiting.");
|
||||
} else {
|
||||
if (draftReleases[0]) draftReleaseId = draftReleases[0].id;
|
||||
}
|
||||
|
||||
// Get last Git Tag
|
||||
const gitTag = await execShell("git describe --tags `git rev-list --tags --max-count=1`");
|
||||
const lastTag = gitTag.toString().replaceAll("\n", "");
|
||||
console.info(`latest tag is ${lastTag}`);
|
||||
|
||||
// Get Git Commits
|
||||
const gitOut = await execShell(`git log develop --pretty=format:"%H --- %s" --after="$(git log -1 --format=%aI ${lastTag})"`);
|
||||
console.info(gitOut);
|
||||
const commits = gitOut.toString().split("\n");
|
||||
|
||||
// Get Node engine version from package.json
|
||||
const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node;
|
||||
|
||||
// Search strings
|
||||
const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"];
|
||||
|
||||
// Map search strings to categories
|
||||
const getFirstLabel = (text) => {
|
||||
let res;
|
||||
labelArr.every((item) => {
|
||||
const labelIncl = text.includes(item);
|
||||
if (labelIncl) {
|
||||
switch (item) {
|
||||
case "ci":
|
||||
case "test":
|
||||
res = "testing";
|
||||
break;
|
||||
case "logg":
|
||||
res = "logging";
|
||||
break;
|
||||
case "eslint":
|
||||
case "bump":
|
||||
case "deps":
|
||||
res = "dependencies";
|
||||
break;
|
||||
case "envcanada":
|
||||
case "openmeteo":
|
||||
case "openweathermap":
|
||||
case "smhi":
|
||||
case "ukmetoffice":
|
||||
case "yr":
|
||||
case "weather":
|
||||
res = "modules/weather";
|
||||
break;
|
||||
case "alert":
|
||||
res = "modules/alert";
|
||||
break;
|
||||
case "calendar":
|
||||
res = "modules/calendar";
|
||||
break;
|
||||
case "clock":
|
||||
res = "modules/clock";
|
||||
break;
|
||||
case "compliments":
|
||||
res = "modules/compliments";
|
||||
break;
|
||||
case "helloworld":
|
||||
res = "modules/helloworld";
|
||||
break;
|
||||
case "newsfeed":
|
||||
res = "modules/newsfeed";
|
||||
break;
|
||||
case "updatenotification":
|
||||
res = "modules/updatenotification";
|
||||
break;
|
||||
default:
|
||||
res = item;
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (!res) res = "core";
|
||||
return res;
|
||||
};
|
||||
|
||||
const grouped = {};
|
||||
const contrib = [];
|
||||
const sha = [];
|
||||
|
||||
// Loop through each Commit
|
||||
for (const item of commits) {
|
||||
|
||||
const cm = item.trim();
|
||||
// ignore `prepare release` line
|
||||
if (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) {
|
||||
|
||||
const [ref, title] = cm.split(" --- ");
|
||||
|
||||
const groupTitle = getFirstLabel(title.toLowerCase());
|
||||
|
||||
if (!grouped[groupTitle]) {
|
||||
grouped[groupTitle] = [];
|
||||
}
|
||||
|
||||
grouped[groupTitle].push(`- ${title}`);
|
||||
|
||||
sha.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
// function to remove duplicates
|
||||
const sortedArr = (arr) => {
|
||||
return arr.filter((item,
|
||||
index) => (arr.indexOf(item) === index && item !== "@dependabot[bot]")).sort(function (a, b) {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
// Get Contributors logins
|
||||
for (const ref of sha) {
|
||||
const jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions("GET")).then((res) => res.json());
|
||||
|
||||
if (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`);
|
||||
}
|
||||
|
||||
// Build Markdown content
|
||||
let markdown = "## Release Notes\n";
|
||||
|
||||
markdown += `Thanks to: ${sortedArr(contrib).join(", ")}\n`;
|
||||
markdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\n`;
|
||||
markdown += "\n";
|
||||
markdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\n\n`;
|
||||
|
||||
const sorted = Object.keys(grouped)
|
||||
.sort() // Sort the keys alphabetically
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = grouped[key]; // Rebuild the object with sorted keys
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
for (const group in sorted) {
|
||||
markdown += `\n### [${group}]\n`;
|
||||
markdown += `${sorted[group].join("\n")}\n`;
|
||||
}
|
||||
|
||||
console.info(markdown);
|
||||
|
||||
// Create Github Release
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
if (draftReleaseId > 0) {
|
||||
// delete release
|
||||
await fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions("DELETE"));
|
||||
console.info(`Old Release with id ${draftReleaseId} deleted.`);
|
||||
}
|
||||
|
||||
const relContent = getOptions("POST");
|
||||
relContent.body = JSON.stringify(
|
||||
{ tag_name: "", name: "unreleased", body: `${markdown}`, draft: true }
|
||||
);
|
||||
const createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json());
|
||||
console.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`);
|
||||
}
|
||||
};
|
||||
|
||||
createReleaseNotes();
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const path = require("node:path");
|
||||
const express = require("express");
|
||||
const helmet = require("helmet");
|
||||
const socketio = require("socket.io");
|
||||
const Log = require("logger");
|
||||
|
||||
const { ipAccessControl, socketIpAccessControl } = require("./ip_access_control");
|
||||
|
||||
const vendor = require("./vendor");
|
||||
|
||||
const { getHtml, getVersion, getEnvVars, cors } = require("#server_functions");
|
||||
|
||||
/**
|
||||
* Server
|
||||
* @param {object} configObj The MM config full and redacted
|
||||
* @class
|
||||
*/
|
||||
function Server (configObj) {
|
||||
const config = configObj.fullConf;
|
||||
const app = express();
|
||||
const port = process.env.MM_PORT || config.port;
|
||||
const serverSockets = new Set();
|
||||
let server = null;
|
||||
|
||||
/**
|
||||
* Opens the server for incoming connections
|
||||
* @returns {Promise} A promise that is resolved when the server listens to connections
|
||||
*/
|
||||
this.open = function () {
|
||||
return new Promise((resolve) => {
|
||||
if (config.useHttps) {
|
||||
const options = {
|
||||
key: fs.readFileSync(config.httpsPrivateKey),
|
||||
cert: fs.readFileSync(config.httpsCertificate)
|
||||
};
|
||||
server = https.Server(options, app);
|
||||
} else {
|
||||
server = http.Server(app);
|
||||
}
|
||||
const io = socketio(server, {
|
||||
allowRequest: socketIpAccessControl(config.ipWhitelist),
|
||||
cors: {
|
||||
origin: /.*$/,
|
||||
credentials: true
|
||||
},
|
||||
allowEIO3: true,
|
||||
pingInterval: 120000, // server → client ping every 2 mins
|
||||
pingTimeout: 120000 // wait up to 2 mins for client pong
|
||||
});
|
||||
|
||||
server.on("connection", (socket) => {
|
||||
serverSockets.add(socket);
|
||||
socket.on("close", () => {
|
||||
serverSockets.delete(socket);
|
||||
});
|
||||
});
|
||||
|
||||
Log.log(`Starting server on port ${port} ... `);
|
||||
|
||||
// Add explicit error handling BEFORE calling listen so we can give user-friendly feedback
|
||||
server.once("error", (err) => {
|
||||
if (err && err.code === "EADDRINUSE") {
|
||||
const bindAddr = config.address || "localhost";
|
||||
const portInUseMessage = [
|
||||
"",
|
||||
"────────────────────────────────────────────────────────────────",
|
||||
` PORT IN USE: ${bindAddr}:${port}`,
|
||||
"",
|
||||
" Another process (most likely another MagicMirror instance)",
|
||||
" is already using this port.",
|
||||
"",
|
||||
" Stop the other process (free the port) or use a different port.",
|
||||
"────────────────────────────────────────────────────────────────"
|
||||
].join("\n");
|
||||
Log.error(portInUseMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.error("Failed to start server:", err);
|
||||
});
|
||||
|
||||
server.listen(port, config.address || "localhost");
|
||||
|
||||
if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {
|
||||
Log.warn("You're using a full whitelist configuration to allow for all IPs");
|
||||
}
|
||||
|
||||
app.use(ipAccessControl(config.ipWhitelist));
|
||||
app.use(helmet(config.httpHeaders));
|
||||
app.use("/js", express.static(__dirname));
|
||||
|
||||
if (config.hideConfigSecrets) {
|
||||
app.get("/config/config.env", (req, res) => {
|
||||
res.status(404).send("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /config/config.env</pre>\n</body>\n</html>");
|
||||
});
|
||||
}
|
||||
|
||||
let directories = ["/config", "/css", "/favicon.svg", "/defaultmodules", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/node_modules/suncalc", "/translations", "/tests/configs", "/tests/mocks"];
|
||||
for (const value of Object.values(vendor)) {
|
||||
const dirArr = value.split("/");
|
||||
if (dirArr[0] === "node_modules") directories.push(`/${dirArr[0]}/${dirArr[1]}`);
|
||||
}
|
||||
const uniqDirs = [...new Set(directories)];
|
||||
|
||||
for (const directory of uniqDirs) {
|
||||
app.use(directory, express.static(path.resolve(global.root_path + directory)));
|
||||
}
|
||||
|
||||
const startUp = new Date();
|
||||
const getStartup = (req, res) => res.send(startUp);
|
||||
|
||||
const getConfig = (req, res) => {
|
||||
const obj = config.hideConfigSecrets ? configObj.redactedConf : configObj.fullConf;
|
||||
// Functions can't survive JSON.stringify, so we wrap them in a
|
||||
// tagged object { __mmFunction: "<source>" }. The client-side
|
||||
// JSON reviver in main.js recognises this tag and reconstructs
|
||||
// the live function from the source string.
|
||||
const jsonString = JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === "function") {
|
||||
return { __mmFunction: value.toString() };
|
||||
}
|
||||
return value;
|
||||
});
|
||||
res.set("Content-Type", "application/json");
|
||||
res.send(jsonString);
|
||||
};
|
||||
|
||||
app.get("/config", (req, res) => getConfig(req, res));
|
||||
|
||||
app.get("/cors", async (req, res) => await cors(req, res));
|
||||
|
||||
app.get("/version", (req, res) => getVersion(req, res));
|
||||
|
||||
app.get("/startup", (req, res) => getStartup(req, res));
|
||||
|
||||
app.get("/env", (req, res) => getEnvVars(req, res));
|
||||
|
||||
app.get("/", (req, res) => getHtml(req, res));
|
||||
|
||||
// Reload endpoint for watch mode - triggers browser reload
|
||||
app.get("/reload", (req, res) => {
|
||||
Log.info("Reload request received, notifying all clients");
|
||||
io.emit("RELOAD");
|
||||
res.status(200).send("OK");
|
||||
});
|
||||
|
||||
server.on("listening", () => {
|
||||
resolve({
|
||||
app,
|
||||
io
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the server and destroys all lingering connections to it.
|
||||
* @returns {Promise} A promise that resolves when server has successfully shut down
|
||||
*/
|
||||
this.close = function () {
|
||||
return new Promise((resolve) => {
|
||||
for (const socket of serverSockets.values()) {
|
||||
socket.destroy();
|
||||
}
|
||||
server.close(resolve);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = Server;
|
||||
@@ -0,0 +1,272 @@
|
||||
const dns = require("node:dns");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const ipaddr = require("ipaddr.js");
|
||||
const undici = require("undici");
|
||||
const Log = require("logger");
|
||||
|
||||
const startUp = new Date();
|
||||
|
||||
/**
|
||||
* Gets the startup time.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
*/
|
||||
function getStartup (req, res) {
|
||||
res.send(startUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `**SECRET_ABC**` placeholders with the value of `process.env.SECRET_ABC`.
|
||||
*
|
||||
* If `allowedSecrets` is given, only those secret names are restored and every
|
||||
* other placeholder is left untouched. Without it, all secrets are restored
|
||||
* (used by the CORS proxy, which only runs on the trusted server side).
|
||||
* @param {string} input - String that may contain `**SECRET_***` placeholders.
|
||||
* @param {Set<string>} [allowedSecrets] - Secret names that may be restored.
|
||||
* @returns {string} The input with the allowed placeholders replaced.
|
||||
*/
|
||||
function replaceSecretPlaceholder (input, allowedSecrets) {
|
||||
if (global.config.cors === "allowAll") {
|
||||
if (input.includes("**SECRET_")) {
|
||||
Log.error("Replacing secrets doesn't work with CORS `allowAll`, you need to set `cors` to `disabled` or `allowWhitelist` in `config.js`");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (placeholder, secretName) => {
|
||||
// Block replacing secrets that are not explicitly allowed.
|
||||
if (allowedSecrets && !allowedSecrets.has(secretName)) {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
// Load the real value from the environment. Fallback to placeholder if missing.
|
||||
return process.env[secretName] || placeholder;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A method that forwards HTTP Get-methods to the internet to avoid CORS-errors.
|
||||
*
|
||||
* Example input request url: /cors?sendheaders=header1:value1,header2:value2&expectedheaders=header1,header2&url=http://www.test.com/path?param1=value1
|
||||
*
|
||||
* Only the url-param of the input request url is required. It must be the last parameter.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
* @returns {Promise<void>} A promise that resolves when the response is sent
|
||||
*/
|
||||
async function cors (req, res) {
|
||||
if (global.config.cors === "disabled") {
|
||||
Log.error("CORS is disabled, you need to enable it in `config.js` by setting `cors` to `allowAll` or `allowWhitelist`");
|
||||
return res.status(403).json({ error: "CORS proxy is disabled" });
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
const urlRegEx = "url=(.+?)$";
|
||||
|
||||
const match = new RegExp(urlRegEx, "g").exec(req.url);
|
||||
if (!match) {
|
||||
url = `invalid url: ${req.url}`;
|
||||
Log.error(url);
|
||||
return res.status(400).send(url);
|
||||
} else {
|
||||
url = match[1];
|
||||
if (typeof global.config !== "undefined") {
|
||||
if (config.hideConfigSecrets) {
|
||||
url = replaceSecretPlaceholder(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate protocol before attempting connection (non-http/https are never allowed)
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
Log.warn(`SSRF blocked (invalid URL): ${url}`);
|
||||
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
Log.warn(`SSRF blocked (protocol): ${url}`);
|
||||
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
}
|
||||
|
||||
// Block localhost by hostname before even creating the dispatcher (no DNS needed).
|
||||
if (parsed.hostname.toLowerCase() === "localhost") {
|
||||
Log.warn(`SSRF blocked (localhost): ${url}`);
|
||||
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
}
|
||||
|
||||
// Whitelist check: if enabled, only allow explicitly listed domains
|
||||
if (global.config.cors === "allowWhitelist" && !global.config.corsDomainWhitelist.includes(parsed.hostname.toLowerCase())) {
|
||||
Log.warn(`CORS blocked (not in whitelist): ${url}`);
|
||||
return res.status(403).json({ error: "Forbidden: domain not in corsDomainWhitelist" });
|
||||
}
|
||||
|
||||
const headersToSend = getHeadersToSend(req.url);
|
||||
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
|
||||
Log.log(`cors url: ${url}`);
|
||||
|
||||
// Resolve DNS once and validate the IP. The validated IP is then pinned
|
||||
// for the actual connection so fetch() cannot re-resolve to a different
|
||||
// address. This prevents DNS rebinding / TOCTOU attacks (GHSA-xhvw-r95j-xm4v).
|
||||
const { address, family } = await dns.promises.lookup(parsed.hostname);
|
||||
if (ipaddr.process(address).range() !== "unicast") {
|
||||
Log.warn(`SSRF blocked: ${url}`);
|
||||
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
}
|
||||
|
||||
// Pin the validated IP — fetch() reuses it instead of doing its own DNS lookup
|
||||
const dispatcher = new undici.Agent({
|
||||
connect: {
|
||||
lookup: (_h, _o, cb) => {
|
||||
const addresses = [{ address: address, family: family }];
|
||||
process.nextTick(() => cb(null, addresses));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = await undici.fetch(url, { dispatcher, headers: headersToSend });
|
||||
if (response.ok) {
|
||||
for (const header of expectedReceivedHeaders) {
|
||||
const headerValue = response.headers.get(header);
|
||||
if (header) res.set(header, headerValue);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.send(Buffer.from(arrayBuffer));
|
||||
} else {
|
||||
throw new Error(`Response status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
Log.error(`Error in CORS request: ${error}`);
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets headers and values to attach to the web request.
|
||||
* @param {string} url - The url containing the headers and values to send.
|
||||
* @returns {object} An object specifying name and value of the headers.
|
||||
*/
|
||||
function getHeadersToSend (url) {
|
||||
const headersToSend = { "User-Agent": getUserAgent() };
|
||||
const headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url);
|
||||
if (headersToSendMatch) {
|
||||
const headers = headersToSendMatch[1].split(",");
|
||||
for (const header of headers) {
|
||||
const keyValue = header.split(":");
|
||||
if (keyValue.length !== 2) {
|
||||
throw new Error(`Invalid format for header ${header}`);
|
||||
}
|
||||
headersToSend[keyValue[0]] = decodeURIComponent(keyValue[1]);
|
||||
}
|
||||
}
|
||||
return headersToSend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the headers expected from the response.
|
||||
* @param {string} url - The url containing the expected headers from the response.
|
||||
* @returns {string[]} headers - The name of the expected headers.
|
||||
*/
|
||||
function geExpectedReceivedHeaders (url) {
|
||||
const expectedReceivedHeaders = ["Content-Type"];
|
||||
const expectedReceivedHeadersMatch = new RegExp("expectedheaders=(.+?)(&|$)", "g").exec(url);
|
||||
if (expectedReceivedHeadersMatch) {
|
||||
const headers = expectedReceivedHeadersMatch[1].split(",");
|
||||
for (const header of headers) {
|
||||
expectedReceivedHeaders.push(header);
|
||||
}
|
||||
}
|
||||
return expectedReceivedHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the HTML to display the magic mirror.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
*/
|
||||
function getHtml (req, res) {
|
||||
let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" });
|
||||
html = html.replace("#VERSION#", global.version);
|
||||
html = html.replace("#TESTMODE#", global.mmTestMode);
|
||||
|
||||
res.send(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the MagicMirror version.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
*/
|
||||
function getVersion (req, res) {
|
||||
res.send(global.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the preferred `User-Agent`
|
||||
* @returns {string} `User-Agent` to be used
|
||||
*/
|
||||
function getUserAgent () {
|
||||
const defaultUserAgent = `Mozilla/5.0 (Node.js ${Number(process.version.match(/^v(\d+\.\d+)/)[1])}) MagicMirror/${global.version}`;
|
||||
|
||||
if (typeof global.config === "undefined") {
|
||||
return defaultUserAgent;
|
||||
}
|
||||
|
||||
switch (typeof global.config.userAgent) {
|
||||
case "function":
|
||||
return global.config.userAgent();
|
||||
case "string":
|
||||
return global.config.userAgent;
|
||||
default:
|
||||
return defaultUserAgent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets environment variables needed in the browser.
|
||||
* @returns {object} environment variables key: values
|
||||
*/
|
||||
function getEnvVarsAsObj () {
|
||||
const obj = { modulesDir: `${global.config.foreignModulesDir}`, defaultModulesDir: `${global.config.defaultModulesDir}`, customCss: `${global.config.customCss}` };
|
||||
if (process.env.MM_MODULES_DIR) {
|
||||
obj.modulesDir = process.env.MM_MODULES_DIR.replace(`${global.root_path}/`, "");
|
||||
}
|
||||
if (process.env.MM_CUSTOMCSS_FILE) {
|
||||
obj.customCss = process.env.MM_CUSTOMCSS_FILE.replace(`${global.root_path}/`, "");
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets environment variables needed in the browser.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
*/
|
||||
function getEnvVars (req, res) {
|
||||
const obj = getEnvVarsAsObj();
|
||||
res.send(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config file path from environment or default location
|
||||
* @returns {string} The absolute config file path
|
||||
*/
|
||||
function getConfigFilePath () {
|
||||
// Ensure root_path is set (for standalone contexts like watcher)
|
||||
if (!global.root_path) {
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
}
|
||||
|
||||
// Check environment variable if global not set
|
||||
if (!global.configuration_file && process.env.MM_CONFIG_FILE) {
|
||||
global.configuration_file = process.env.MM_CONFIG_FILE;
|
||||
}
|
||||
|
||||
return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
|
||||
}
|
||||
|
||||
module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder };
|
||||
@@ -0,0 +1,49 @@
|
||||
/* global io */
|
||||
|
||||
export const MMSocket = function (moduleName) {
|
||||
if (typeof moduleName !== "string") {
|
||||
throw new Error("Please set the module name for the MMSocket.");
|
||||
}
|
||||
|
||||
this.moduleName = moduleName;
|
||||
|
||||
// Private Methods
|
||||
let base = "/";
|
||||
if (typeof config !== "undefined" && typeof config.basePath !== "undefined") {
|
||||
base = config.basePath;
|
||||
}
|
||||
this.socket = io(`/${this.moduleName}`, {
|
||||
path: `${base}socket.io`,
|
||||
pingInterval: 120000, // send pings every 2 mins
|
||||
pingTimeout: 120000 // wait up to 2 mins for a pong
|
||||
});
|
||||
|
||||
let notificationCallback = function () {};
|
||||
|
||||
const onevent = this.socket.onevent;
|
||||
this.socket.onevent = (packet) => {
|
||||
const args = packet.data || [];
|
||||
onevent.call(this.socket, packet); // original call
|
||||
packet.data = ["*"].concat(args);
|
||||
onevent.call(this.socket, packet); // additional call to catch-all
|
||||
};
|
||||
|
||||
// register catch all.
|
||||
this.socket.on("*", (notification, payload) => {
|
||||
if (notification !== "*") {
|
||||
notificationCallback(notification, payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Public Methods
|
||||
this.setNotificationCallback = (callback) => {
|
||||
notificationCallback = callback;
|
||||
};
|
||||
|
||||
this.sendNotification = (notification, payload = {}) => {
|
||||
this.socket.emit(notification, payload);
|
||||
};
|
||||
};
|
||||
|
||||
// Legacy global bridge for third-party modules that reference MMSocket directly.
|
||||
if (!globalThis.MMSocket) globalThis.MMSocket = MMSocket;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Bridge for SunCalc v2 (ESM-only entry):
|
||||
* default modules still expect a global `SunCalc` from getScripts()/vendor loading.
|
||||
* Keep this as a tiny compatibility shim until the module loader is fully ESM-first.
|
||||
*/
|
||||
import * as SunCalc from "../node_modules/suncalc/index.js";
|
||||
|
||||
globalThis.SunCalc = SunCalc;
|
||||
@@ -0,0 +1,49 @@
|
||||
const os = require("node:os");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const si = require("systeminformation");
|
||||
// needed with relative path because logSystemInformation is called in an own process in app.js:
|
||||
const mmVersion = require("../package").version;
|
||||
const Log = require("./logger");
|
||||
|
||||
let mmGitHash = "";
|
||||
let mmGitBranch = "";
|
||||
try {
|
||||
mmGitHash = execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
|
||||
mmGitBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
// not a git repo or git not available
|
||||
}
|
||||
|
||||
const logSystemInformation = async () => {
|
||||
try {
|
||||
const system = await si.system();
|
||||
const osInfo = await si.osInfo();
|
||||
const versions = await si.versions();
|
||||
|
||||
const installedNodeVersion = versions.node;
|
||||
const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);
|
||||
const freeRam = (os.freemem() / 1024 / 1024).toFixed(2);
|
||||
const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);
|
||||
|
||||
let systemDataString = [
|
||||
"\n#### System Information ####",
|
||||
`- MM: version: v${mmVersion}${mmGitHash ? `; git: ${mmGitHash}` : ""}${mmGitBranch ? `; branch: ${mmGitBranch}` : ""}`,
|
||||
`- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}`,
|
||||
`- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,
|
||||
`- VERSIONS: electron: ${process.env.ELECTRON_VERSION}; used node: ${process.env.USED_NODE_VERSION}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`,
|
||||
`- ENV: XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`,
|
||||
` WAYLAND_DISPLAY: ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`,
|
||||
`- RAM: total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`,
|
||||
`- OTHERS: uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
|
||||
].join("\n");
|
||||
Log.info(systemDataString);
|
||||
|
||||
// Return is currently only for tests
|
||||
return systemDataString;
|
||||
} catch (error) {
|
||||
Log.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = logSystemInformation;
|
||||
logSystemInformation();
|
||||
@@ -0,0 +1,129 @@
|
||||
/* global translations */
|
||||
|
||||
const Translator = (function () {
|
||||
|
||||
/**
|
||||
* Load a JSON file via fetch.
|
||||
* @param {string} file Path of the file we want to load.
|
||||
* @returns {Promise<object>} the translations in the specified file
|
||||
*/
|
||||
async function loadJSON (file) {
|
||||
const baseHref = document.baseURI;
|
||||
const url = new URL(file, baseHref);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch {
|
||||
Log.error(`Loading json file =${file} failed`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
coreTranslations: {},
|
||||
coreTranslationsFallback: {},
|
||||
translations: {},
|
||||
translationsFallback: {},
|
||||
|
||||
/**
|
||||
* Load a translation for a given key for a given module.
|
||||
* @param {Module} module The module to load the translation for.
|
||||
* @param {string} key The key of the text to translate.
|
||||
* @param {object} variables The variables to use within the translation template (optional)
|
||||
* @returns {string} the translated key
|
||||
*/
|
||||
translate (module, key, variables = {}) {
|
||||
|
||||
/**
|
||||
* Combines template and variables like:
|
||||
* template: "Please wait for {timeToWait} before continuing with {work}."
|
||||
* variables: {timeToWait: "2 hours", work: "painting"}
|
||||
* to: "Please wait for 2 hours before continuing with painting."
|
||||
* @param {string} template Text with placeholder
|
||||
* @param {object} variables Variables for the placeholder
|
||||
* @returns {string} the template filled with the variables
|
||||
*/
|
||||
function createStringFromTemplate (template, variables) {
|
||||
if (Object.prototype.toString.call(template) !== "[object String]") {
|
||||
return template;
|
||||
}
|
||||
let templateToUse = template;
|
||||
if (variables.fallback && !template.match(new RegExp("{.+}"))) {
|
||||
templateToUse = variables.fallback;
|
||||
}
|
||||
return templateToUse.replace(new RegExp("{([^}]+)}", "g"), function (_unused, varName) {
|
||||
return varName in variables ? variables[varName] : `{${varName}}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.translations[module.name] && key in this.translations[module.name]) {
|
||||
return createStringFromTemplate(this.translations[module.name][key], variables);
|
||||
}
|
||||
|
||||
if (key in this.coreTranslations) {
|
||||
return createStringFromTemplate(this.coreTranslations[key], variables);
|
||||
}
|
||||
|
||||
if (this.translationsFallback[module.name] && key in this.translationsFallback[module.name]) {
|
||||
return createStringFromTemplate(this.translationsFallback[module.name][key], variables);
|
||||
}
|
||||
|
||||
if (key in this.coreTranslationsFallback) {
|
||||
return createStringFromTemplate(this.coreTranslationsFallback[key], variables);
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a translation file (json) and remember the data.
|
||||
* @param {Module} module The module to load the translation file for.
|
||||
* @param {string} file Path of the file we want to load.
|
||||
* @param {boolean} isFallback Flag to indicate fallback translations.
|
||||
*/
|
||||
async load (module, file, isFallback) {
|
||||
Log.log(`[translator] ${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`);
|
||||
|
||||
if (this.translationsFallback[module.name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await loadJSON(module.file(file));
|
||||
const property = isFallback ? "translationsFallback" : "translations";
|
||||
this[property][module.name] = json;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the core translations.
|
||||
* @param {string} lang The language identifier of the core language.
|
||||
*/
|
||||
async loadCoreTranslations (lang) {
|
||||
if (lang in translations) {
|
||||
Log.log(`[translator] Loading core translation file: ${translations[lang]}`);
|
||||
this.coreTranslations = await loadJSON(translations[lang]);
|
||||
} else {
|
||||
Log.log("[translator] Configured language not found in core translations.");
|
||||
}
|
||||
|
||||
await this.loadCoreTranslationsFallback();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the core translations' fallback.
|
||||
* The first language defined in translations.js will be used.
|
||||
*/
|
||||
async loadCoreTranslationsFallback () {
|
||||
let first = Object.keys(translations)[0];
|
||||
if (first) {
|
||||
Log.log(`[translator] Loading core translation fallback file: ${translations[first]}`);
|
||||
this.coreTranslationsFallback = await loadJSON(translations[first]);
|
||||
}
|
||||
}
|
||||
};
|
||||
}());
|
||||
|
||||
window.Translator = Translator;
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
const fs = require("node:fs");
|
||||
const { loadEnvFile } = require("node:process");
|
||||
|
||||
const modulePositions = []; // will get list from index.html
|
||||
const regionRegEx = /"region ([^"]*)/i;
|
||||
const indexFileName = "index.html";
|
||||
const discoveredPositionsJSFilename = "js/positions.js";
|
||||
|
||||
const { styleText } = require("node:util");
|
||||
const Log = require("logger");
|
||||
const globals = require("globals");
|
||||
const { Linter } = require("eslint");
|
||||
const { getConfigFilePath } = require("#server_functions");
|
||||
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
|
||||
class ConfigError extends Error {
|
||||
constructor (message) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
const requireFromString = (src) => {
|
||||
const m = new module.constructor();
|
||||
m._compile(src, "");
|
||||
return m.exports;
|
||||
};
|
||||
|
||||
// return all available module positions
|
||||
const getAvailableModulePositions = () => {
|
||||
return modulePositions;
|
||||
};
|
||||
|
||||
// return if position is on modulePositions Array (true/false)
|
||||
const moduleHasValidPosition = (position) => {
|
||||
if (getAvailableModulePositions().indexOf(position) === -1) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getModulePositions = () => {
|
||||
// if not already discovered
|
||||
if (modulePositions.length === 0) {
|
||||
// get the lines of the index.html
|
||||
const lines = fs.readFileSync(indexFileName).toString().split("\n");
|
||||
// loop thru the lines
|
||||
lines.forEach((line) => {
|
||||
// run the regex on each line
|
||||
const results = regionRegEx.exec(line);
|
||||
// if the regex returned something
|
||||
if (results && results.length > 0) {
|
||||
// get the position parts and replace space with underscore
|
||||
const positionName = results[1].replace(" ", "_");
|
||||
// add it to the list only if not already present (avoid duplicates)
|
||||
if (!modulePositions.includes(positionName)) {
|
||||
modulePositions.push(positionName);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
|
||||
}
|
||||
catch {
|
||||
Log.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror");
|
||||
}
|
||||
}
|
||||
// return the list to the caller
|
||||
return modulePositions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the config for deprecated options and throws a warning in the logs
|
||||
* if it encounters one option from the deprecated.js list
|
||||
* @param {object} userConfig The user config
|
||||
*/
|
||||
const checkDeprecatedOptions = (userConfig) => {
|
||||
const deprecated = require(`${global.root_path}/js/deprecated`);
|
||||
|
||||
// check for deprecated core options
|
||||
const deprecatedOptions = deprecated.configs;
|
||||
const usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));
|
||||
if (usedDeprecated.length > 0) {
|
||||
Log.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
|
||||
// check for deprecated module options
|
||||
for (const element of userConfig.modules) {
|
||||
if (deprecated[element.module] !== undefined && element.config !== undefined) {
|
||||
const deprecatedModuleOptions = deprecated[element.module];
|
||||
const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));
|
||||
if (usedDeprecatedModuleOptions.length > 0) {
|
||||
Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the config file. Combines it with the defaults and returns the config
|
||||
* @returns {object} an object holding full and redacted config
|
||||
*/
|
||||
const loadConfig = () => {
|
||||
Log.log("Loading config ...");
|
||||
const defaults = require("./defaults");
|
||||
if (global.mmTestMode) {
|
||||
// if we are running in test mode
|
||||
defaults.address = "0.0.0.0";
|
||||
}
|
||||
|
||||
// For this check proposed to TestSuite
|
||||
// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8
|
||||
const configFilename = getConfigFilePath();
|
||||
let templateFile = `${configFilename}.template`;
|
||||
|
||||
// check if templateFile exists
|
||||
try {
|
||||
fs.accessSync(templateFile, fs.constants.F_OK);
|
||||
Log.warn("config.js.template files are deprecated and not used anymore. You can use variables inside config.js so copy the template file content into config.js if needed.");
|
||||
} catch {
|
||||
// no action
|
||||
}
|
||||
|
||||
// check if config.env exists
|
||||
const configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf("."))}.env`;
|
||||
try {
|
||||
if (fs.existsSync(configEnvFile)) {
|
||||
// load content into process.env
|
||||
loadEnvFile(configEnvFile);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.log(`${configEnvFile} does not exist. ${error.message}`);
|
||||
}
|
||||
|
||||
// Load config.js and catch errors if not accessible
|
||||
try {
|
||||
let configContent = fs.readFileSync(configFilename, "utf-8");
|
||||
const hideConfigSecrets = configContent.match(/^\s*hideConfigSecrets: true.*$/m);
|
||||
let configContentFull = configContent;
|
||||
let configContentRedacted = hideConfigSecrets ? configContent : undefined;
|
||||
Object.keys(process.env).forEach((env) => {
|
||||
configContentFull = configContentFull.replaceAll(`\${${env}}`, process.env[env]);
|
||||
if (hideConfigSecrets) {
|
||||
if (env.startsWith("SECRET_")) {
|
||||
configContentRedacted = configContentRedacted.replaceAll(`"\${${env}}"`, `"**${env}**"`);
|
||||
configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, `**${env}**`);
|
||||
} else {
|
||||
configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, process.env[env]);
|
||||
}
|
||||
}
|
||||
});
|
||||
configContentRedacted = configContentRedacted ? configContentRedacted : configContentFull;
|
||||
const configObj = {
|
||||
configFilename: configFilename,
|
||||
configContentFull: configContentFull,
|
||||
configContentRedacted: configContentRedacted,
|
||||
redactedConf: Object.assign({}, defaults, requireFromString(configContentRedacted)),
|
||||
fullConf: Object.assign({}, defaults, requireFromString(configContentFull))
|
||||
};
|
||||
|
||||
if (Object.keys(configObj.fullConf).length === 0) {
|
||||
Log.error("WARNING! Config file appears empty, maybe missing module.exports last line?");
|
||||
}
|
||||
checkDeprecatedOptions(configObj.fullConf);
|
||||
|
||||
try {
|
||||
const cfg = `let config = { basePath: "${configObj.fullConf.basePath}"};`;
|
||||
fs.writeFileSync(`${global.root_path}/config/basepath.js`, cfg, "utf-8");
|
||||
} catch (error) {
|
||||
Log.error(`Could not write config/basepath.js file: ${error.message}`);
|
||||
}
|
||||
|
||||
return configObj;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
Log.error(`Could not find config file: ${configFilename}`);
|
||||
} else if (error.code === "EACCES") {
|
||||
Log.error(`No permission to read config file: ${configFilename}`);
|
||||
} else {
|
||||
Log.error(`Cannot access config file: ${configFilename}\n${error.message}`);
|
||||
}
|
||||
throw new ConfigError("");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the config file using eslint.
|
||||
* @param {object} configObject the configuration object
|
||||
*/
|
||||
const checkConfigFile = (configObject) => {
|
||||
let configObj = configObject;
|
||||
if (!configObj) configObj = loadConfig();
|
||||
const configFileName = configObj.configFilename;
|
||||
|
||||
// Validate syntax of the configuration file.
|
||||
Log.info(`Checking config file ${configFileName} ...`);
|
||||
|
||||
// I'm not sure if all ever is utf-8
|
||||
const configFile = configObj.configContentFull;
|
||||
|
||||
const errors = linter.verify(
|
||||
configFile,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-sparse-arrays": "error",
|
||||
"no-undef": "error"
|
||||
}
|
||||
},
|
||||
configFileName
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
Log.info(styleText("green", "Your configuration file doesn't contain syntax errors :)"));
|
||||
validateModulePositions(configObj.fullConf);
|
||||
} else {
|
||||
let errorMessage = "Your configuration file contains syntax errors :(";
|
||||
|
||||
for (const error of errors) {
|
||||
errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
throw new ConfigError("");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the modules array in the config object.
|
||||
* Checks that:
|
||||
* - `modules` is an array
|
||||
* - every entry has a `module` property of type string
|
||||
* - every entry's `position` (if set) is a known region from index.html
|
||||
*
|
||||
* Unknown positions produce a warning; structural errors are fatal.
|
||||
* @param {object} data - The full config object to validate.
|
||||
*/
|
||||
const validateModulePositions = (data) => {
|
||||
Log.info("Checking modules structure configuration ...");
|
||||
|
||||
const positionList = getModulePositions();
|
||||
|
||||
// `modules` always exists (defaults.js provides a default array), but guard against it being overridden with a non-array value
|
||||
if (data.modules !== undefined && !Array.isArray(data.modules)) {
|
||||
Log.error("This module configuration contains errors:\nmodules must be an array");
|
||||
throw new ConfigError("");
|
||||
}
|
||||
|
||||
// Validate each module entry
|
||||
for (const [index, mod] of (data.modules ?? []).entries()) {
|
||||
// Each module entry must be an object so we can safely inspect its fields
|
||||
if (mod === null || typeof mod !== "object" || Array.isArray(mod)) {
|
||||
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule entry must be an object`);
|
||||
throw new ConfigError("");
|
||||
}
|
||||
|
||||
// `module` (the module name) is required and must be a string
|
||||
if (typeof mod.module !== "string") {
|
||||
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule: must be a string`);
|
||||
throw new ConfigError("");
|
||||
}
|
||||
|
||||
// `position` is optional, but must be a string when provided
|
||||
if (mod.position !== undefined && typeof mod.position !== "string") {
|
||||
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nposition: must be a string`);
|
||||
throw new ConfigError("");
|
||||
}
|
||||
|
||||
// `position` is optional, but when set it must match a known region
|
||||
if (mod.position && !positionList.includes(mod.position)) {
|
||||
Log.warn(`Module ${index} ("${mod.module}") uses unknown position: "${mod.position}"`);
|
||||
Log.warn(`Known positions are: ${positionList.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
|
||||
};
|
||||
|
||||
module.exports = { loadConfig, getModulePositions, moduleHasValidPosition, getAvailableModulePositions, checkConfigFile, ConfigError };
|
||||
@@ -0,0 +1,14 @@
|
||||
const vendor = {
|
||||
"moment.js": "node_modules/moment/min/moment-with-locales.js",
|
||||
"moment-timezone.js": "node_modules/moment-timezone/builds/moment-timezone-with-data.js",
|
||||
"weather-icons.css": "node_modules/weathericons/css/weather-icons.css",
|
||||
"weather-icons-wind.css": "node_modules/weathericons/css/weather-icons-wind.css",
|
||||
"font-awesome.css": "css/font-awesome.css",
|
||||
"nunjucks.js": "node_modules/nunjucks/browser/nunjucks.min.js",
|
||||
"suncalc.js": "js/suncalc-global.mjs",
|
||||
"croner.js": "node_modules/croner/dist/croner.umd.js"
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = vendor;
|
||||
}
|
||||
Reference in New Issue
Block a user