10 KiB
Implementation notes
Load-bearing facts about LuCI and OpenWrt that this package depends on, why the
code is shaped the way it is, and how to re-verify each claim. None of it is
derivable from this repository alone: the upstream behaviour lives in
luci-base, luci-mod-status and the active theme.
Upstream references are paths inside openwrt/luci.
The Overview page rebuilds itself every poll cycle
luci-mod-status/htdocs/luci-static/resources/view/status/index.js builds one
.cbi-section per include once, in render(), and then on every poll cycle
calls each include's load() and render() again and replaces the section
content through dom.content. The interval is luci.main.pollinterval,
default 5 seconds.
Two consequences drive the design of luci/overview.js:
-
Never move a section unless the order actually changed. Re-appending a node re-inserts it, and re-insertion restarts its CSS animations. The theme puts
fade-inon every revealed section —.fade-in { animation: fade-in .4s ease }withopacity0 → 1inthemes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css— so re-appending all sections on each cycle makes the whole page flash every five seconds.applyLayout()therefore compares the wanted order against the live DOM order and touches nothing when they match. -
Never clear a visibility class and re-add it. Dropping
overview-manager-hiddenfrom every section and then re-adding it exposes every hidden widget for a frame. UseclassList.toggle(name, force), which only mutates when the state really changes.
scripts/test-overview-ui.js pins both: it counts appendChild calls and class
mutations across a second identical render and fails if either is non-zero.
Reverting either property makes it fail, which is worth re-checking if the test
is ever refactored.
dom.content (in luci.js) removes all children and appends the new ones in
one synchronous task, so replacing our own container each cycle cannot paint an
intermediate state. Only node moves and class churn are visible.
load() caches its result for the lifetime of the page. The saved layout cannot
change while the Overview page stays open, and the alternative is two helper
process spawns every five seconds forever. A failed read is deliberately not
cached, so a transient rpcd error retries on the next cycle instead of pinning
an empty widget list for the session.
Widget-to-section mapping is positional and order-sensitive
There is no attribute tying a rendered section to the include that produced it,
so the mapping is by index: the n-th .cbi-section belongs to the n-th
include. data-overview-widget is stamped on first use and reused afterwards.
index.js requires its includes as view.status.include.<name> and sorts
those module names, so the .js suffix takes no part in the ordering. A
file-name sort is not the same sort: - is 0x2D and . is 0x2E, so plain file
names order 20_x-y.js before 20_x.js, while module names order 20_x before
20_x-y. One such pair shifts every later widget onto the wrong section, and
the visible effect is that the wrong card is hidden or moved.
common.compareWidgets exists for exactly this and must stay code-unit based.
localeCompare would reorder per browser locale and reintroduce the bug in a
locale-dependent way.
Two further constraints follow from the same mechanism:
- The include count must equal the section count, or
applyLayout()gives up after eight retries and silently does nothing.index.jsfiltersfs.listentries bytype == 'file', so an include installed as a symlink would be skipped there while the shell helper's[ -f ]follows it and counts it. Nothing in the tree creates such a symlink today; if the layout ever stops applying on a specific device, check this first. titlemust be non-empty and stable.index.jsuses the include title as thelocalStoragekey for its own per-browser Hide button (includes[i].id = title), so an empty title claims the empty key and collides with any other untitled include. It is deliberately not wrapped in_(): a translated title would change that key whenever the interface language changes.
Client-side translation
The chain is not obvious and is easy to get subtly wrong.
- The global
_()is defined inluci-base/htdocs/luci-static/resources/cbi.js, not inluci.js. It isTR[sfh(trimws(s))] || s. window.TRis emitted byadmin/translations/<lang>(luci-base/ucode/controller/admin/index.uc), which the theme header loads on every page, including the login page. It is a flat map of eight-hex-digit hashes to strings, merged from every*.<lang>.lmoin/usr/lib/lua/luci/i18n.- The lookup key is the hash of the whitespace-normalised msgid:
trimwsincbi.jstrims and collapses runs of space, tab and newline.po2lmohashes the raw msgid from the catalog. Therefore msgids must already be normalised — one line, single spaces, no leading or trailing whitespace — or the runtime lookup misses and the string silently stays English. - Because all catalogs share one namespace in an unspecified merge order, a
string that
luci-basealso translates must either be left out or given the identical translation.Show,Move upandMove downare in that category and use the exactluci-basewording so the collision cannot matter.
scripts/test-po2lmo.py enforces the normalisation rule, the catalog-to-source
correspondence in both directions, and round-trips every translation through the
real hash and a minimal LMO reader.
Registering the language is mandatory
Shipping a catalog is not enough. determine_request_language() in
luci-base/ucode/dispatcher.uc only accepts a language that exists in
luci.languages; with luci.main.lang=auto and no matching entry it falls
through to en. A router with no luci-i18n-* package installed has an empty
luci.languages, so the catalog would never be selected.
openwrt/files/etc/uci-defaults/luci-app-overview-manager adds the entry, the
same way luci.mk generates it for stock luci-i18n-* packages. Because this
package defines its own postinst, the default handler that drains
/etc/uci-defaults does not run, so postinst invokes the script itself and
removes it on success; otherwise the language would only appear after the next
reboot.
Verify on a device:
wget -qO- 'http://127.0.0.1/cgi-bin/luci/admin/translations/ru' | head -c 400
uci show luci.languages
and that content negotiation resolves as expected:
curl -s -H 'Accept-Language: ru' http://<router>/cgi-bin/luci/ | grep -o '<html lang="[^"]*"'
scripts/po2lmo.py must stay byte-compatible
It reimplements luci-base/src/po2lmo.c. Format details that are easy to lose:
values are written first, each padded to a four-byte boundary; the index of
16-byte big-endian records follows, sorted by key hash; the file ends with the
total size of the value blob. The val_id field does not hold a hash, it holds
the plural count. Entries whose key and value hash identically are omitted so
the reader falls back to the source string. extract_string unescapes only \"
and \\ — \n stays two literal characters, which is what makes the header's
Plural-Forms parsing work. An empty catalog produces no file at all.
To re-verify after any change, build the reference tool and diff its output. The reader needs a generated parser header that is not required for writing, so an empty stub is enough:
git clone --depth 1 --filter=blob:none --no-checkout https://github.com/openwrt/luci
cd luci && git sparse-checkout set modules/luci-base && git checkout master
cd .. && mkdir -p oracle/lib
cp luci/modules/luci-base/src/po2lmo.c oracle/
sed -n '1,72p' luci/modules/luci-base/src/lib/lmo.c > oracle/lib/sfh.c
cp luci/modules/luci-base/src/lib/lmo.h oracle/lib/
: > oracle/lib/plural_formula.h
cc -O1 -Ioracle/lib -o oracle/po2lmo oracle/po2lmo.c oracle/lib/sfh.c
PO2LMO_REFERENCE=$PWD/oracle/po2lmo python3 scripts/test-po2lmo.py
The same binary can be diffed against every catalog in luci-base/po/*/*.po as
a broader check; all of them currently match byte for byte.
Static resources are cached without a cache-buster
L.require builds module URLs as
<base>/<path>.js plus ?v=<env.resource_version> only when that field is
set, and the OpenWrt 25.12 page template does not set it — it versions
luci.js alone. uhttpd serves the files with ETag and Last-Modified but no
Cache-Control, so browsers apply heuristic freshness and can reuse a stale
copy for a long time.
Practical effect: after changing anything under
/www/luci-static/resources/, a normal reload may keep running the old code. A
reload that bypasses the cache is required — in Safari that is
Cmd+Option+R ("Reload Page From Origin"); Cmd+Shift+R does nothing there.
Confirm which code is live by reading the section titles, since a stale include
renders its section heading differently:
[...document.querySelectorAll('.cbi-section')].map(s => s.querySelector('h3')?.textContent.trim())
Shell that ships to the router runs under BusyBox
The test suite runs on GNU coreutils, which silently accepts constructs a stock
OpenWrt image does not have: sort -o, base64, grep -P and find -printf
among them. scripts/check-busybox-compat.sh scans the staged package tree
rather than the sources, so the checked set cannot drift from what is installed;
it also runs busybox ash -n on each script when a busybox binary is present,
which no pattern list can replace.
Four files reach the router: CONTROL/postinst, CONTROL/prerm,
etc/uci-defaults/luci-app-overview-manager and usr/libexec/overview-manager.
Working against a real device
-
OpenWrt has no
sftp-server, soscpfails. Stream instead:ssh <host> 'cat > /path' < localfile. -
apk-toolson the device has nomkpkg, and OpenWrt 25.12 cannot install an.ipk. Building a package needs the SDK; for a quick check, deploy the payload files and compare checksums. -
ACL changes need
/etc/init.d/rpcd reload, which drops active LuCI sessions. -
Name the package in every transaction. A blanket upgrade is never appropriate on a live router;
scripts/check-apk-trust.shfails the build if one appears in a script or in the documentation. -
Verify a published package with the device's own
apkand the tracked public key, and confirm it is rejected without it:apk verify --keys-dir <dir-with-public-key> <package>.apk