Files
2026-10-29 18:46:39 +08:00

45 KiB
Raw Permalink Blame History

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.ut injects every light_* / 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 — its build.mjs resolves every derived token and every built-in preset at package build time and ships the results as prebuilt dist/aurora/ artifacts. luci-theme-aurora and 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 by studio.js for 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 the TOKENS_ENGINE_VERSION constant, stamped from the vendored package's package.json version. studio.js appends it as ?v= when loading tokens.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:

  1. --local <path> — a local luci-theme-tokens checkout, for iterating on an unreleased spec (run npm install + node build.mjs there first).
  2. npm registry — the version pinned in .dev/package.json devDependencies; this is the normal path.
  3. 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:

  1. studio.js joins its UI metadata with the registry at load time and throws on missing/stale entries (buildColorTokenTables).
  2. .dev/tests/theme-token-sync.test.mjs re-runs sync-tokens.mjs --check against the registry (the check also covers the TOKENS_ENGINE_VERSION stamp in studio.js, and a dedicated test asserts it matches the engine header).
  3. .github/workflows/token-sync-check.yml runs 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:

  1. Frontend — studio.js loads it for live preview, on-save computation, and the ordered token tables (COLOR_TOKENS et 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-tokens 2.0.0: the PRESETS map lives in that package's aurora/presets.js, its build.mjs resolves every preset to hex and ships dist/aurora/presets.json, and gen-presets.mjs only formats whatever .dev/scripts/aurora-presets.json says.
  • Structure is authored here, in the template itself. sync-tokens.mjs overwrites .dev/scripts/aurora-presets.json on every sync, so nothing this repo decides may live there.

To retune colours:

  1. luci-theme-tokens repo: edit the PRESETS map in aurora/presets.js (each preset specifies only the 10 inputs × light/dark), npm test.

  2. Tag vX.Y.Z, push — CI auto-publishes.

  3. config (here): bump the pin in .dev/package.json → node .dev/scripts/sync-tokens.mjs — refreshes .dev/scripts/aurora-presets.json and reruns gen-presets.mjs, which rewrites root/usr/share/aurora/*.template in 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-tokens writes into .dev/src/resource/, so finish with pnpm build before 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_payload enforces on hub configurations.
  • Font stacks must be copied verbatim from field 7 of the matching font|<slot>|<id>|… row in font-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>.template file in this repo (copy an existing one — gen-presets.mjs only rewrites templates that already exist, it never creates one)
  • marketplace.js → BUILTIN_PRESETS (store card) and BUILTIN_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-tokens repo's aurora/spec.js/aurora/defaults.js, released as @eamonxg/luci-theme-tokens; everything here is vendored from that package.

  1. luci-theme-tokens repo: edit aurora/spec.js/aurora/defaults.js, npm test.
  2. Tag vX.Y.Z, push — CI auto-publishes.
  3. theme: bump .dev/package.json → npm install → npm run build.
  4. config (here): bump the pin in .dev/package.json → pnpm sync-tokens — refreshes tokens.global.js, color-tokens.conf, and .dev/scripts/aurora-presets.json (and, via gen-presets.mjs, the preset templates) in one go (if there's a new token, follow the test prompts to fill in studio.js copy). To iterate on an unreleased spec: node .dev/scripts/sync-tokens.mjs --local ../luci-theme-tokens.
  5. 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 and buildColorTokenTables() fail loudly until every registry token has metadata.
  6. Bump SCHEMA_VERSION in 80_aurora (see §5).
  7. pnpm build → pnpm test → commit. Steps 4 and 5 both wrote into .dev/src/resource/, so the artifacts are stale until this runs; pnpm test refuses 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 --check correctly 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_VERSION at the top is the current version; the header comments record what each version (v2 / v3 / v4 / …) changed.
  • On upgrade, migrate_color_schema() compares the stored config_version; on a mismatch it calls reseed_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.
  • 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's option (light|dark)_ line count equals COLOR_TOKEN_KEYS count × 2. The key list ships as /usr/share/aurora/color-tokens.conf, generated by sync-tokens.mjs from the same registry as the templates, so the two cannot drift apart. It then calls load_preset_struct_snapshot(), which requires every PRESET_STRUCT_KEYS option and checks each against the same bounds validate_and_apply_hub_payload applies 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 font line per preset (slot is sans or mono); built-in presets (Lato, System UI/System Mono) have no matching file lines.
  • One file line 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.mjs guards this format (field counts, slot/weight enums, sha256 shape, URL patterns) and runs as part of npm 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 a receive_upload gate 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).
  • Front-end plumbing lives in utils/asset-upload.js (dropzone, progress row, delete confirm, cgi-upload XHR); view/aurora/studio.js composes it for both the Custom Fonts and Brand Asset Library sections.
  • Stored under /www/luci-static/aurora/fonts/custom/<slot>-<slug>.{woff2,meta,face} (.face is the pre-rendered @font-face block, .meta carries the display family + font stack).
  • Custom faces are always included in the combined aurora-font.css, and selection flows through the struct_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 — the admin/system/aurora/marketplace page: 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 via E()'s safe children or document.createTextNode — never innerHTML — since it is untrusted server content.
  • utils/hub-api.js — rpc.declare wrappers for every hub_* method against the luci.aurora object, plus a 5-minute localStorage cache (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_aurora reads default.template at install time, when no browser exists; then apply_theme_preset and reset_defaults read them, and get_theme_preset feeds 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.json is downloaded by every visitor to the store, so it carries only what the store reads: the four SWATCH_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 (mode 0600) — 32 random bytes, hex-encoded, as device_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 the device_token field of a hub_share/hub_update/hub_delete POST/PUT/DELETE body.
  • /etc/aurora/device.hash (mode 0644) — 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 a sysupgrade that 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:

  1. backup — copies the current /etc/config/aurora to /etc/aurora/pre-hub-backup.conf (single slot: only the most recent apply can be rolled back).
  2. fetch — GETs the full config payload from the hub.
  3. 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 62 light_*/dark_* hex keys, enum-checked nav_type/font ids, bounded/regex-checked rem values and font stacks, per-item toolbar validation, has_control_char guards against embedded-newline UCI injection) using only case/grep -E — no eval. Nothing is written to UCI until every field passes; a single uci batch (colors deleted then reset, layout/typography set, toolbar_item sections replaced, hub_applied + icon_cache_version bookkeeping) is then committed atomically, and reverted if the commit fails.
  4. 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 updates struct_login_bg. Best-effort: a failed asset fetch doesn't fail the job.
  5. finalize — resyncs font CSS, bumps the icon cache timestamp, marks the job done.
  6. download count — fires a fire-and-forget POST .../download with device_hash after 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_mono are shared as preset ids only (resolve_font_preset_id), never as uploaded .woff2 bytes — 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 brand but hover/status colors don't follow? Derived tokens weren't written to UCI — check that tokens.global.js loaded and that persistDerivedTokens ran on save.
  • Colors broken or invisible text after an upgrade? Stale keys weren't pruned — confirm SCHEMA_VERSION was bumped and the migration ran.
  • Applying a preset fails? The backend COLOR_TOKEN_KEYS count 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 --xxx names in the theme's _tokens.css exactly.
  • 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 against htdocs/'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 check names the stale files.
  • A page is blank with nothing in the console? Check that htdocs/luci-static/resources/utils/color.global.js still contains var Color=. If it is an empty or near-empty file, mangle.toplevel or compress.toplevel got 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-pot scanned. pnpm test asserts 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 under utils/) — LuCI appends ?v=${env.resource_version}, parsed from the luci.js script tag, i.e. luci-base's version. Bumping this package's PKG_VERSION does 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.js engine version sync-tokens.mjs → TOKENS_ENGINE_VERSION
    aurora/presets.json content hash gen-presets.mjs → PRESETS_VERSION
    utils/color.global.js content 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 .b is 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.