From cd7de51cf8bbab6a35eff37df35bb948de8bf55c Mon Sep 17 00:00:00 2001 From: intermittech Date: Sat, 18 Jul 2026 02:51:05 +0200 Subject: [PATCH 1/2] Add usermod web UI injection mechanism (/um.js), design by @blazoncek --- wled00/data/index.js | 19 +++++++++++++++++++ wled00/fcn_declare.h | 6 ++++++ wled00/um_manager.cpp | 1 + wled00/wled_server.cpp | 12 ++++++++++++ 4 files changed, 38 insertions(+) diff --git a/wled00/data/index.js b/wled00/data/index.js index c91b8b06a4..6ce855db83 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -226,6 +226,23 @@ function loadSkinCSS(cId) { function getURL(path) { return (loc ? locproto + "//" + locip : "") + path; } + +// load usermod UI inject code (served by the device when usermods provide any); +// umInject(state) is then called after every state render, see readState() +function loadUmInject() { + if (gId("um")) return; // already loaded + let scE = d.createElement("script"); + scE.id = "um"; + scE.src = getURL("/um.js"); + scE.async = false; + scE.onload = () => { + if (typeof umInject == "function") requestJson(); // render once with state available + }; + scE.onerror = (ev) => { + console.log("Usermod inject script not present or failed to load", ev); + }; + d.body.appendChild(scE); +} function onLoad() { let l = window.location; @@ -1604,6 +1621,7 @@ function readState(s,command=false) selectedFx = i.fx; redrawPalPrev(); // if any color changed (random palette did at least) updateUI(); + if (typeof umInject == "function") umInject(s); // usermod UI injections (see loadUmInject()) return true; } @@ -1802,6 +1820,7 @@ async function requestJson(command=null, retry=0) { } var s = json.state ? json.state : json; readState(s); + if (json?.info?.u) loadUmInject(); // usermods present: load their UI inject code reqsLegal = true; resolve(); diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 6201a19192..fb2c73a755 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -343,6 +343,10 @@ typedef struct UM_Exchange_Data { } um_data_t; const unsigned int um_data_size = sizeof(um_data_t); // 12 bytes +// usermods can inject JS into the main web UI via addUIInjectCode() (served at /um.js); +// external usermods can test this macro to stay compatible with older WLED bases +#define WLED_ENABLE_UM_UI_INJECT + class Usermod { protected: um_data_t *um_data; // um_data should be allocated using new in (derived) Usermod's setup() or constructor @@ -367,6 +371,7 @@ class Usermod { virtual bool onUdpPacket(uint8_t* payload, size_t len) { return false; } //fired upon UDP packet received virtual void onUpdateBegin(bool) {} // fired prior to and after unsuccessful firmware update virtual void onStateChange(uint8_t mode) {} // fired upon WLED state change + virtual void addUIInjectCode(Print &dest) {} // print JS code injecting UI elements into the main web UI (served at /um.js, run after every state render) virtual uint16_t getId() {return USERMOD_ID_UNSPECIFIED;} // API shims @@ -407,6 +412,7 @@ namespace UsermodManager { bool onUdpPacket(uint8_t* payload, size_t len); void onUpdateBegin(bool); void onStateChange(uint8_t); + void addUIInjectCode(Print &dest); Usermod* lookup(uint16_t mod_id); size_t getModCount(); }; diff --git a/wled00/um_manager.cpp b/wled00/um_manager.cpp index 504b5ba97c..b6e7bfc033 100644 --- a/wled00/um_manager.cpp +++ b/wled00/um_manager.cpp @@ -73,6 +73,7 @@ bool UsermodManager::onUdpPacket(uint8_t* payload, size_t len) { } void UsermodManager::onUpdateBegin(bool init) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->onUpdateBegin(init); } // notify usermods that update is to begin void UsermodManager::onStateChange(uint8_t mode) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->onStateChange(mode); } // notify usermods that WLED state changed +void UsermodManager::addUIInjectCode(Print &dest) { for (auto mod = DYNARRAY_BEGIN(usermods); mod < DYNARRAY_END(usermods); ++mod) (*mod)->addUIInjectCode(dest); } // collect usermod UI inject JS (served at /um.js) /* * Enables usermods to lookup another Usermod. diff --git a/wled00/wled_server.cpp b/wled00/wled_server.cpp index 0b4d0fb546..5d344a02bf 100644 --- a/wled00/wled_server.cpp +++ b/wled00/wled_server.cpp @@ -476,6 +476,18 @@ void initServer() request->send(200, FPSTR(CONTENT_TYPE_PLAIN), (String)getFreeHeapSize()); }); + // usermod UI inject code: the main UI loads this script and calls umInject(state) after + // every state render, letting usermods add their own elements without patching index.js + server.on(F("/um.js"), HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream(FPSTR(CONTENT_TYPE_JAVASCRIPT)); + response->addHeader(FPSTR(s_cache_control), F("no-store")); + response->addHeader(F("Expires"), F("0")); + response->print(F("function umInject(s){")); + UsermodManager::addUIInjectCode(*response); + response->print(F("}")); + request->send(response); + }); + #ifdef WLED_ENABLE_USERMOD_PAGE server.on("/u", HTTP_GET, [](AsyncWebServerRequest *request) { handleStaticContent(request, "", 200, FPSTR(CONTENT_TYPE_HTML), PAGE_usermod, PAGE_usermod_length); From 8d86f87392f89658fa7a383f6005a7667f1c31ee Mon Sep 17 00:00:00 2001 From: intermittech Date: Sat, 18 Jul 2026 03:41:49 +0200 Subject: [PATCH 2/2] Address review: shield injected JS, avoid redundant fetch on load - umInjectSafe() wraps umInject() in try/catch so an exception in usermod-provided JS cannot abort readState() and trigger requestJson()'s retry loop - pass the current state into loadUmInject(s) and run the first injection directly on script load instead of refetching /json/si - use json.info && json.info.u to match the surrounding style --- wled00/data/index.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/wled00/data/index.js b/wled00/data/index.js index 6ce855db83..404fdf7913 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -228,21 +228,30 @@ function getURL(path) { } // load usermod UI inject code (served by the device when usermods provide any); -// umInject(state) is then called after every state render, see readState() -function loadUmInject() { +// umInjectSafe(state) is then called after every state render, see readState() +function loadUmInject(s) { if (gId("um")) return; // already loaded let scE = d.createElement("script"); scE.id = "um"; scE.src = getURL("/um.js"); scE.async = false; - scE.onload = () => { - if (typeof umInject == "function") requestJson(); // render once with state available - }; + scE.onload = () => umInjectSafe(s); // render once with the state captured at load time scE.onerror = (ev) => { console.log("Usermod inject script not present or failed to load", ev); }; d.body.appendChild(scE); } + +// run usermod UI inject code, shielding the UI from exceptions in usermod-provided JS +// (an uncaught throw here would abort readState() and trigger requestJson()'s retry loop) +function umInjectSafe(s) { + if (typeof umInject != "function") return; + try { + umInject(s); + } catch (e) { + console.error("Usermod UI inject error:", e); + } +} function onLoad() { let l = window.location; @@ -1621,7 +1630,7 @@ function readState(s,command=false) selectedFx = i.fx; redrawPalPrev(); // if any color changed (random palette did at least) updateUI(); - if (typeof umInject == "function") umInject(s); // usermod UI injections (see loadUmInject()) + umInjectSafe(s); // usermod UI injections (see loadUmInject()) return true; } @@ -1820,7 +1829,7 @@ async function requestJson(command=null, retry=0) { } var s = json.state ? json.state : json; readState(s); - if (json?.info?.u) loadUmInject(); // usermods present: load their UI inject code + if (json.info && json.info.u) loadUmInject(s); // usermods present: load their UI inject code reqsLegal = true; resolve();