45 KiB
Development Guide
A getting-started guide for contributors new to luci-app-aurora-config. It
covers the big picture, the parts that are easy to get wrong, and recipes for
common tasks.
What this is. The configuration app for the Aurora theme (
luci-theme-aurora). It adds a LuCI admin page where users tune theme colors, layout, branding, and the toolbar, plus a Configuration Marketplace page to browse, apply, and share complete configurations with other users. This repo only owns the configuration UI and writes settings into UCI — the actual rendering lives in the theme package.
1. One build step, and it is minification only
Edit .dev/src/resource/. Never edit htdocs/ — it is generated, and the
next build overwrites it.
cd .dev && pnpm install # once
pnpm build # .dev/src/resource/ -> htdocs/luci-static/resources/
pnpm test # runs --check first, so a forgotten build fails here
There is no bundler and no transpiler. Each file is minified in place and stays
a separate resource, because LuCI's own loader is what resolves
"require utils.hub-api as hubApi" — it fetches
/luci-static/resources/utils/hub-api.js over HTTP. Merging files would break
that. The frontend uses LuCI's E() DOM API, not React/Vue; the backend is a
single rpcd shell script.
Why the artifacts are committed. uhttpd has no gzip — there is no
Content-Encoding anywhere in its source — so every byte is a flash read and a
socket write on the router. And the feed builds each package by cloning its
repo at a git ref and compiling from source
(openwrt-cloud/feed/scripts/resolve-refs.sh), so minifying only in our own
release workflow would compress the one channel nobody installs from. The
generated bytes have to be in git for anyone's build to get them.
.gitattributes marks htdocs/luci-static/resources/** as
linguist-generated, so diffs there fold away — read the source instead.
Everything in .dev/scripts/ writes to .dev/src/resource/; build-js.mjs is
the only thing that writes to htdocs/. That way "are the artifacts fresh?" has
exactly one answer, and --check is the one place that answers it.
To test a change on a device, run pnpm build, then sync htdocs/ and root/
to the matching paths and reload the page or restart rpcd.
2. Repository layout
.dev/ # Sources, tooling, tests. Not installed.
├── package.json # The only install root (pnpm). Pins luci-theme-tokens
├── scripts/build-js.mjs # terser -> htdocs. The ONLY writer of htdocs
├── tests/paths.mjs # repo() / srcPath() — tests never depend on cwd
└── src/resource/ # ★ EDIT HERE
├── view/aurora/
│ ├── studio.js # Theme Studio: color editor, layout, branding, toolbar (largest file)
│ └── marketplace.js # Configuration Marketplace: browse/apply, share, "my shares"
├── aurora/presets.json # Store card data: the 4 swatch colours + layout/typography per preset — GENERATED
└── utils/
├── color.global.js # Vendored colorjs.io (global `Color`)
├── color.global.js.license # Its three MIT notices; build-js re-attaches them as a banner
├── tokens.global.js # ★ Color derivation engine (global `AuroraTokens`) — GENERATED, do not edit
├── theme-preview.js # Shared light/dark split preview card
├── feed-check.js # Version comparison + package-page lookup (pure, unit-tested)
├── hub-api.js # rpc.declare wrappers for the hub_* RPC methods + list cache
└── asset-upload.js # Shared dropzone/upload widget (fonts, brand assets)
htdocs/luci-static/resources/ # GENERATED by `pnpm build` — committed, never hand-edited
root/
├── etc/uci-defaults/80_aurora # First-install setup + schema migration
├── etc/sysupgrade.conf.d/aurora-device # Preserves /etc/aurora/device.{key,hash} across sysupgrade
├── usr/libexec/rpcd/luci.aurora # Backend RPC (shell)
└── usr/share/aurora/
├── *.template # Five built-in presets (UCI fragments). Colour block GENERATED; the layout/typography block below it is hand-authored
├── color-tokens.conf # Ordered token key list for the backend — GENERATED
└── font-presets.conf # Font preset manifest (Fontsource packages + pinned versions) — GENERATED
.dev/scripts/sync-tokens.mjs # Vendors tokens.global.js + color-tokens.conf + aurora-presets.json (resolved preset hex) from @eamonxg/luci-theme-tokens, stamps TOKENS_ENGINE_VERSION into studio.js, then runs gen-presets
.dev/scripts/gen-presets.mjs # Injects the colour block into *.template, reads each template's layout/typography back out, and writes src/resource/aurora/presets.json (swatch colours only — §8.1)
.dev/scripts/aurora-presets.json # Resolved preset hex values, vendored by sync-tokens.mjs — GENERATED
.dev/scripts/gen-font-presets.mjs # Regenerates font-presets.conf (curated manifest lives in this file)
.dev/scripts/gen-pot.mjs # Scans src/resource for _() calls; its "#:" refs deliberately still name htdocs, which is where an installed reader finds the string
Makefile # OpenWrt package metadata (version lives here)
3. The color token system (core concepts)
This is the part most likely to trip you up. Read it before touching colors.
3.1 Inputs vs. derived tokens
The entire palette is driven by 10 editable inputs; every other color is derived from them.
| Kind | Count | Examples |
|---|---|---|
| Inputs (user-editable) | 10 | bg, surface, text, brand, on_brand, link, info, warning, success, danger |
| Derived (computed) | 20 | text_muted, surface_sunken, brand_hover, brand_subtle, focus_ring, *_surface, scrim, mega_menu_bg, … |
The derivation rules (mix / shade / set / alpha / const) live in the
theme's token spec. utils/tokens.global.js is the prebuilt browser bundle
from the @eamonxg/luci-theme-tokens npm package, vendored by
.dev/scripts/sync-tokens.mjs — never edit it by hand (see §3.4).
3.2 Why the config app computes derived tokens itself
The theme authors derived tokens in _tokens.css at build time as flat
color literals — they no longer reference var() / color-mix(). The compiled
main.css is emitted as hex fallbacks plus lab(...) values for compatible
browsers. As a result, overriding --brand alone does not cascade into
--brand-hover, --focus-ring, and friends.
So the config app's strategy is: the user edits the 10 inputs → the frontend
expands them into all 30 values via AuroraTokens.resolve() → all 30 are
written to UCI as hex/hex8 runtime colors → the theme's template injects them,
overriding the baked defaults wholesale. Advanced users may also override
individual derived values; blank derived fields continue to follow the generated
values.
3.3 End-to-end data flow
User edits input colors in the UI
│
▼
studio.js: persistDerivedTokens()
runs AuroraTokens.resolve(mode, <10 inputs>) for light + dark
writes 30 light_<key> / dark_<key> hex entries into UCI (aurora.theme)
│ on save
▼
/etc/config/aurora ← UCI storage
│
▼
Theme template header.ut, at page render:
emits each light_* as :root { --<key> }
emits each dark_* as [data-darkmode=true] { --<key> }
│
▼
Overrides the baked defaults in _tokens.css → user's palette renders
⚠️
header.utinjects everylight_*/dark_*key indiscriminately. Stale keys left in UCI become dangling overrides pointing at CSS variables that no longer exist. Pruning them is the job of schema migration (see §5).
3.4 Single source of truth
The token model lives in one npm package:
@eamonxg/luci-theme-tokens(repo) — an independent repo with its own semver, published from its own root. It owns the engine (the five operators + spec-driven resolver, also exported standalone as@eamonxg/luci-theme-tokens/engine), the aurora spec (aurora/spec.js/aurora/defaults.js/aurora/presets.js), and all derivation — itsbuild.mjsresolves every derived token and every built-in preset at package build time and ships the results as prebuiltdist/aurora/artifacts.luci-theme-auroraand this repo are both just consumers of the prebuilt output; neither derives anything locally anymore.
This repo vendors that package's dist/aurora/ output by the exact
version pinned in .dev/package.json — .dev/scripts/sync-tokens.mjs copies three
files verbatim, no local derivation:
utils/tokens.global.js←dist/aurora/tokens.global.js— the browser global, still used bystudio.jsfor live preview / on-save recompute.root/usr/share/aurora/color-tokens.conf←dist/aurora/color-tokens.conf— the ordered key list the backend (luci.aurora) reads at runtime..dev/scripts/aurora-presets.json←dist/aurora/presets.json— every built-in preset, fully resolved to runtime hex, for all 5 presets × light/dark.view/aurora/studio.js— only theTOKENS_ENGINE_VERSIONconstant, stamped from the vendored package'spackage.jsonversion.studio.jsappends it as?v=when loadingtokens.global.js, so a version bump busts the browser's HTTP cache.
It then reruns gen-presets.mjs, which is a pure formatter: it reads
.dev/scripts/aurora-presets.json and injects option light_<key> /
option dark_<key> lines into root/usr/share/aurora/*.template, preserving
each template's non-color tail (struct_*, toolbar, etc.). It performs no
color math and does not load tokens.global.js — all values are already
resolved upstream.
sync-tokens.mjs resolves the package from three sources, in order:
--local <path>— a localluci-theme-tokenscheckout, for iterating on an unreleased spec (runnpm install+node build.mjsthere first).- npm registry — the version pinned in
.dev/package.jsondevDependencies; this is the normal path. - sibling checkout
../luci-theme-tokens— fallback when the registry is unreachable; logs a warning since the output may not match the pin exactly.
All vendored artifacts are committed, so nothing downstream (CI, SDK build, device) needs npm or the luci-theme-tokens repo. The pinned version doubles as the compatibility statement: it names the luci-theme-tokens release this app targets.
Drift is caught in three layers:
studio.jsjoins its UI metadata with the registry at load time and throws on missing/stale entries (buildColorTokenTables)..dev/tests/theme-token-sync.test.mjsre-runssync-tokens.mjs --checkagainst the registry (the check also covers theTOKENS_ENGINE_VERSIONstamp instudio.js, and a dedicated test asserts it matches the engine header)..github/workflows/token-sync-check.ymlruns the same check in CI (push/PR and weekly); Renovate/Dependabot can bump the pin when luci-theme-tokens releases.
Consumers of tokens.global.js at runtime:
- Frontend —
studio.jsloads it for live preview, on-save computation, and the ordered token tables (COLOR_TOKENSet al.).
(.dev/scripts/gen-presets.mjs no longer loads it — see above; the presets it
emits come straight from .dev/scripts/aurora-presets.json.)
4. Recipes
A. Retune a preset / add a new one
A preset is a whole look, not a palette: its template carries the 62
colour options and a structural block — nav_type, struct_spacing,
struct_radius_base, struct_content_width_centered, struct_font_sans,
struct_font_mono, toolbar_enabled (the shell's PRESET_STRUCT_KEYS).
apply_theme_preset writes all of it. The two halves have different owners:
- Colours come from upstream. Preset ownership moved into
@eamonxg/luci-theme-tokens2.0.0: thePRESETSmap lives in that package'saurora/presets.js, itsbuild.mjsresolves every preset to hex and shipsdist/aurora/presets.json, andgen-presets.mjsonly formats whatever.dev/scripts/aurora-presets.jsonsays. - Structure is authored here, in the template itself.
sync-tokens.mjsoverwrites.dev/scripts/aurora-presets.jsonon every sync, so nothing this repo decides may live there.
To retune colours:
-
luci-theme-tokensrepo: edit thePRESETSmap inaurora/presets.js(each preset specifies only the 10 inputs × light/dark),npm test. -
Tag
vX.Y.Z, push — CI auto-publishes. -
config(here): bump the pin in.dev/package.json→node .dev/scripts/sync-tokens.mjs— refreshes.dev/scripts/aurora-presets.jsonand rerunsgen-presets.mjs, which rewritesroot/usr/share/aurora/*.templatein place, preserving each template's non-color tail (struct_*, toolbar, etc.) → commit. To iterate on an unreleased preset:node .dev/scripts/sync-tokens.mjs --local ../luci-theme-tokens.sync-tokenswrites into.dev/src/resource/, so finish withpnpm buildbefore committing.
To retune the look, edit the structural block in
root/usr/share/aurora/<name>.template directly, then pnpm gen-presets
(it re-reads the block into the browser copy). Then pnpm build. Two rules the tests enforce:
- Numeric values must sit on the settings page's slider steps (spacing 0.05,
radius 0.125, width 1) and inside the bounds
validate_and_apply_hub_payloadenforces on hub configurations. - Font stacks must be copied verbatim from field 7 of the matching
font|<slot>|<id>|…row infont-presets.conf. The device reverse-maps the stack back to a roster id to know which woff2 files to fetch; a retyped stack matches nothing and the preset silently renders its fallback family.
A brand-new preset also needs:
- a new
root/usr/share/aurora/<name>.templatefile in this repo (copy an existing one —gen-presets.mjsonly rewrites templates that already exist, it never creates one) marketplace.js→BUILTIN_PRESETS(store card) andBUILTIN_SEED_RE(so the hub's own seed of it is not listed twice)luci.aurora→resolve_preset_path()(name → template path)80_aurora→ template fallback chain (optional).dev/tests/builtin-presets.test.mjs→PRESET_IDS
B. Add or change a color token
The token spec's single source of truth is the standalone
luci-theme-tokensrepo'saurora/spec.js/aurora/defaults.js, released as@eamonxg/luci-theme-tokens; everything here is vendored from that package.
luci-theme-tokensrepo: editaurora/spec.js/aurora/defaults.js,npm test.- Tag
vX.Y.Z, push — CI auto-publishes. theme: bump.dev/package.json→npm install→npm run build.config(here): bump the pin in.dev/package.json→pnpm sync-tokens— refreshestokens.global.js,color-tokens.conf, and.dev/scripts/aurora-presets.json(and, viagen-presets.mjs, the preset templates) in one go (if there's a new token, follow the test prompts to fill instudio.jscopy). To iterate on an unreleased spec:node .dev/scripts/sync-tokens.mjs --local ../luci-theme-tokens.- For a new token, add its UI copy in
studio.js→COLOR_TOKEN_METADATA/DERIVED_COLOR_TOKEN_METADATA(and a group entry if needed). The tests andbuildColorTokenTables()fail loudly until every registry token has metadata. - Bump
SCHEMA_VERSIONin80_aurora(see §5). pnpm build→pnpm test→ commit. Steps 4 and 5 both wrote into.dev/src/resource/, so the artifacts are stale until this runs;pnpm testrefuses to pass otherwise.
C. Change frontend code
This is the everyday loop. The one thing to remember is that htdocs/ is
generated, so an edit is not finished until it has been built.
cd .dev
$EDITOR src/resource/view/aurora/studio.js # edit the SOURCE
pnpm build # regenerate htdocs/
pnpm test # --check runs first
cd .. && git add -A && git commit # source and artifact travel together
Forgetting pnpm build is caught locally: pnpm test runs
build-js.mjs --check before the suite and fails naming the stale file. The
same check catches the reverse mistake — someone hand-editing htdocs/.
Two things that look like staleness but are not:
- Adding or changing a comment leaves the artifact identical. The
compressor strips comments, so
--checkcorrectly reports "up to date". - A terser upgrade moving the output by a few bytes is expected; commit the regeneration like any other.
To try it on a real device, copy htdocs/ and root/ to the matching paths
and reload the page (/etc/init.d/rpcd restart for backend changes).
D. Verify without a device
cd .dev
# Everything: artifact freshness, then the full suite
pnpm test
# Just the artifacts vs. the sources
pnpm check
# Generated artifacts in sync with the pinned luci-theme-tokens package?
node scripts/sync-tokens.mjs --check
# Shell syntax (not covered by pnpm test)
sh -n ../root/usr/libexec/rpcd/luci.aurora
sh -n ../root/etc/uci-defaults/80_aurora
pnpm test asserts against the shipped bytes, not only the sources.
.dev/tests/artifact-safety.test.mjs checks that every artifact parses, keeps
the require directives and top-level return LuCI needs, still declares the
*.global.js globals, carries the vendored MIT notices, points at no
uninstalled sourcemap, ships no hidden file, and preserves every _() msgid
byte-for-byte. §11 explains why each of those can break.
5. Schema migration & upgrades
When the meaning of a color token changes incompatibly, snapshots in an
existing /etc/config/aurora go stale — they can inject invisible or wrong
colors, or leave behind obsolete keys. This is handled in
root/etc/uci-defaults/80_aurora:
SCHEMA_VERSIONat the top is the current version; the header comments record what each version (v2 / v3 / v4 / …) changed.- On upgrade,
migrate_color_schema()compares the storedconfig_version; on a mismatch it callsreseed_colors_from_template(), which:- re-sets every
light_*/dark_*key from the current template, and - deletes any key absent from the template — this is what clears stale state.
- re-sets every
- On a fresh install it simply copies the template to
/etc/config/aurora.
Any change that alters the key set or token semantics must bump
SCHEMA_VERSION and add a comment — otherwise existing users never run the
migration on upgrade.
6. Backend RPC reference (luci.aurora)
A shell rpcd script exposing the object luci.aurora. The frontend calls it via
rpc.declare({ object: "luci.aurora", method: ... }). The full method list is
the case "$1" in "list") block at the end of the script. Common methods:
| Method | Purpose |
|---|---|
get_init_data |
Read first-paint data in one RPC: installed versions, font presets, icons, and the active preset snapshot |
get_theme_preset |
Read a preset snapshot for UI placeholders and comparison |
apply_theme_preset |
Apply a built-in preset: colours plus navigation, spacing, radius, content width, both font stacks and the toolbar switch. Shortcut sections and brand images are left untouched |
export_config / import_config |
Configuration import/export |
list_icons / upload_icon / remove_icon |
Icon management |
prepare_font / get_font_presets / get_font_status |
Font handling |
upload_font / remove_font |
Custom (user-uploaded) font management |
hub_apply / get_hub_status |
Marketplace: kick off an async apply job / poll its progress |
hub_restore_backup |
Marketplace: one-tap rollback to the pre-apply snapshot |
hub_share / hub_update / hub_delete |
Marketplace: publish the current config / edit / unpublish a share |
hub_me |
Marketplace: this device's creator profile and everything it has published |
hub_set_nickname / hub_export_key / hub_import_key |
Marketplace: name the creator identity / back it up / restore it on another router |
Browsing the store is not in this list: the browser fetches the hub's public
list and detail endpoints directly (utils/hub-api.js), so no shell forks and
no rpcd round trip stand between the store page and its cards. Only calls that
need the device's write credential or the device's own filesystem go through
rpcd.
ACLs live in root/usr/share/rpcd/acl.d/luci-app-aurora.json; the menu entries
(Theme Studio, Configuration Marketplace) in
root/usr/share/luci/menu.d/luci-app-aurora.json. §8 below covers the Theme
Store in detail.
load_preset_snapshot()validates that a template'soption (light|dark)_line count equalsCOLOR_TOKEN_KEYS count × 2. The key list ships as/usr/share/aurora/color-tokens.conf, generated bysync-tokens.mjsfrom the same registry as the templates, so the two cannot drift apart. It then callsload_preset_struct_snapshot(), which requires everyPRESET_STRUCT_KEYSoption and checks each against the same boundsvalidate_and_apply_hub_payloadapplies to a hub configuration — a preset and a shared config are the same kind of thing, so one must not be able to write a value the other rejects. A template missing any of them fails the whole apply rather than landing half a look on top of the previous one.
7. Font system (v2)
Like the color templates, root/usr/share/aurora/font-presets.conf is a
generated file — never hand-edit it. Regenerate it with:
pnpm gen-font-presets
.dev/scripts/gen-font-presets.mjs holds the curated manifest (Fontsource npm
package id, pinned version, and weights per preset). Running it re-downloads
every woff2 from jsDelivr, recomputes its sha256, and rewrites the conf —
commit the result.
7.1 font-presets.conf format
v2|generated-by-gen-font-presets|do-not-edit
font|<slot>|<name>|<label>|<source>|<family>|<stack>
file|<slot>|<name>|<weight>|<sha256>|<url_jsdelivr>|<url_npmmirror>
- One
fontline per preset (slotissansormono); built-in presets (Lato,System UI/System Mono) have no matchingfilelines. - One
fileline per weight for Fontsource-backed presets, carrying the sha256 and both a jsDelivr and an npmmirror URL for the same file. .dev/tests/font-presets.test.mjsguards this format (field counts, slot/weight enums, sha256 shape, URL patterns) and runs as part ofnpm test.
7.2 Download pipeline (luci.aurora)
prepare_font downloads each required file primary (jsDelivr) → fallback
(npmmirror), verifying the sha256 from font-presets.conf after each
attempt; a hash mismatch counts as a failure and triggers the fallback too.
The @font-face rule is always generated locally on the router — no remote
CSS is ever fetched or served to the browser. A slot's job status starts at
ready (queued/in progress) and settles on one of two final values:
cached (a verified file is in place — pure-stack presets with nothing to
download report this too) or fallback (download/verification failed and
the built-in face is used instead).
fonts/preload.txt is a single-line marker file consumed by the theme's
header.ut to emit <link rel="preload"> for the active webfont:
- Empty — deliberately no preload (e.g. the built-in/system face is active, nothing to preload).
- Absent — an older layout that predates this marker; the theme falls back to preloading Lato.
7.3 Custom fonts
Users can upload their own woff2 via the upload_font RPC method (and remove
it via remove_font):
- Upload goes through cgi-io to
/tmp/aurora_font.tmp(fonts) or/tmp/aurora_icon.tmp(brand assets), then areceive_uploadgate in the rpcd script validates before anything touches flash:- shared size cap: 8MB (
MAX_UPLOAD); - fonts: woff2 magic bytes (
wOF2); - images: extension allowlist
jpg jpeg png webp svg ico, path components rejected. The set is exactly what the themes hub will accept, so nothing you can put on the router is something you cannot share -- avif and gif are absent because no asset slot can use them; - the gate deletes the tmp file on every rejection (front-end callers never clean up).
- shared size cap: 8MB (
- Front-end plumbing lives in
utils/asset-upload.js(dropzone, progress row, delete confirm, cgi-upload XHR);view/aurora/studio.jscomposes it for both the Custom Fonts and Brand Asset Library sections. - Stored under
/www/luci-static/aurora/fonts/custom/<slot>-<slug>.{woff2,meta,face}(.faceis the pre-rendered@font-faceblock,.metacarries the display family + font stack). - Custom faces are always included in the combined
aurora-font.css, and selection flows through thestruct_font_*UCI stacks exactly like preset fonts.
8. Configuration Marketplace
The Marketplace lets users browse configurations shared by other users, apply
one with a single click, and share their own — all through a public hub at
https://themes.eamonxg.fun. Nothing about it changes how colors are
derived or how UCI is structured; it is a distribution layer on top of the
existing config format.
8.1 Pieces
view/aurora/marketplace.js— theadmin/system/aurora/marketplacepage: a card grid (Hot/New sort, swatch preview, downloads count) that opens a detail modal per config (palette, layout, typography, included assets) with an Apply button; a "Share My Configuration" modal; and a "My Shares" table (update-with-current-config / delete) shown only when the device has published at least one share. All hub-sourced text (name, author, hex values, toolbar URLs) is rendered viaE()'s safe children ordocument.createTextNode— neverinnerHTML— since it is untrusted server content.utils/hub-api.js—rpc.declarewrappers for everyhub_*method against theluci.auroraobject, plus a 5-minutelocalStoragecache (aurora.hub.list) for the "hot" list so the page can paint instantly from a stale copy while it revalidates.root/usr/libexec/rpcd/luci.aurora— the backend: talks to the hub over HTTP, validates everything it gets back, and applies it to UCI.aurora/presets.json— the built-in preset cards, so they render with no round trip and work offline. See below for why it is not the templates.
Why the built-in presets exist twice. root/usr/share/aurora/*.template
and aurora/presets.json hold the same five palettes in two shapes, on
opposite sides of the ubus boundary, and neither can replace the other:
- The templates never leave the router.
80_aurorareadsdefault.templateat install time, when no browser exists; thenapply_theme_presetandreset_defaultsread them, andget_theme_presetfeeds the settings page its placeholder values. Applying a built-in preset costs one ubus call carrying a name — the router does the rest. Deleting the templates would mean the browser posting all 62 colours plus layout and typography and the shell validating the lot, which is more traffic, more work, and more attack surface than a name string. presets.jsonis downloaded by every visitor to the store, so it carries only what the store reads: the fourSWATCH_KEYS(bg,surface,text,brand) × light/dark, plus layout, typography and the empty toolbar. It used to carry all 62 colours; 7,968 of its 11,017 bytes were never looked at. Replacing it with rpcd calls would put five shell forks on the store's first paint — the thing that page was explicitly optimised away from — and break offline rendering.
gen-presets.mjs writes both from one source, so they cannot drift, and
builtin-presets.test.mjs parses SWATCH_KEYS out of marketplace.js to make
sure the generator's copy of that list stays in step. Add a key there and the
test fails until the generator follows, rather than the card quietly rendering
a blank swatch.
8.2 rpcd methods (object luci.aurora)
| Method | Purpose |
|---|---|
get_hub_status(job_id) |
Poll a job's state/step/error; the frontend polls this every 1.5s (marketplace.js: pollApplyStatus) |
hub_apply(id) |
Forks an async apply job (hub_apply_worker) in the background, returns a job_id immediately |
hub_delete(id) |
Unpublish share id |
hub_export_key |
Read device.key back out so a creator identity can be moved to another router |
hub_import_key(key) |
Adopt an exported identity, taking over the shares it owns |
hub_local_state |
What the router is actually wearing: active_source (which card the look came from), modified (whether it still equals that card), and backup (the look it had before, projected into the same preview shape a presets.json row carries). Local only — no hub round trip |
hub_me |
One request for both halves of the answer: who this router publishes as, and what it has published |
hub_restore_backup |
Put back the single most recent pre-apply snapshot. Reached from the "My configuration" card, not from a button of its own |
hub_set_nickname(nickname) |
Set the creator name the hub shows on this device's shares |
hub_share_begin(name, description, target_id) |
Open a publish draft on the hub and return one upload ticket per asset, plus the local /luci-static/ URL backing each. target_id empty publishes a new entry, non-empty replaces that share |
hub_share_commit(draft_id) |
Claim a draft whose assets the browser has finished uploading, turning it into a config (or an update) |
hub_list/hub_get/hub_my_shares are gone. The first two moved into the
browser (see §8.1); hub_my_shares walked a local id list and re-validated
each id against the hub on every page load, and hub_me replaced it with a
single request whose answer is the hub's own — there is no local record left to
lose or wrongly prune. tests/rpcd-hub.test.mjs pins this table to the ACL, so
adding a method without documenting it fails the suite.
The three helpers that talk to the hub — hub_http_get, hub_http_put,
hub_http_delete — return 0 on success, 2 when the hub answers 404,
and 1 when it cannot be reached at all. The distinction is not free:
uclient-fetch exits 8 for every error status alike and -q suppresses the one
line naming it, so the helpers keep stderr and read the status off it. Without
that, unpublishing a share that was already gone, or applying a theme the store
had taken down, both told the user their connection was broken.
8.3 Device identity
Every hub write (hub_share/hub_update/hub_delete) and the download-count
ping after an apply are authenticated by a per-device identity, created
lazily by ensure_device_identity() on first use:
/etc/aurora/device.key(mode0600) — 32 random bytes, hex-encoded, asdevice_token. This is a secret: it is the only thing that lets the hub accept an update/delete against a device's own shares. It is never rendered in any UI and never leaves the router except as thedevice_tokenfield of ahub_share/hub_update/hub_deletePOST/PUT/DELETE body./etc/aurora/device.hash(mode0644) — a second, independent random hex value,device_hash, sent only with the download-count ping. It carries no authority (there is nothing it can authenticate) and exists purely for anonymous download deduplication on the hub side.- Both files are listed in
/etc/sysupgrade.conf.d/aurora-device, so asysupgradethat preserves configuration keeps the same device identity (and thus the same ownership over previously published shares) across firmware upgrades.
8.4 Apply flow (hub_apply → hub_apply_worker)
hub_apply validates the id, forks hub_apply_worker in the background, and
returns a job_id right away; the worker writes each step to a status file
under /tmp/aurora_hub_jobs/<job_id>.status for get_hub_status to read:
- backup — copies the current
/etc/config/aurorato/etc/aurora/pre-hub-backup.conf(single slot: only the most recent apply can be rolled back). - fetch —
GETs the full config payload from the hub. - validate —
validate_and_apply_hub_payload()treats the hub as untrusted input and re-checks every field with the same rules the frontend/local share path enforces (exact 62light_*/dark_*hex keys, enum-checkednav_type/font ids, bounded/regex-checked rem values and font stacks, per-item toolbar validation,has_control_charguards against embedded-newline UCI injection) using onlycase/grep -E— noeval. Nothing is written to UCI until every field passes; a singleuci batch(colors deleted then reset, layout/typography set,toolbar_itemsections replaced,hub_applied+icon_cache_versionbookkeeping) is then committed atomically, and reverted if the commit fails. - assets — any image assets in the payload are fetched from the hub and
sha256-verified (
fetch_verified) before being written under/www/luci-static/aurora/images/; a login-background asset also updatesstruct_login_bg. Best-effort: a failed asset fetch doesn't fail the job. - finalize — resyncs font CSS, bumps the icon cache timestamp, marks the
job
done. - download count — fires a fire-and-forget
POST .../downloadwithdevice_hashafter the job completes, for the hub's download counter.
If validation or fetch fails, the job is marked error with a step and error
code; the frontend maps every code to a result-only message (never surfaces
words like bad_payload or job) and never leaves partial state — either the
whole batch commits or none of it does.
Rollback: hub_restore_backup copies pre-hub-backup.conf back over
/etc/config/aurora, resyncs font CSS and the icon cache timestamp, and
returns no_backup if nothing has been applied yet in this backup slot. The
gallery page shows a persistent banner ("Applied <name>" + "Restore
previous configuration") whenever aurora.theme.hub_applied is set, so
rollback is always one tap away, not just immediately after applying.
8.5 Share v1 scope
build_share_payload() assembles the current /etc/config/aurora into the
hub's schema (colors, layout, typography, toolbar, assets) and is shared by
both hub_share and hub_update. Deliberate v1 limits:
- Images only, no custom fonts. Only the six image asset kinds
(
logo_svg,favicon_png,favicon_ico,pwa_icon_192,pwa_icon_512,login_bg) are ever uploaded;typography.font_sans/font_monoare shared as preset ids only (resolve_font_preset_id), never as uploaded.woff2bytes — a custom font a user uploaded locally is never re-uploaded to the public hub. - Skips factory-default images. An image asset is only included if its
UCI value differs from the package's stock filename (
logo.svg,favicon.ico,app-icon-192x192.png,app-icon-512x512.png,apple-touch-icon.png) — an unmodified slot means the user never customized it, so there's nothing worth uploading and nothing copyright-risky to check. Each included asset is also read back from disk, sha256'd, and size-checked before upload, and any filename containing/or..is rejected outright (defense against path traversal into$ICON_PATH). - Toolbar entries are re-validated with the hub's exact per-field limits (title 1-30 chars, url ≤200 chars + scheme, icon charset+≤64 chars, ≤12 entries) at share time — an out-of-range item is silently dropped from the share rather than rejecting the whole publish.
9. Releasing
OpenWrt package metadata lives in the Makefile:
PKG_VERSION # semantic version; bump at least a patch for breaking schema changes
PKG_RELEASE # YYYYMMDD-style date
CI builds .ipk / .apk artifacts through the build-luci-package Action using
the OpenWrt SDK.
The SDK build needs no Node and runs no build step of ours — it installs
htdocs/ and root/ verbatim. That is the whole reason the minified artifacts
are committed (§1): the feed rebuilds every package from a git ref, so whatever
is in the tree at that tag is what users install. Before tagging, confirm the
tree is self-consistent:
cd .dev && pnpm test # includes build-js --check
cd .. && git status --porcelain # must be empty
A tag pushed with stale artifacts ships the old frontend against the new backend, and nothing downstream will notice.
10. Troubleshooting cheat sheet
- Changed
brandbut hover/status colors don't follow? Derived tokens weren't written to UCI — check thattokens.global.jsloaded and thatpersistDerivedTokensran on save. - Colors broken or invisible text after an upgrade? Stale keys weren't
pruned — confirm
SCHEMA_VERSIONwas bumped and the migration ran. - Applying a preset fails? The backend
COLOR_TOKEN_KEYScount no longer matches the template line count. - Injection has no effect (variable name mismatch)? Config keys (underscores)
become hyphenated CSS variables on injection; they must match the
--xxxnames in the theme's_tokens.cssexactly. - A page dies with
Invalid regular expression: missing /, but the source parses? The packaged copy was mangled by jsmin — see §11. Confirm by diffing the device's file size againsthtdocs/'s. - The device shows an older UI than the source says it should? Someone
committed a source change without running
pnpm build.cd .dev && pnpm checknames the stale files. - A page is blank with nothing in the console? Check that
htdocs/luci-static/resources/utils/color.global.jsstill containsvar Color=. If it is an empty or near-empty file,mangle.toplevelorcompress.toplevelgot switched on and deleted the global (§11). - A newly added string shows in English on a translated UI? The msgid in
the artifact no longer matches the source
gen-potscanned.pnpm testasserts this, so it should not survive a commit — but check there first.
11. Minification: why we do it ourselves
luci.mk minifies every .js with Crockford's jsmin whenever
LUCI_MINIFY_JS is 1 — and luci.mk defaults it to 1. jsmin is from the
1990s and predates every ES6 construct this package uses, so it does not
merely fail to shrink things: it rewrites them into something else.
This package therefore sets LUCI_MINIFY_JS:=0 in its Makefile, before the
luci.mk include (the variable is declared with ?=, so it must be set
first), and runs terser itself in .dev/scripts/build-js.mjs instead. The rest
of this section is why jsmin cannot be trusted, followed by what terser needs
to be told about LuCI.
What terser needs to know about LuCI
Four settings in build-js.mjs are load-bearing. Three are present, one is
present by being absent:
| Setting | Why |
|---|---|
parse.bare_returns: true |
A LuCI view's last statement is a top-level return view.extend(…). Without this, terser refuses to parse the file at all. |
compress.directives: false |
"require uci" and friends are string directives. A directive-dropping compressor deletes them and LuCI then loads the view with none of its dependencies. |
format.comments: false |
Maximum compression — but it also strips the three MIT notices inside the vendored colorjs build, which we redistribute. They are re-attached from utils/color.global.js.license; any future vendored library needs the same sidecar. |
mangle.toplevel / compress.toplevel left at their defaults (false) |
color.global.js and tokens.global.js exist to publish a top-level var Color / var AuroraTokens. Turning either on deletes the declaration outright — measured, the output becomes an empty string — and terser still reports success. The symptom is a white page with nothing in the console. Never enable them. |
Everything else LuCI-specific needs no special handling: assetUpload,
feedCheck, hubApi, themePreview and the injected globals (L, E, _,
uci, rpc, ui, form, view, fs) are all free variables, which terser
treats as globals and never renames.
.dev/tests/artifact-safety.test.mjs asserts all of this against the shipped
bytes: they parse, they keep every require directive their source declared,
they keep their top-level return, the two globals are still declared, the
licence banner is intact, no sourceMappingURL points at a map the package
does not install, every _() msgid survives byte-for-byte, and no hidden file
(.DS_Store) got installed.
Cache busting: anything we fetch ourselves needs a ?v=
uhttpd dates this package's installed files Last-Modified: Thu, 01 Jan 1970
and sends no Cache-Control. Browsers then compute heuristic freshness as
roughly 10% of the file's apparent age — from the epoch, that is years — and
serve their copy without even sending If-None-Match. (luci-base and the
theme carry real dates; only ours are at the epoch.)
That is not theoretical. The Marketplace spent months drawing its built-in
cards from a presets.json whose shape had changed underneath it: against the
old {light,dark} shape, paletteOf finds no colours and navOf no layout,
so every card rendered with the fallback palette and a top nav bar. Both fail
silently, so the page looked deliberate.
Two kinds of URL, and only one is ours:
- LuCI
requires it (view/aurora/*.js, everything underutils/) — LuCI appends?v=${env.resource_version}, parsed from theluci.jsscript tag, i.e. luci-base's version. Bumping this package'sPKG_VERSIONdoes not change it. Nothing here can fix that; a browser picks these up on a hard reload or when luci-base is upgraded. - We build the URL — then it must carry a version. Three do:
Resource Version Stamped by utils/tokens.global.jsengine version sync-tokens.mjs→TOKENS_ENGINE_VERSIONaurora/presets.jsoncontent hash gen-presets.mjs→PRESETS_VERSIONutils/color.global.jscontent hash build-js.mjs→__ASSET_HASH(...)__
__ASSET_HASH(<path>)__ in any source is replaced at build time by the first
8 hex of that file's sha256. Use it for anything vendored that carries no
version of its own — the URL then changes if and only if the bytes do, so the
cache stays warm right up until it must not. An unknown path fails the build.
If you add another fetch() or script injection for a packaged file, give it a
version the same way. artifact-safety.test.mjs checks the three above and
that no sentinel survives into an artifact, but it cannot know about a fourth.
What it gets wrong
1. A regex literal after an arrow becomes a division. jsmin decides a /
opens a regex only when the previous non-blank character is one of:
( , = : [ ! & | ? { } ; \n
An arrow's > is not in that set. So in (url) => /^https?:\/\//.test(url)
the / reads as division, and the two adjacent slashes inside the regex then
look like a line comment — everything from there to the end of the line is
deleted. The regex loses its terminator and the browser reports
Invalid regular expression: missing /. The whole view fails to load.
This actually shipped: the theme store was dead on-device for exactly this
reason, in marketplace.js's isExternalShortcut.
2. Template literals are not understood at all. Backticks mean nothing to jsmin, so:
- the
//in`https://${FEED_HOST}/manifest.json`is eaten as a line comment, same as above; - whitespace inside the literal is stripped as if it were code. Multi-line
inline CSS is the dangerous case: a descendant selector
.a .bis squeezed to.a.b, which silently means "an element with both classes". No syntax error, no console message — the styling just quietly stops matching.
Reproducing it
npm install jsmin --no-save
for f in $(find .dev/src/resource -name '*.js'); do
./node_modules/.bin/jsmin "$f" > /tmp/m.js
node --check /tmp/m.js || echo "MANGLED: $f"
done
Note this only catches breakage #1 (a syntax error). Breakage #2 produces valid JS with different behaviour, so it will pass this check — that asymmetry is the main reason minification is off rather than worked around.
The rule that used to be enforced, and is not any more
.dev/tests/jsmin-safety.test.mjs used to forbid a regex literal directly after an
arrow, because that is the shape jsmin destroys. It was retired when terser
took over: the rule existed only because jsmin is not a parser, and terser is.
artifact-safety.test.mjs replaced it with assertions on the artifacts
themselves, which is strictly stronger — it checks what actually shipped rather
than banning a syntax that might have shipped badly.
The style is still worth keeping if you are writing code that might get vendored into a tree that minifies with jsmin:
const EXTERNAL_URL_RE = /^https?:\/\//;
const isExternalShortcut = (url) => EXTERNAL_URL_RE.test(shortcutText(url));
Sibling packages: luci-theme-aurora and luci-theme-shadcn do not
override LUCI_MINIFY_JS. Their Vite output survives jsmin's syntax check,
but breakage #2 is invisible to that check — worth a look if styling ever
misbehaves only on-device.