update 2026-10-22 17:05:51

This commit is contained in:
action committed 2026-10-22 17:05:51 +08:00
1 parent 06b74cb314
commit 3f38c69f75
303 files changed
+7299 -130543

No files matched your search

-879
View File
@@ -1,879 +0,0 @@
# 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.
```bash
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](https://github.com/eamonxg/luci-theme-tokens)) — 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.
```bash
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
```bash
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:
```bash
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** — `GET`s 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:
```bash
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 `require`s 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
```bash
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:
```js
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.
@@ -1,195 +0,0 @@
# 让分享的配置带上自定义字体
## 背景
主题商店分享的配置,若使用了本机上传的自定义字体,接收方应用后字体不生效。
真机复现(JDCloud RE-CS-02,2026-08-06):
```
uci struct_font_sans = "OPPO Sans Regular", "Lato", ui-sans-serif, system-ui, sans-serif
本机 custom 字体只有 = Inter (sans-inter.meta)
aurora-font.css = 只有 Lato + Inter 两个 @font-face,无 OPPO Sans Regular
```
根因:分享端把引用了自定义字体的 CSS font-stack 原样发出,但**字体文件本身从不上传**
(`luci.aurora:818` 注释 "v1 never ships font assets")。接收端写入这个 stack,
字体族在本机不存在,浏览器静默回退到 stack 末尾的 Lato。
云端其实早已为字体建好整条通道,缺口只在路由器两端:
| 环节 | 状态 | 位置 |
|---|---|---|
| schema 白名单 + 8 MiB 限额 | 已就位 | `validate.js:19,22-30` |
| magic byte `isWoff2` | 已就位 | `assets.js:72-73` |
| Content-Type `font/woff2` | 已就位 | `assets.js:82-83` |
| 上传票据通道(kind-agnostic) | 已就位 | `drafts.js:153-204` |
| 审核 body 25 MB(为 8 MB 字体预留) | 已就位 | `admin.js:12-16` |
| 审核台 woff2 passthrough | 已就位 | `admin/index.html:907-909` |
| **分享端列出字体资产** | **缺** | `luci.aurora:935` 循环只有 6 种图片 |
| **接收端落地字体资产** | **缺** | `luci.aurora:663` 只认 5 种图片 kind |
| 网页详情页展示字体 | 缺(纯展示) | `site/config.html:377` |
## 决策(已确认)
1. 字体随配置分享,走已有的 asset 通道
2. 尺寸上限**维持 8 MiB 不变**。曾短暂改到 16 MiB 又改回:R2 免费额度 10 GB,
8 MiB 一份约能放 1280 份配置,翻倍不值得。连带把当时拆出来的
`MAX_FONT_UPLOAD` 也收掉了——和 `MAX_UPLOAD` 值相同就只是个空壳抽象。
云端因此零功能改动,只留一句注释记下 `ADMIN_APPROVE_BODY_BYTES`(25 MB)
才是字体上限的真实天花板
3. 加版权说明(分享时**和**详情抽屉,两处方向相反)与存储说明(详情抽屉)
## 关键杠杆点
接收端只要把下载到的 woff2 按 `upload_font` 的形状摆好——
`$FONT_CUSTOM_PATH/` 下 `.woff2` + `.meta`(`family|stack`) + `.face` 三件套
(`luci.aurora:2382-2399`)——下游渲染链路**一行都不用改**:
- `write_combined_font_css:1438` 的 custom 扫描自动收录
- `write_font_preload:1488` 的 stack 匹配自动命中
- `migrate_font_cache:1778` 的 custom 保留判断自动放行(不会被误删)
## 任务清单
### A. 尺寸上限 —— 最终不改
- [x] A1–A4 全部回退到 8 MiB(见「决策」第 2 条)。唯一留下的是 `admin.js`
的一句注释。附带收获:过程中确认了路由器端 `MAX_UPLOAD` 是图片和字体
共用的,将来真要给字体单独放宽,必须拆常量而不是直接改它 —— 否则图片
也会跟着放宽,然后在 hub 的 2 MiB 那关被拒
### B. 分享端(luci.aurora)
- [x] B1 assets 循环加 font_sans/font_mono
- [x] B2 `.files` 第二列改为相对 `/luci-static/aurora/` 的路径,
`hub_share_begin` 去掉写死的 `images/` 前缀
- [x] B3 只分享 custom 字体,名册预设不上传
- [x] B4(计划外)抽出 `shared_custom_font_base()`:发布面板的清单和真正的
打包本来会各判一次「哪份字体会被发出去」,两处迟早漂移成「清单里写着、
实际没发」。现在两者问同一个函数
### C. 接收端(luci.aurora)
- [x] C1/C2/C3 新增 `apply_hub_font_asset()`,在 assets 循环里对 font kind
单独分流。字体落成与 `upload_font` 完全同形的三件套 —— 杠杆点成立,
`write_combined_font_css` / `write_font_preload` / `migrate_font_cache`
一行未改就自动认它(真机 preload.txt 已验证)
- [x] C4 覆盖前备份,冲突即覆盖(family/stack 相同,视觉无差)
- [x] C5 新增 `restore_pre_hub_fonts()`:覆盖的复制回来,**新引入的直接删掉**
(`.new` 标记)。少了后者,回滚会留下几 MB 别人的字体永远占着 overlay
- [x] C6(计划外)本地复验 woff2 magic:字节来自网络,不复验等于把校验外包给
服务器
### D. 说明文案(含 14 语言 po)
- [x] D1 分享面板版权声明(仅在真的要发字体时出现)
- [x] D2 详情抽屉存储说明:体积 + 存放位置 + 升级不保留
- [x] D3 详情抽屉此前**根本没有**字体下载提示(那句只在内置预设抽屉里)。
现在两句各判各的,已带文件的槽位不再算进「待下载」
- [x] D4 8 条字符串 × 14 语言全部翻译到位。另有 14 条**既有**未翻译条目
属上一批 store 功能,不在本次范围
- [ ] D5 po2lmo 真机看翻译(未做)
### E. 展示层
- [x] E1 **无需改动**——`site/config.html:377` 的 `IMAGE_ASSET_KINDS` 是
「能画预览图的 kind」而不是白名单,字体走 else 分支已按「只列名不预览」
处理好了。原计划把它当缺口是看错了
## 已考证的事实(供文案写实)
- 字体落 `/www/luci-static/aurora/fonts/custom/`,属根文件系统 →
写入实际落 **overlay 分区**,与 opkg 软件包、`/etc/config` 共用可写 flash
- 上传中转 `/tmp`(tmpfs,吃 RAM),传完即删,非持久占用
- 拉丁字体子集无压力(实测 Lato 23 KB、Inter 111 KB);
中文全字集 woff2 通常几 MB 到十几 MB,是真正的风险
- 8 MB flash 设备 overlay 常仅剩 1–3 MB,16 MB 的多在 5–10 MB(量级估计)
- overlay 写满 → 装不了包、uci 保存失败、sysupgrade 可能出问题
- `sysupgrade.conf.d/aurora-device` 只保留 device.key/hash,
**字体升级后丢失**;但 `/etc/config/aurora` 默认保留,
`migrate_font_cache:1774-1786` 会检测到并重置为默认,不留坏状态
## 验证
- [x] V1 真机(192.168.8.1)单机闭环,证据如下
- [x] V2 hub 单测 383 passed;本仓库 321 passed
- [x] V3 尺寸上限维持 8 MiB,原有边界单测未动
- [x] V4 回归:mono 槽位用的是名册预设,全程正确地**不**出现在
`shared_fonts` 和 assets 里
### V1 真机证据
分享端 —— 把 sans 指向本机上传的 Inter:
```
hub_local_state → "shared_fonts": [{ "slot":"sans","family":"Inter","size":111268 }]
build_share_payload → assets 含 { "kind":"font_sans","sha256":"e06f6b1b…","size":111268 }
.files → login_bg images/hub-login-bg-08c6284def5e.jpg
font_sans fonts/custom/sans-inter.woff2
```
接收端 —— 删掉本机 Inter 三件套、uci 仍指着它(**bug 现场复现**:
`grep -c Inter aurora-font.css` = 0,页面回退 Lato),再跑新代码:
```
apply_hub_font_asset sans <sha> http://127.0.0.1/hubtest.woff2 → APPLY_OK
custom/ → sans-inter.woff2 (111268) + .meta + .face
.meta → Inter|"Inter", "Lato", ui-sans-serif, system-ui, sans-serif
CSS → @font-face font-family:"Inter" src:url(custom/sans-inter.woff2?v=…)
preload → /luci-static/aurora/fonts/custom/sans-inter.woff2?v=…
```
负路径 —— sha256 不匹配:`REJECTED_AS_EXPECTED`,目录全空(.hubtmp 也没留)。
测试后真机已恢复原状(`struct_font_sans` 还是那份坏掉的
"OPPO Sans Regular",即用户报告的现场,刻意没动)。
## F. 审核台不再往返字体字节(计划外,但必须做)
`ADMIN_APPROVE_BODY_BYTES` 是 25 MB,而一份资产拉满的配置是
6×2 MiB(图片)+ 2×8 MiB(字体)= 28 MiB,base64 后约 **37 MB**。
也就是说「能分享但永远批不了」这个缺陷**当前就存在**,不需要谁去抬字体上限。
- [x] F1 `assets.js` 新增 `APPROVE_FROM_R2_KINDS = {font_sans, font_mono}`:
sanitize 不改变字节的 kind 没理由被下载、base64、再原样传回来
- [x] F2 approve 对这些 kind 从 `pending/` 直接读,并照样重跑 magic + size
检查(字节在存储里躺过,不复验就是把校验外包给桶)
- [x] F3 线格式改成各自声明:passthrough 的 kind **必须**用
`{passthrough:true}` 且不带 `data_b64`,其余 kind **必须**带
`data_b64`。任一方漂移就 400 —— 防的是「某个 kind 将来加了真正的
sanitizer 却还留在集合里」,那会静默地批准没人清洗过的字节
- [x] F4 `favicon_ico` 刻意不加入:2 MiB 不构成体积问题,且它的 passthrough
是被迫的(canvas 产不出 ICO)而非设计如此,是最可能长出真 sanitizer 的
- [x] F5 补 6 个测试。**这条路径此前零覆盖** —— 所以我改了线格式,383 个测试
依然全绿。并做了变异验证:把服务端自己的判断换成「前端说了算」,必须有
测试变红
## 验证补充
- [x] D5 po2lmo:从 luci 源码编出 po2lmo → 编 zh_Hans → 推真机 →
用 LuCI 自己的 `i18n.translate` 逐条查,9 条新字符串全部解析出中文
(注意语言代码是 `zh-cn`,不是 po 目录名 `zh_Hans`)
- [x] E2E:**本地起完整 hub**(`wrangler dev` + 本地 D1/R2),路由器
`HUB_BASE` 临时指过去,跑完 分享→直传→审核→应用→回滚 全程,
**完全没碰生产商店**
### E2E 全程记录
```
hub_share_begin → assets 含 font_sans,src=/luci-static/aurora/fonts/custom/sans-inter.woff2
凭 ticket 直传 → login_bg 866522B / font_sans 111268B,均 HTTP 200
hub_share_commit → id=m399c8az
admin approve → body 仅 103 字节(字体的 111KB 不在其中),HTTP 200
{"kind":"font_sans","passthrough":true}
GET /assets/… → HTTP 200, 111268B, content-type: font/woff2
应用前 → 删光本机 custom/,CSS 中 Inter 出现 0 次
hub_apply → custom/ 三件套重建,uci struct_font_sans 指向 Inter,
CSS 有 @font-face,preload.txt 命中,active_source=hub:m399c8az
回滚 → 备份目录里是 sans-inter.new(本次新引入)→ 三件套被删净,
CSS 中 Inter 归 0,别人的字体没有留下来占 overlay
```
## 尚未做
- 部署到生产:hub 的 approve 线格式变了,`src/` 和 `site/` 同属一次
`wrangler deploy`,所以前后端会一起更新,不存在中间不一致的窗口
- 真正在公开商店发布一条(本地 hub 已覆盖同样的链路,价值主要在验证生产配置)
注意:测试机 overlay 挂在 tmpfs(RAM overlay),重启全丢,
且其 df 数字不代表普通设备——见 memory `router-ram-overlay`。