Files
small-packages/luci-theme-footstrap/docs/package.md
T
2026-09-20 01:22:33 +08:00

493 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The package: source tree, Makefile, install scripts
Reference for what is where in `luci-theme-footstrap/`, what the Makefile does that a template
theme would not, and what runs on the router at install and removal time.
How the packages are actually built and published: [ci.md](ci.md).
## Source tree
```
luci-theme-footstrap/
├── Makefile
├── build-css.sh styles/ → cascade.css (cat + awk, no node)
├── mangle-tokens.sh shorten the private --fs-* names in a BUILT sheet
├── strip-templates.sh drop the comments from .ut (template and whole-line code)
├── strip-shell.sh drop whole-line # from root/**.sh
├── build-apk.sh SDK build, kept so the theme stays buildable without owfeed
├── dev-sync.sh deploy to a HARDWARE router over ssh (containers use owlab)
├── update-po.sh regenerate/verify the translation catalogue
├── luci-upstream.pin pinned openwrt/luci commit + sha256 of the borrowed tools
├── styles/ CSS SOURCE. Not shipped — luci.mk does not copy it
├── po/ translation catalogue; luci.mk turns it into luci-i18n-* packages
│ ├── templates/footstrap.pot
│ ├── ru/footstrap.po
│ └── es/footstrap.po
├── htdocs/luci-static/ → /www/luci-static/
│ ├── footstrap/ cascade.css (GENERATED, gitignored), logo.svg,
│ │ manifest.json + app-icon-192.png (ONE raster: every browser that
│ │ installs a page picks the largest icon and downscales, and iOS reads
│ │ the apple-touch-icon LINK, which points at the same file)
│ │ (the raster is COMMITTED and made by tools/build-icons.mjs — the
│ │ buildbot has no browser; `npm run icons` proves it matches logo.svg)
│ │ (pattern.svg and fonts/ are NOT here: they are symlinks uci-defaults
│ │ makes to /etc/footstrap/, which the admin uploads or installs)
│ └── resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
├── root/ → /
│ ├── etc/uci-defaults/30_luci-theme-footstrap
│ ├── etc/config/footstrap empty stub, written at runtime
│ ├── lib/upgrade/keep.d/luci-theme-footstrap what sysupgrade carries across a flash
│ └── usr/share/rpcd/acl.d/luci-theme-footstrap.json
└── ucode/template/themes/footstrap/ → /usr/share/ucode/luci/template/…
├── header.ut footer.ut sysauth.ut
└── partials/{head,brand,logout,notices,notice,search,icon,footer}.ut
```
`luci.mk` installs by directory presence — no install recipes needed:
| Source | Installed to |
|---|---|
| `ucode/*` | `/usr/share/ucode/luci/` |
| `htdocs/*` | `/www/` |
| `root/*` | `/` |
| `root/etc/uci-defaults/*` | picked up as `LUCI_DEFAULTS` |
Only `src/ luasrc/ htdocs/ root/ ucode/ po/` are copied verbatim. `styles/` is not in that list,
which is why `cascade.css` is generated in `Build/Prepare` straight into the build tree and is
absent from git.
**Never edit `cascade.css`.** Colours go in `styles/03-palettes.css`, scales and tokens in
`styles/02-tokens.css`. See [css.md](css.md).
**The web manifest is a static file, and that is a constraint, not a shortcut.** A theme may not
register a dispatcher node — it would outlive the theme that registered it — so there is nothing that
could render the manifest per request. Two values are fixed by that: `start_url`/`scope` are
`/cgi-bin/luci/`, uhttpd's own default, and `background_color`/`theme_color` are the DEFAULT palette's
page colour, since the manifest is read once at install time and cannot follow a live Appearance
change (it paints the splash and the installed window's chrome, never the page). Chrome's install
prompt needs a secure context, so over plain HTTP what this buys is iOS's Add to Home Screen — which
reads the `apple-touch-icon` link rather than the manifest — plus the icon itself. Regenerate it with
`node tools/build-icons.mjs` whenever `logo.svg` changes; `npm run icons` fails if you forget.
The committed file is **quantised to a 32-colour palette**, which is 4.6 KB where the browser's own
RGBA screenshot is 15.7 KB: the picture is a flat background, one ink colour and the ramp between
them, so a palette is the right encoding and the worst channel moves by 18 of 255 on 0.2% of the
pixels. That step is the one thing in this repo that wants **ImageMagick**, and only when
regenerating — `npm run icons` compares PIXELS in the browser it already runs, so a different
ImageMagick version is not a failure and a redrawn logo still is. Nothing in luci-base could stand in
for the icon, which is worth stating: it ships functional glyphs (interfaces, signal bars, ports) and
no logo or raster of any kind, and every theme carries its own.
## Makefile: what differs from a template theme
```makefile
PKG_NAME:=luci-theme-footstrap
LUCI_NAME:=luci-theme-footstrap # pin: luci.mk keys the Build/Prepare hook name on LUCI_NAME,
# which defaults to the checkout directory — the CSS build
# would silently not run in a renamed checkout
FOOTSTRAP_VERSION?= # CI injects it from the tag; locally the version is git-derived
LUCI_TITLE:=Footstrap Theme
LUCI_DEPENDS:=+luci-base # the WHOLE dependency list
LUCI_PKGARCH:=all # noarch: one build for every target
LUCI_MAINTAINER / LUCI_URL # otherwise the package claims "OpenWrt LuCI community"
LUCI_MINIFY_CSS:=0 # see below
PKG_LICENSE:=Apache-2.0 # the theme, and nothing else: it carries no webfonts
include $(TOPDIR)/feeds/luci/luci.mk # ABSOLUTE, not ../../luci.mk: CI rsyncs the package into
# package/, not into the feed
```
**Do not set `PKG_VERSION`.** `luci.mk` derives it from git; CI injects `FOOTSTRAP_VERSION` from
the tag, because an SDK build has no `.git` to derive from.
### Minification: CSS off, JS on two paths
Two different tools; confusing them is expensive.
- **`LUCI_MINIFY_CSS:=0` is mandatory.** luci.mk's CSS minifier is **csstidy**, old enough to
mangle `:has()`, `color-mix()` and nested `calc()`: the package installs and the layout falls
apart. `build-css.sh` minifies instead — a string-aware awk pass of its own.
- **`LUCI_MINIFY_JS` has two paths.** A release CI build pre-minifies with **terser**
(`tools/minify-js.mjs`, which can mangle identifiers — jsmin cannot) and sets
`FOOTSTRAP_PREMIN=1`, which turns `LUCI_MINIFY_JS` to `0`; jsmin on top of terser output would
reopen the `return /re/` trap on forms terser legitimately emits. A build **without** node (SDK
user, buildbot) keeps the default `1`, and jsmin minifies the untouched source. Both paths
matter: comments are ~60% of the JS source, and uhttpd serves `/www` **uncompressed**, so those
are bytes on the wire and in flash.
The source therefore has to stay jsmin-safe — see the regex rule in
[conventions.md](conventions.md).
### `Build/Prepare` — six steps, in this order
The hook (its name keys on `LUCI_NAME`) runs right after luci.mk copies the sources into
`PKG_BUILD_DIR`, and edits the **copy**:
1. **Copy `LICENSE`** into `PKG_BUILD_DIR` — `PKG_LICENSE_FILES` resolves against *that*, and
luci.mk does not copy the package root.
2. **`build-css.sh`** → `cascade.css` in the build tree. `cat`/`awk` only, so it runs on the
OpenWrt buildbot with no host toolchain.
3. **`mangle-tokens.sh`** — shorten the private `--fs-*` names, 16% of the sheet.
**Before** step 4 on purpose: the reserved set is derived by reading the JS and the templates,
so it must see them whole — and step 4 is what strips the template comments. It reads them from the **source** tree, never from
`PKG_BUILD_DIR` — in CI the build tree's JS has already been through terser, its comments are
gone, and five names that only appear in a comment would stop being reserved. That made the
shipped sheet depend on *who* built it.
4. **`strip-templates.sh`** — the comments out of the `.ut` files: `{# … #}` template comments, and
`/* … */` code comments **that own their lines**, wherever they sit (ucode inside `{% … %}`,
JavaScript inside an inline `<script>`, CSS inside an inline `<style>`). 63 KB of templates
become 21 KB, which is **−7.4 KB of the compressed package** — it went from 72.4 KB to 65.2 KB —
and −7 KB on every page the router serves, since uhttpd serves `/www` uncompressed.
The rule is what makes this safe, and it is not "remove `/* … */`": a comment is removed only
when `/*` is the first non-blank thing on its line and `*/` the last on its (possibly later)
line. To eat live code, a string literal would have to span lines *and* contain a line that is
nothing but a comment; measured across every `.ut` here, 18362 of 18362 comment bytes are
whole-line, none are inline, and no multi-line template literal contains a line-leading `/*`.
Anything that does not fit the rule is left in place and counted — the tree has exactly one such
case today, the glob `` `/usr/lib/lua/luci/i18n/*.${lang}.lmo` ``, whose `/*` is in a string.
Verified on a live router, not only by reading: the pages the stripped templates serve are
byte-identical to the ones the source templates serve, once the comments are removed from both
(the only remaining difference is the session id).
5. **`strip-shell.sh`** — whole-line `#` out of the shell under `root/`.
6. **Stamp `FS_VERSION`** into `fs-version.js` with `sed`. The path is part of the contract —
`dev-sync.sh` and `tools/stage.sh` run the same substitution, so moving the constant means
fixing three places.
### The catalogue lives in `po/`, and luci.mk owns it
`LUCI_LANGUAGES` in luci.mk is `$(wildcard po/*)`, so a `po/` directory makes it bake a
`luci-i18n-footstrap-<lang>` package per language — the ordinary arrangement for everything in the
luci tree, and the only one [Weblate](https://hosted.weblate.org/engage/openwrt/) can translate,
which `CONTRIBUTING.md` names as *the* way to translate LuCI. Nothing in this package's own
`Build/Prepare` touches the catalogue.
It was `i18n/` from v0.8.5 to v0.12.x, which is what stopped the language packages being
generated. The reason was issue #6: the self-update script people had installed **at the time** picked
its asset with `grep -E '\.apk$' | head -1`, GitHub returns assets **sorted by name**, and
`luci-i18n-…` sorts before `luci-theme-…` — so the Update button installed a 6 KB catalogue
instead of the theme, reported success, and offered the same update forever. A script already on
somebody's router cannot be fixed remotely, so the *release* was fixed instead.
That script is retired, and the release is built by owfeed. From 0.14.4 the owfeed build emits the
same set luci.mk would: **`luci-theme-footstrap`, plus one `luci-i18n-footstrap-<lang>` per
language**, with the catalogue at `footstrap.<lang>.lmo` and a `uci-defaults` line registering the
language in LuCI's own menu. `tools/stage.sh` builds the per-language staging trees, `owfeed.yml`
declares one package each, and `tools/i18n-packages.mjs` fails the build when those three lists
disagree.
Between v0.12.x and 0.14.3 the catalogues rode **inside** the theme under the basename
`footstrap-theme.<lang>.lmo`. That cost every router 10,992 B of flash and 4,821 B of the `.apk`,
including the majority reading English, and it was the bundling that forced the odd basename in the
first place — a router still owning `footstrap.<lang>.lmo` through the old language package would
otherwise have hit a file conflict. Split back out under luci.mk's own names, that conflict is an
ordinary upgrade of the same package instead. Measured on both stands: upgrading the theme takes the
old `footstrap-theme.*.lmo` away with it, and installing `luci-i18n-footstrap-ru` puts
`footstrap.ru.lmo` down in its place, with `luci.languages.ru` registered and the chrome rendering
in Russian.
**A package manager cannot read `uci luci.main.lang`, so the catalogue has to install itself.**
`depends` points from the catalogue to the theme, which means installing or upgrading the THEME
pulls in nothing: measured on a stand, a Russian router on 0.14.3 taking 0.14.4 through the feed lost
every `.lmo` with the old package and gained no new one — English theme, nothing said why (issue
#41). Three mechanisms cover the three ways a router can arrive at the new shape, and none of them
covers all of it:
| path | what carries it |
|---|---|
| `install.sh`, install or re-run | reads `luci.main.lang` and fetches the catalogue, from the feed or — when the feed has not got it yet — from the signed release, pinned to the tag the installed theme came from |
| `apk upgrade` on 25.12+ | `install-if: luci-theme-footstrap={version} luci-i18n-base-<lang>` in `owfeed.yml`: apk installs the catalogue by itself once the theme is at that version and the router already carries that language's LuCI base. The pin re-fires the rule on every theme upgrade |
| `opkg upgrade` on 24.10 | nothing. opkg's `Recommends:` is executed but unconditional — it would put Russian on every 24.10 router — and a postinst cannot call opkg, which holds an exclusive lock for the whole run. This leg is `install.sh` or nothing |
`install-if` needs at least two entries with one pinned by `=` (apk-package(5)); `{version}` is
expanded by owfeed to the version being built, so a release does not hand-edit the pin.
Where none of the three fired, the Appearance tab says so: it asks `_('Layout', 'footstrap')` — a
string every catalogue carries — and, when the answer comes back untranslated on a non-English
router, names the package. That check is why `Appearance` is the wrong string to test: it is
obsolete in the catalogues (`#~ msgid`) and reported "not translated" on a router whose Russian
catalogue was installed and working.
`install.sh` reads `luci.main.lang` after installing the theme and fetches the matching package
best-effort, so the split does not silently un-translate a router on the upgrade that introduces it.
A language with no catalogue, `en`, and `auto` are all no-ops there. **A feed that does not carry
the catalogue is not the end of that path**: a new package reaches owfeed-packages through a pull
request against it, so `luci-i18n-footstrap-ru` and `-es` were absent from the feed for as long as
that took while the signed assets sat in the release — the installer falls back to the release,
through the same verified chain, and pins it to the tag the INSTALLED theme came from rather than to
`latest`, because the feed trails the release by up to a day and a catalogue knows only the strings
of its own version. `tools/check-packages.sh`
asserts one theme package per format, one catalogue per language package, and none in the theme.
## uci-defaults: registration
`root/etc/uci-defaults/30_luci-theme-footstrap` is the **single source of truth** for
registration (`dev-sync.sh` runs the same file; nothing else registers the theme).
- Registers **one** entry: `luci.themes.Footstrap=/luci-static/footstrap`. Layout, palette, mode
and rounding are **client** switches on the Footstrap tab.
- The key in `themes.<Name>` is CamelCase without hyphens — a uci option-name limitation.
- Links the three admin uploads into `/www`: `bg`, `pattern.svg` and the `fonts/` directory all
live in `/etc/footstrap`, because uhttpd serves `/www` only and `/etc` is what a sysupgrade
keeps. The pattern keeps its `.svg` name — uhttpd types a response by extension.
**It runs TWICE per install, not once.** Our `postinst` calls it, and OpenWrt's stock
`default_postinst` separately runs and then deletes every `/etc/uci-defaults/*` in the package.
The script is idempotent, so this is harmless.
**Fresh install vs upgrade is decided by the registration itself.** `mediaurlbase` is written only
in the run that first added `luci.themes.Footstrap`; on an upgrade the entry is already there, so
nothing moves a router off the theme it is on. `[ "$PKG_UPGRADE" != 1 ]` is checked beside it, as
the other themes in the tree do, but carries nothing on its own: apk never exports the variable and
neither does our postinst.
**It migrates nothing from older footstraps, on purpose.** A router is expected to `sysupgrade`
rather than upgrade single packages, so leftovers go with the image; what remains worth cleaning is
config, and the one config key this package owns is the theme entry. That was not always so — the
script used to delete eight legacy theme names, re-point four legacy media paths, carry the old
top-bar layout into `luci.main.footstrap_layout`, sweep two downloaded wallpapers and a pre-0.12.1
`fonts/` directory, and fall back to bootstrap if the active theme's files were missing. All of it
served installs that predate the first version published in the LuCI tree. Requested in review:
start from the assumption that a user of the official package started there.
## postinst / postrm
`postinst` re-runs uci-defaults and clears the LuCI caches. It does **NOT** `rpcd reload` — nor
does `postrm`, nor `dev-sync.sh` — and the reason is its own section below.
`postrm` does three things, and exits early on an upgrade (`case "$1" in *upgrade*`) because opkg
runs the OLD package's postrm mid-upgrade — reverting `mediaurlbase` there is what once flipped
every updating 24.10 user back to bootstrap:
- deletes `luci.themes.Footstrap`;
- if our theme is still active, moves `mediaurlbase` back to bootstrap on a **two-part** check
(both the media directory *and* the ucode template must exist: a one-sided check would hand the
UI to a half-removed bootstrap, which is the white page this branch was written for);
- removes `/etc/footstrap` — the admin's uploads, kept out of the package so an upgrade preserves
them, and a real removal is the one time they should go.
## Why postinst/postrm never call `rpcd reload`
They used to — `reload`, never `restart`, because `restart` logs out every LuCI session (rpcd
holds them in memory) while `reload` (`SIGHUP`) is supposed to survive them. Measured on an
owrt2512 stand, that survival claim is true but beside the point: `reload` is `exec_self()`, a
full re-exec that re-scans the plugin dirs and `dlopen()`s every `.so`. A plugin file absent or
mid-write at that instant is **dropped**, past one stderr line, and does **not** come back on its
own:
```
mv /usr/lib/rpcd/file.so /tmp/ ; rpcd reload -> `ubus list` loses `file`
file restored, no reload -> still missing
reload (or restart) -> back
```
This was first found against the `luci` ucode plugin — a reload racing another package's file
replacement left `luci/getFeatures`, `luci/getTimezones` and `luci/getMountPoints` all answering
`-32000 Object not found` until the next reload, reported from the field on a SNAPSHOT router and
cleared only by a reboot. The same race against `file` is worse: it is what makes System ->
Backup/Flash Firmware's "Reset to defaults" row silently vanish, because `view/system/flash.js`
reads `/proc/mtd` and `/proc/mounts` through `fs.trimmed()` wrapped in
`L.resolveDefault(..., '')` — a `file` object gone reads as "no overlay" rather than as an error
(OpenWrt forum thread 251930, posts 97/109 — "fixed" by installing an unrelated package whose own
postinst reload happened to re-register `file`).
None of that risk buys this package anything, because:
- it registers **no rpcd object of its own** — no `/usr/libexec/rpcd/*` script, no
`/usr/lib/rpcd/*.so`, only `root/usr/share/rpcd/acl.d/luci-theme-footstrap.json` — so there is
nothing here for a reload to refresh, only every *other* plugin's dlopen risk to run for free;
- rpcd reads `acl.d/*.json` **at login**, not only at start or on `SIGHUP`. Measured: narrow
`rpcd.@login[0].read`/`write` from `*` to an explicit group, remove the ACL file with rpcd
already running -> `session access` for `uci footstrap write` answers `false`; put the file back
with **no reload**, log in again -> `true`. A fresh login already sees a just-installed grant;
- on a stock router, root's rpcd login grants `read='*' write='*'` — blanket. `session access`
answers `true` even for an **invented** object (measured against `uci totally_made_up_pkg_xyz`),
so the theme's ACL group never mattered to a root session in the first place.
The one gap the login-time read does not cover: a session that logged in **before** the install,
on a **restricted** (non-`*`) rpcd login — its in-memory ACL set predates the new grant, and only
a fresh login re-reads `acl.d`. `postinst` closes that gap without touching rpcd at all, ending a
session only when re-authenticating would actually hand it the theme's scope: it must be **denied**
`uci`/`footstrap`/`write` right now, **and** its own username's `rpcd.@login[]` entry must list the
theme's ACL group in its `read` or `write` list — otherwise the login was never given the group at
all (a second admin account, a monitoring script's login), a fresh login would deny it exactly the
same, and ending the session would cost that session for nothing:
```sh
for s in $(ubus call session list 2>/dev/null | grep -o '"ubus_rpc_session": "[a-f0-9]*"' | cut -d'"' -f4); do
case "$s" in *[!0]*) ;; *) continue ;; esac
ubus call session access "{\"ubus_rpc_session\":\"$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"write\"}" 2>/dev/null | grep -q true && continue
u=$(ubus call session get "{\"ubus_rpc_session\":\"$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4)
[ -n "$u" ] || continue
# Enumerated from `uci show`, not a `uci -q get` index count: the latter stops at the FIRST
# login section with no username option and never sees any section past it. `while read`, not
# `for`, because a username may contain a space and word-splitting would skip that section; the
# subshell's answer is echoed out and captured instead of set in a variable.
grant=$(uci show rpcd 2>/dev/null | grep -F .username= | while read -r kv; do
sect=${kv%%.username=*}
un=${kv#*.username=}
un=${un#\'}
un=${un%\'}
[ "$un" = "$u" ] || continue
for grp in $(uci -q get "$sect.read") $(uci -q get "$sect.write"); do
[ "$grp" = luci-theme-footstrap ] && { echo 1; break 2; }
done
done)
[ -n "$grant" ] || continue
ubus call session destroy "{\"ubus_rpc_session\":\"$s\"}" >/dev/null 2>&1 || true
done
```
Guarded on the `session` ubus object existing at all, and the `case` line skips the all-zero
session id — the unauthenticated default, never denied anything by an ACL, so this sweep is not
its business. Verified verbatim on the stand: a blanket-root session survived, a stale restricted
session (whose login lists the group) was destroyed, the unauthenticated all-zero session was left
untouched, and a fresh login answered `true`. The one visible price is the narrowest one available:
an admin on a restricted account whose login already lists the theme's group, installing the theme
from LuCI's own Software page, logs themselves out and must log back in once — every root session,
every session that already has the scope, and every denied session whose login was never given the
group in the first place, is left alone.
The same reasoning holds one step harder for `dev-sync.sh`: it copies theme files onto a live
router the same way a package install would, the theme still registers no rpcd object, and a
developer's ssh session is root with the stock blanket `read='*' write='*'` grant — so its
post-copy `rpcd reload` was dropped too, leaving only the LuCI cache removal.
## `/etc/config/footstrap` must be a conffile
The package ships `root/etc/config/footstrap` as an **empty stub that is written at runtime**:
"Save as default" has rpcd uci-set the router-wide axes into that very file
(`saveAsDefault()` in `fs-prefs.js`).
With no `conffiles` define, the package manager owns it as an ordinary file and **replaces it on
upgrade** — so the admin's saved defaults were wiped by the theme's own one-click Update, silently,
and reported as success. Measured on a live router: it held eight options, was package-owned, and
had no `.conffiles` entry beside base-files' and dnsmasq's.
Nothing observable fails when this regresses — the wipe happens on somebody else's router, months
later — so `npm run conffiles` gates it: every shipped `/etc/config/*` must be declared.
The uploaded background is the sibling case with the other answer. `/etc/footstrap/login-bg` is
written at runtime too, but it lives outside `/etc/config`, so a package upgrade never touches it
and a conffile entry would be rejected (the package does not ship that path). What *would* eat it is
a firmware **sysupgrade**, which keeps only what is listed — hence
`root/lib/upgrade/keep.d/luci-theme-footstrap`.
Three things now take that route, and `/etc/footstrap/` is where all of them live: the background,
the wallpaper pattern, and `/etc/footstrap/fonts/` — the webfonts `fonts/set-font.sh` installs,
along with the `@font-face` sheet it generates beside them. Each is exposed by a symlink under
`/www/luci-static/footstrap/` that uci-defaults recreates on **every** install and upgrade rather
than shipping, because `/www` is repopulated from firmware on a sysupgrade.
The fonts one links a **directory**, and that has two traps which end identically — `ln` exits 0 and
the link lands at `…/fonts/fonts`, one level too deep, so nothing serves and nothing complains:
- the path is already a symlink to a directory, and `ln -sf` follows it. `-n` is the fix;
- the path is a **real directory** — which every footstrap before 0.12.1 shipped here, full of the
woff2 files it carried — and `-n` does not help. Measured on a 25.12 router: exit 0, link created
inside. So the path is cleared first unless it already is the symlink we want, in uci-defaults and
in `set-font.sh` alike.
No ACL entry goes with the fonts, and none should: `set-font.sh` is root on the router with a shell,
not a browser going through rpcd. Nothing in the UI writes those three options.
## ACL
`root/usr/share/rpcd/acl.d/luci-theme-footstrap.json` grants what the Footstrap tab needs to persist
a router-wide default and a login background:
- `uci` `set`/`commit`, scoped to the `footstrap` config — "Save as default";
- `cgi-io upload` plus `file` `write`/`remove` on `/etc/footstrap/login-bg` and
`/etc/footstrap/pattern.svg` — the wallpaper photo and the tiled pattern;
- `file exec` on two literal commands, `/bin/chmod 644 /etc/footstrap/login-bg` and the same for
`/etc/footstrap/pattern.svg` — an upload that is not world-readable is one uhttpd answers with 403.
Those two `file.exec` grants are the only ones the theme ships, and each is one fixed
argument-complete command.
There is no grant for self-update, because there is no self-update: the theme upgrades through the
package feed the installer adds.
rpcd **skips an unreadable file in `acl.d` and says nothing**, so a stray comma means the grant
is issued to nobody and nothing else notices. `npm run acl` (`tools/check-acl.sh`, also a step in
CI's `check` job) parses every shipped `acl.d/*.json` and additionally rejects a document that
parses but grants nothing — a list instead of an object, or an entry with neither `read` nor
`write`, both of which rpcd accepts just as quietly.
A related trap the page had to solve: `rpc.js` only raises on the ubus status code when the
declaration asks it to (`reject: true`). Without it, a per-config ACL refusal — `uci` granted,
`footstrap` not — **resolves** with status 6 (permission denied) and every `.then()` runs as if the
file had been written.
## `luci-upstream.pin`
The single source for the pinned `openwrt/luci` commit and the sha256 of the two borrowed tools
that CI **downloads and RUNS** as gates:
- `luci-base/src/jsmin.c` — decides whether the shipped JS is safe;
- `build/i18n-scan.pl` — decides whether the catalogue is complete. It lexes `.ut` (rewriting the
template into JS before xgettext) and picks up the title from the rpcd ACL; a `grep` for
`_('…')` does neither.
Taken from a moving `master`, these gates would be "whatever upstream pushed last"; the sha256
says so out loud. The same file pins `USIGN_PIN` (so the signer in CI and the verifier in the field
are the same code) and `OPENWRT_KEYRING_PIN` — the commit of `openwrt/keyring` the release SDK's
signing keys are read from, pinned from a *different* host than the tarball they verify.
It deliberately pins **no ucode**. The template compile-check moved into the `verify` containers,
where the router's own interpreter runs it against the installed templates on both release lines
([ci.md](ci.md)) — so there is no interpreter to build and no commit to keep current, and the file
says so in place to stop the pin coming back.
## The same package, twice: this tree and the luci tree
The theme **is in** [openwrt/luci](https://github.com/openwrt/luci) as
`themes/luci-theme-footstrap` (merged 2026-08-20), and the copy that lives there is **not** this
directory copied across. `tools/sync-luci-fork.sh <path-to-luci>` materialises it, and the
difference is one decision made twice.
**How a change gets there now.** Their `CONTRIBUTING.md` is the authority and the shape is
ordinary: a feature branch off a fresh `upstream/master`, one PR per change, subject
`luci-theme-footstrap: <lowercase description>`, a body that says why, and a `Signed-off-by` with a
real first and last name (a GitHub noreply address is refused). `git push -f` is explicitly the way
to update a PR — inside your own branch, never on master. Release branches (`openwrt-25.12`, …)
take bug and security fixes only; a new package never lands on one, which is why the theme reaches
users of a *release* through owfeed and reaches everyone else with the next OpenWrt release.
**Two things the sync cannot carry, and both have bitten once.** The far side's `Makefile` is
hand-maintained (`include ../../luci.mk`, `PKG_MAINTAINER`), so `postinst`, `postrm` and
`conffiles` have to be changed there as well — a postrm cleaned up here and not there is exactly
the kind of divergence nothing else notices. And `po/` belongs to **Weblate** over there: upstream
forbids editing catalogues by hand, so the sync no longer sends them, and a msgid change is its own
deliberate PR against `po/templates/`. `npm run fork-drift` lists every shipped file the two trees
disagree about and names those two separately; it is a report rather than a gate, because an
unproposed change is a legitimate difference.
**That tree gets the built stylesheet; this one keeps the layers.** Here, `styles/` is thirty-nine
files in four cascade layers whose *order* is the design, and `cascade.css` is a build artefact
this repository does not even track. There, the other four themes each commit one `cascade.css`
and have no build step at all — a theme arriving with its own build system asks a reviewer to
audit that before they can read a stylesheet. So the sheet is generated on this side and
committed on that one, and `styles/` plus the four shell scripts do not travel.
**Nothing else is optimised on the way.** Measured against the stock tree, no package in
`openwrt/luci` ships anything pre-minified: the four themes' stylesheets run 17–20 bytes per line
and `luci-base`'s own JS 28–29, i.e. ordinary source with indentation. So the copy keeps its
`--fs-*` names unmangled, its templates and shell keep their comments, and the JS goes over
untouched for `luci.mk` to run **jsmin** across at package time — which is what every other
package in that tree gets. The release path here does more (terser, `mangle-tokens.sh`, comment
stripping) and the packages differ by about 14% because of it: 76 321 bytes against 66 825.
The one place the copy still stands out is the sheet itself, at 128 bytes per line against the
stock 17–20, and the arithmetic is why it stays: unminified it is **467 615 bytes**, eight times
the largest stock theme, because this repository keeps the *why* beside each rule and in source
form those comments are 70% of the file. The choice there is not "like everyone else" versus
"minified" — it is 136 kB of minified sheet against 468 kB of prose.
Two more things the copy changes, both in the Makefile, which is the one file maintained by hand
on the far side and never overwritten by the sync:
- `include ../../luci.mk`, not `$(TOPDIR)/feeds/luci/luci.mk` — in-tree, that is the path.
- `PKG_MAINTAINER` rather than `LUCI_MAINTAINER`: 96 of 101 apps in that tree use the former, and
the formality bot reads it.
Re-run the sync after any change under `styles/`, or the committed sheet is a stale artefact of a
source that has moved.