chore: import upstream snapshot with attribution
Enforce Pull-Request Rules / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:19:24 +08:00
commit dbe3ade0dc
449 changed files with 71828 additions and 0 deletions
+31
View File
@@ -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
}
+159
View File
@@ -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 };
+348
View File
@@ -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();
+16
View File
@@ -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);
}
+93
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
configs: [],
clock: ["secondsColor"]
};
+211
View File
@@ -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();
});
});
}
+30
View File
@@ -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 };
+352
View File
@@ -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;
+109
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+178
View File
@@ -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;
+198
View File
@@ -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
View File
@@ -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;
+272
View File
@@ -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 };
+49
View File
@@ -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;
+8
View File
@@ -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;
+49
View File
@@ -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();
+129
View File
@@ -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
View File
@@ -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 };
+14
View File
@@ -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;
}