Files
op-packages/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
T

895 lines
53 KiB
JavaScript

'use strict';
'require baseclass';
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document by a real load ----
*
* A view's <style> dies with the document on a full load; SPA nav never reloads, so it restyles
* every page after. `luci-app-filemanager` injects `.cbi-button-apply, .cbi-button-reset,
* .cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered + important,
* outranking every cascade layer: one visit and Save/Reset are gone from every config page.
*
* But DELETING them on nav broke SSClash. A poller can be re-registered by re-rendering the view;
* a stylesheet only returns if its injector runs AGAIN, and a library importing CSS at MODULE EVAL
* never will (module cached for the life of the document). ACE's ace_editor.css (14 KB of
* absolutely-positioned layers, gutter, line boxes) is imported once — after the sweep, navigating
* back to its editor gave a black rectangle 2 007 346 px tall. Deletion was silently one-way.
*
* So: a sheet matching only its OWN app's widgets (`.ace_*`, `.cpu-status-view-mode-entry`) is
* inert elsewhere — LEAVE it. One reaching into the widget universe the THEME styles
* (`.cbi-button-save`, `pre`, `:root`) can repaint any page: that document is spent, so refuse to
* hand it to another view and fall back to a REAL page load — speed traded, never correctness, and
* the fresh document carries no view CSS, so SPA nav resumes right after. That refusal is the SPA
* router's (fs-router.js) — this module only answers the question.
*
* `invasiveSheet()` is that test; its universe is read back from cascade.css itself (same-origin,
* so `cssRules` is readable) rather than a hand-written list, so it tracks the theme. 0.3 ms per
* nav. Exempt: `[data-fs-shell]` (the one <style> the server emits — marked, not guessed at) and
* anything inside `#view` (dies with the content swap); LuCI core injects no <style> at runtime at
* all (checked: luci.js, ui.js, cbi.js). If cascade.css cannot be read, EVERY view sheet counts as
* invasive: fail to the slow path, never the broken one. */
let _themeNames = null;
/* What counts as a NAME — a class or an id — in a selector. This is the vocabulary the whole zone
* test is written in: themeNames() harvests the theme's names with it, pinnedToApp() looks for the
* app's own name with it, and judgeSheet() asks whether a part names anything of ours with it. Three
* copies of the pattern sat under a comment explaining that two copies of the JUDGEMENT would drift
* into disagreeing — and a vocabulary that disagrees with itself is the same bug one level down: widen
* it in the harvester alone and names enter `names` that the other two can never match, so a selector
* that does reach the chrome reads as pinned and is left unfenced.
*
* Shared safely BECAUSE every use is String.match(): a /g regex is stateful under .test(), but
* [Symbol.match] resets lastIndex first. Do not call .test() on this one. */
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
/* ---- A QUOTED VALUE IS DATA, AND EVERY SCANNER BELOW USED TO READ IT AS SYNTAX ----
*
* `[title="a,b"]` is ONE selector part carrying a comma; `[href*="("]` is one attribute carrying an
* unbalanced paren; `[data-x=".foo"]` names no class at all. Read literally, each of the three
* scanners in this file gets a different wrong answer out of the same string:
* - selectorParts() split `.app-row[title="a,b"]` into `.app-row[title="a` and `b"]`. The second
* half carries no class or id, so pinnedToApp() called it UNPINNED and judgeSheet() called the
* sheet invasive — documentPoisoned() then reported the document spent and the SPA fell back to a
* full load on every navigation for the life of the page, which is the exact failure the <link>
* caching bug above was written to end.
* - fenceRules() rejoins the parts with ', ', so that same rule came back as `[title="a, b"]` —
* the app's own selector silently rewritten to match a value it never asked for, by the setter
* reporting success. Deleting a rule is what this file exists to prevent; changing one is worse,
* because nothing looks wrong afterwards.
* - stripPseudoArgs() counts parens, so `[href*="("]` drove `depth` to 1 and never back: the whole
* remainder of the selector was eaten and a part pinned by the app's own id read as unpinned.
*
* One masker answers it for all three, so the vocabulary cannot disagree with itself the way the
* comment above NAME_RE warns about. It replaces the CONTENT of every quoted string with spaces and
* is length-preserving 1:1, which is what lets selectorParts() scan the mask and still slice the
* ORIGINAL — the fence must write back the app's own bytes, not our reading of them. An escape and
* the character it escapes are both content, so `\"` cannot close the string. */
function maskStrings(text) {
let out = '', q = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q === null) {
out += ch;
if (ch === '"' || ch === '\'') q = ch;
continue;
}
if (ch === '\\') {
out += ' ';
if (i + 1 < text.length) { out += ' '; i++; }
continue;
}
out += (ch === q) ? ch : ' ';
if (ch === q) q = null;
}
return out;
}
/* a re-hosted <style>'s text is no longer what its app wrote — dedupeViewSheets keys on the
* original, or the app's next identical copy stops looking like a duplicate (see there) */
const origText = new WeakMap();
function themeNames() {
if (_themeNames) return _themeNames;
const names = new Set(); /* every class and id the theme styles */
const props = new Set(); /* every custom property it declares or reads */
const walk = (rules) => {
for (const r of rules) {
/* masked like every other read of a selector: a `.foo` inside one of OUR quoted values
* would enter `names` as a name we style, and pinnedToApp() — which masks — could then
* never match it. That is the harvester-only widening NAME_RE's comment describes, and
* it ends with a foreign selector that does reach the chrome reading as pinned. */
if (r.selectorText)
(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
if (r.cssText)
(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
if (r.cssRules) walk(r.cssRules);
}
};
for (const ss of document.styleSheets) {
if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
try { walk(ss.cssRules); } catch (e) { return null; }
}
_themeNames = names.size ? { names, props } : null;
return _themeNames;
}
/* Is this selector part held inside the app's OWN markup by a name the theme does not know?
* `#cbi-podkop-section > .cbi-section-remove` is: podkop's section has to exist for it to match
* anything, so it can never reach another page — or our chrome. A part made ENTIRELY of names the
* theme knows (`*`, `.nav`, `#indicators`, `ul.nav > li > a`) has nothing pinning it anywhere.
*
* Functional pseudo-class arguments are stripped before looking for the pin, and that is the whole
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
* an app class too, but inside a NEGATION — it does not require the app's markup, it excludes it.
*
* Shared by invasiveSheet() (is this sheet dangerous?) and fenceRules() (which parts get fenced?) —
* they must agree by construction: a part judged able to reach another page is exactly a part able
* to reach the chrome. Two copies of this test would drift into disagreeing. */
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
* part carrying a comma, and splitting it there hands both halves to pinnedToApp() as garbage —
* `.cbi-button-save:not(.custom-save-button` keeps a visible app name (the argument regex needs a
* closing paren to fire), so the file manager's own motivating rule reads as pinned and is neither
* judged nor fenced. Measured on the router: `documentPoisoned()` said clean. `:not(a, b)` is
* ordinary modern CSS, not an exotic.
*
* Scans the MASK and slices the ORIGINAL (see maskStrings): a comma inside `[title="a,b"]` is a
* character in a value, not a separator — while the parts handed back must be the app's own bytes,
* because fenceRules() joins them straight back into selectorText. */
function selectorParts(text) {
const scan = maskStrings(text);
const out = [];
let depth = 0, start = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
}
out.push(text.slice(start).trim());
return out.filter(Boolean);
}
/* Drop every functional pseudo-class ARGUMENT, nesting included. The old regex
* (`/:[a-z-]+\([^)]*\)/g`) stops at the first `)`, so `:not(:is(.app))` left a stray `)` and, worse,
* left `.app` looking like a pin.
*
* Works on the MASK, which does two things at once here: a paren inside `[href*="("]` no longer
* drives `depth` into a hole it never comes back from, and the `.foo` in `[data-x=".foo"]` stops
* looking like the app's own pin to pinnedToApp() — the only caller. Its output is read by NAME_RE
* and never written back to the CSSOM, so masking the content away costs nothing. */
function stripPseudoArgs(part) {
const scan = maskStrings(part);
let out = '', depth = 0;
for (let i = 0; i < scan.length; i++) {
const ch = scan[i];
if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
if (ch === ')') { depth--; continue; }
if (!depth) out += ch;
}
return out;
}
function pinnedToApp(part, names) {
return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
}
/* A rule with a bare SELECTOR (`:root`, `pre`, `*`) still cannot touch us if none of its
* DECLARATIONS can: a custom property this theme never reads is inert. That is the difference
* between an app costing a full page load and not — `luci-app-temp-status` opens with
* `:root { --app-temp-status-temp: #147aff; … }`, and both it and the file manager's hex editor
* would otherwise read as "document spent" on the strength of the selector alone.
*
* Still invasive: any STANDARD property on a bare selector (the stock file manager writes
* `:root { color-scheme: light dark }`, re-pointing every UA widget at the OS preference), and any
* custom property the THEME reads — the point of the private `--fs-*` tier is that an app writing
* `--accent`/`--radius` on `:root` cannot repaint us, and this must keep it so for names we read. */
function inertDeclarations(rule, props) {
const st = rule.style;
if (!st || !st.length) return false; /* no declarations to judge -> judge by selector */
for (let i = 0; i < st.length; i++) {
const p = st.item(i);
if (p.slice(0, 2) !== '--') return false; /* a real property: it paints something */
if (props.has(p)) return false; /* a custom property the theme itself reads */
}
return true;
}
/* ---- the verdict is a property of the sheet, taken BEFORE we rewrite it -------------------
*
* An invasive verdict is STICKY, and it has to be: the moment rehostIntoThemeLayer() fences a sheet,
* what stands in the DOM is no longer the CSS its app wrote, and re-judging our own edit answers a
* different question than the one asked.
*
* It used to answer the right one BY ACCIDENT. The fence named a class (`.fs-sidebar`), so a fenced
* selector still carried a name the theme styles, still tripped the `themeHit` test, and
* documentPoisoned() went on reporting the document spent. Nothing said that was load-bearing —
* moving the fence onto an attribute leaves no class name in the text, every fenced document would
* have read CLEAN, and the SPA would have carried openclash's `*{padding:0!important}` into the next
* page with the chrome fenced and the content flattened. Take the verdict once, keep it.
*
* Only `true` is kept. A clean sheet can still GROW hostile rules — an app that builds its CSS with
* insertRule() has an EMPTY sheet the first time we look — so a clean verdict stays provisional and
* is re-taken on every ask. */
const _invasive = new WeakSet();
function invasiveSheet(el, universe) {
if (_invasive.has(el)) return true;
const v = judgeSheet(el, universe);
/* CACHE ONLY A VERDICT WE COULD ACTUALLY READ.
*
* A <link> has NO .sheet until its bytes land, and judgeSheet's "unreadable -> invasive" default
* therefore fires for EVERY linked app stylesheet at the instant <head>'s observer first sees
* it. Remembering that verdict is what turned a benign sheet into a permanently spent document:
* measured on the router, `luci-app-mwan3` ships ONE rule —
* `#mwan3-service-status > .alert-message { … }`, pinned to the app's own id, which this
* module's own judgement calls clean — and its <link> on the Overview left documentPoisoned()
* true for the life of the page, so every navigation FROM THE LANDING PAGE was a full load.
* Proven by serving those same bytes twice: as a <style> the document stayed clean, as a <link>
* it went poisoned. Nothing about the CSS decided it; only how it arrived.
*
* Safe to re-take, and the "never re-judge our own edit" rule above still holds, because the
* two cases do not overlap: rehostIntoThemeLayer() edits a <style>'s text and fences its rules
* — and a <style> in the document always HAS a sheet, so its verdict was cached when taken and
* is never re-taken. A <link> it never edits at all (it disables the element and re-imports the
* href into the theme layer), so a later read still sees the app's untouched CSS, which is the
* right question. A 404 or cross-origin sheet keeps answering `true` on every ask exactly as
* before — it simply is not remembered, which changes nothing.
*
* Conservative WHILE unreadable is preserved: `v` is still returned as taken, so a hostile
* sheet is fenced and re-hosted on sight and the document reads spent until the bytes prove
* otherwise. Only the memory is dropped. */
let readable;
try { readable = !!el.sheet; } catch (e) { readable = false; }
if (v && readable) _invasive.add(el);
return v;
}
/* true when this sheet can repaint a page that is not its own. A sheet that is not readable —
* still loading, 404, cross-origin — is invasive by default: unknown CSS takes the slow path,
* never the broken one. */
function judgeSheet(el, universe) {
let sheet;
try { sheet = el.sheet; } catch (e) { return true; }
if (!sheet) return true;
const { names, props } = universe;
let invasive = false;
const walk = (rules) => {
for (const r of rules) {
if (invasive) return;
if (r.selectorText) {
/* ONE question, the SAME one fenceRules() asks: is this part held inside the app's
* own markup by a name the theme does not know? `#cbi-podkop-section >
* .cbi-section-remove` is — podkop's section has to exist for it to match anything,
* so it can never reach another page or our chrome. A part with no such pin matches
* the same widgets everywhere, and that is what invasive MEANS.
*
* This used to ask a second question first — "does it name anything the theme
* styles?" — and skip the part when the answer was no. That is the hole: a pin is a
* name the theme does NOT know, so "names nothing of ours" was read as "pinned" when
* it often means the exact opposite. Measured on the router, verdict CLEAN and 95 of
* 338 chrome elements flattened, by two selectors an app could write by accident:
* *:not(#zzz) { padding: 0 !important } ← the `#` is inside a NEGATION
* [class] { padding: 0 !important } ← no class/id name at all
* Both are unpinned, both match the whole document. fenceRules() already keyed on
* pinnedToApp() alone and would have fenced them — it never got the chance, because
* the judge called the sheet clean and nothing was re-hosted. The comment above
* pinnedToApp() claims the two agree "by construction"; now they do. */
for (const p of selectorParts(r.selectorText)) {
if (pinnedToApp(p, names)) continue;
/* Unpinned, but it may still be unable to touch us: a rule whose every
* declaration is a custom property this theme never reads is inert wherever it
* lands (`:root { --app-temp-status-temp: … }`). */
if (inertDeclarations(r, props)) continue;
invasive = true;
return;
}
}
if (r.cssRules) walk(r.cssRules);
/* an @import's rules are not r.cssRules — follow it, or the verdict is blind to every
* rule behind it. Re-hosting a <link> produces exactly such a shim, and a sheet that
* judged its own shim inert would report a document clean while still carrying the
* poison into the next page. Unreadable (cross-origin) import: invasive, like any
* sheet we cannot read. */
if (r.styleSheet) {
let imported;
try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
if (imported) walk(imported);
}
}
};
try { walk(sheet.cssRules); } catch (e) { return true; }
return invasive;
}
/* Both element kinds count; the <link> half is not hypothetical: `luci-app-banip` and
* `luci-app-adblock` append `<link rel=stylesheet href=…/custom.css>` to <head> at MODULE EVAL,
* and it styles `.cbi-input-text`/`.cbi-input-select` — stock widgets, every page, unlayered. A
* <link> INSIDE the view tree (`luci-app-nlbwmon`) needs no handling: it dies with the swap. */
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
/* DOES THIS SHEET OUTLIVE THE PAGE IT ARRIVED WITH? Everything this module decides hangs off that
* one question, and the answer is where the element sits: a <style>/<link> inside the view tree
* dies with the swap (dom.content() replaces #view's children), so it can neither poison the next
* page, nor need scoping to this one, nor be a duplicate worth removing. Only sheets outside it
* are this module's business. Named because it is asked in four places and read wrong in none of
* them only by luck: `!el.closest('#view')` states where an element is, not what follows from it. */
function outlivesPage(el) {
return !el.closest('#view');
}
/* Is `path` — a menu.d node's `css`, i.e. a path under /luci-static/resources — already carried by
* this document in a form that SURVIVES a swap?
*
* The router asks before committing a client navigation: only a server render emits that <link>, so
* a page whose stylesheet is missing must arrive by full load (see fs-router.js). The question is
* this module's because the answer is: a link inside #view is about to be deleted with the rest of
* the view, and counting it would hand the router a sheet the next dom.content() throws away —
* `luci-app-nlbwmon` returns E('link', …, L.resource('view/nlbw.css')) from render(), so that shape
* is real.
*
* WHOLE PATH, NOT A SUFFIX. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…`, and the base in
* that line is the SAME value the runtime holds: header.ut hands `resource` to `new LuCI({…})`, and
* `L.resource()` joins it back exactly, so the server's href is reconstructable rather than guessable
* — only the cache key has to come off. A suffix match is what a guess costs: anchored at nothing but
* a `/`, `custom.css` matches any sheet ending in that filename, and two in-tree apps append exactly
* that to <head> at module eval — `luci-app-adblock` and `luci-app-banip` both add
* `L.resource('view/<app>/custom.css')`, outside #view, so outlivesPage() keeps them and this module
* disables rather than removes them: they stay for the life of the document. A third-party node
* declaring `"css": "custom.css"` would then read as already-carried the moment the user had passed
* through Adblock → Feeds, and the router would swap into a page whose stylesheet was never linked —
* the one outcome the guard exists to prevent.
*
* Equality also keeps the failure safe: L.path() drops a part that leaves its charset, so a malformed
* `css` yields the bare base and matches no href at all — a full load, which is the correct answer for
* a value nobody can serve. */
function documentCarries(path) {
const want = L.resource(String(path));
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
if (!outlivesPage(link))
continue;
if ((link.getAttribute('href') || '').split('?')[0] === want)
return true;
}
return false;
}
/* An invasive sheet we OWN is contained — scopeToCurrentPage() darkens it the moment the router
* stamps the new page, so it cannot reach the next page and the document is not spent. One we could
* not attribute (not re-hostable, so never owned: an @import at the top, a sheet built with
* insertRule(), anything unreadable) still spends it, which is the pre-existing behaviour and the
* conservative half.
*
* A SILENCED sheet is contained too, and missing that undid the whole of the above for the <link>
* half. Re-hosting a <link> owns the @import SHIM and silences the ORIGINAL for good (see
* rehostIntoThemeLayer) — but the original stays in the document and a disabled sheet still answers
* `cssRules`, so it re-judged as invasive on every ask, was owned by nobody, and this returned true
* for the life of the document. Which means the very apps this module was written for
* (`luci-app-banip`, `luci-app-adblock`, `luci-app-openclash` — all three inject a <link>) turned
* the SPA router off entirely: a full page load on every navigation, from the moment such a page was
* opened until the tab was closed. The <style> half never showed it, because a <style> is re-hosted
* IN PLACE and therefore owned.
*
* Sound for the same reason ownership is: `el.sheet.disabled = true` is what decides whether CSS
* paints (silence() explains why the element flag alone is not enough), and nothing re-enables it —
* scopeToCurrentPage() only ever touches sheets in `_owner`, and the original is deliberately not
* one. A sheet that paints nothing cannot poison the next page. */
function documentPoisoned() {
const names = themeNames();
return Array.prototype.some.call(
document.querySelectorAll(VIEW_SHEETS),
(el) => outlivesPage(el)
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
}
/* ---- an invasive sheet still has to render ITS page: re-host it into the theme LAYER ----
*
* documentPoisoned() saves every page AFTER this one. It cannot save this one — the sheet is
* already applying. Every footstrap rule lives in a @layer, and an UNLAYERED normal declaration
* beats a layered one at any specificity, so a third-party reset owns the chrome outright:
* `luci-app-openclash` ships `* { margin: 0; padding: 0 }` (a log.htm reset, leaked document-wide
* by a <link> its Lua template prints into .fs-content). Measured on the router with those two
* rules and nothing else: menu text flush at x=0 with the icons clipped, submenu indent gone, tabs
* collapsed to a bare text row. On stock luci-theme-bootstrap — no layers — the same `*` (0,0,0)
* loses to any class selector on specificity and nobody ever noticed. That asymmetry is the whole
* bug, and it is ours: the layers are what handed a 0,0,0 selector the win (issue #8).
*
* So put the sheet back on specificity footing: re-host it into the EXISTING `theme` layer. Only
* same-layer arbitrates by specificity — which is exactly what makes bootstrap survive. Measured,
* all three placements, on the real cascade:
*
* app unlayered (today) chrome BROKEN app's own design OK
* app -> @layer theme chrome OK app's own design OK
* app -> @layer before theme chrome OK app's own design BROKEN
*
* The tempting shape is the third — give the app its own layer under the theme. Do NOT: the theme
* then beats the app at ANY specificity, including the rules it aims at its own page. OpenClash
* restyles its tabs with `#tab-header ul.cbi-tabmenu li` (1,1,2) and footstrap styles
* `ul.cbi-tabmenu li` (0,1,2) — demote the app and footstrap repaints the app author's own tabs.
* In `theme` the app keeps them, and `*` still loses to the chrome's 0,3,1. No new layer is
* declared; a re-opened `@layer theme` block appends to the one 00-header.css already names.
*
* What this deliberately does NOT fix: `base`. The app must outrank `theme` for its own page to
* work, so it sits above `base`, and `*` still wipes base's widget padding (cssdiff: `input`
* 4px->0, `.ifacebadge`, `strong` margins). That is not a regression — an unlayered `*` beats base
* today too — it is the price of the same trade, and the only way out would re-break the app.
*
* NEVER delete the sheet instead (see dedupeViewSheets below for what that cost). Re-hosting moves
* where a rule lands in the cascade; every rule still exists, so a library's "did I already import
* this?" check still finds its sheet. A <link> is DISABLED rather than removed, so an app that
* looks its own <link> back up by href still finds the element. */
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
*
* Re-hosting into the theme layer settles a fight on specificity. It cannot settle one against
* `!important`, because importance ranks ABOVE layers — measured: `* { padding: 0 !important }`
* still owns the chrome after re-hosting, and so does `#indicators { display: none !important }`.
* The only pure-CSS answer to a foreign flag is our own flag in an earlier layer (it wins — also
* measured), but that means ~550 of them, and `color`/`background` among them would beat this
* theme's OWN forced-colors block. Fixing the cascade by breaking high-contrast is not a fix.
*
* So do not out-rank the rule — put the chrome where it cannot be addressed. Appending
* `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's SUBJECT leaves it
* matching everything it used to except us. `!important` has nothing left to win.
*
* The chrome is NOT one element, and naming one is how this went wrong the first time: the fence
* said `.fs-sidebar`, which is the menu in both layouts (the bar is the same markup) — but the skip
* link is a sibling of .fs-shell and the Appearance popover hangs off <body>, so both stayed exposed
* while every test said the chrome was defended. `data-fs-chrome` is the fix: an element DECLARES
* that it is ours, where it is written (header.ut, fs-appearance.js), and the fence and the pin
* follow without being told. A future chrome root cannot forget to edit a constant in this file,
* because there is no constant naming it any more. `npm run chrome-fence` holds the three together.
*
* `:where()` is load-bearing and not cosmetic: it contributes ZERO specificity, so `*` stays 0,0,0
* and `#indicators` stays 1,0,0 and the app's rules keep their exact weight against each other and
* against the theme on its own page. A plain `:not(.fs-sidebar)` takes its argument's specificity
* and would silently re-order the app's stylesheet against itself.
*
* Only UNPINNED parts are fenced. A part pinned by the app's own name cannot reach the chrome
* anyway (proven by the same test invasiveSheet uses), so leaving it alone costs nothing and keeps
* the surgery as small as the danger.
*
* Two silent traps, both measured on the real CSSOM, both of which cost the app its rule:
* - A selector LIST must be fenced part by part. Appending to the whole `selectorText` fences only
* the last part: `*, ul` came back as `*, ul:where(…)` — `*` unfenced, chrome still exposed.
* - A pseudo-element must stay LAST. `a::after` + a tail append serialised to `a::after:where()`
* — the argument silently EATEN, leaving an empty `:where()` that matches NOTHING, and the
* setter reported success. The fence goes before the pseudo-element: `a:where(…)::after`. */
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
function fenceSelector(part) {
/* the getter always normalises a pseudo-element to `::`, incl. legacy `:before` */
const i = part.indexOf('::');
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
}
function fenceRules(rules, names) {
for (const r of rules) {
if (r.selectorText) {
const parts = selectorParts(r.selectorText);
if (parts.length && parts.some((p) => !pinnedToApp(p, names))) {
/* The setter parses the whole selector and, on one it cannot parse, does NOTHING and
* does not throw — so it is atomic: never a half-written selector, and a failure just
* leaves the rule where it already was (unfenced, i.e. today's exposure). */
try {
r.selectorText = parts
.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
}
}
if (r.cssRules) fenceRules(r.cssRules, names);
}
}
/* An @import's rules live in a sheet that is fetched separately, so they are not there the moment
* the shim is inserted — retry until they are, then fence. Bounded: a sheet that never becomes
* readable (404, cross-origin) simply stays unfenced, which is where we already were. */
function fenceImported(styleEl, names, tries) {
/* no initialiser: every path below assigns (the try, or the catch's null), so `= null`
* here was a dead store — eslint 10 puts no-useless-assignment in recommended and said so. */
let rules;
try {
const first = styleEl.sheet && styleEl.sheet.cssRules[0];
rules = first && first.styleSheet && first.styleSheet.cssRules;
} catch (e) { rules = null; }
if (rules) { fenceRules(rules, names); return; }
if (tries > 0) requestAnimationFrame(() => fenceImported(styleEl, names, tries - 1));
}
/* What a sheet IS, as text: the rules that are APPLYING, not the markup that may or may not have
* produced them. Serialised only ever to COMPARE — never re-parsed, so the serialiser cannot cost
* anyone a rule. */
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
/* ---- a <style>'s textContent is NOT its sheet, and both the wrap and the dedupe assumed it was ----
*
* Wrapping means re-setting textContent, which RE-PARSES: whatever the parse does not reproduce is
* deleted — silently, by the one fix in this file whose entire thesis is that deleting a view's CSS
* is one-way (see the head of the file). Two shapes where the text does not describe the sheet, and
* they are one question, not two:
* - an app that builds its CSS with insertRule() — an empty <style> appended first, rules pushed in
* after: the text is EMPTY while the rules apply, so the wrap writes `@layer theme {}` over a live
* sheet and every rule in it is gone.
* - a <style> carrying @import: it is invalid inside @layer and has to sit at the top of a sheet, so
* the wrapped copy comes back without it.
*
* So ask the exact question once — does re-parsing this text give back the sheet that is applying? —
* rather than enumerate the shapes that make it false; the enumeration is what missed insertRule().
* The probe is a CONSTRUCTIBLE sheet: never adopted, so nothing paints, no <head> mutation, and our
* own observer never sees it. It also drops @import (per spec), which is why that case needs no test
* of its own — the serialisations differ and the answer is already no.
*
* No probe means no answer, and the honest answer to "may I re-parse this?" when we cannot check is
* NO: the sheet keeps every rule and the fence still holds Zone 1 without it. */
let _probe = null;
function textIsSheet(el, live) {
try {
if (!_probe) _probe = new CSSStyleSheet();
_probe.replaceSync(el.textContent);
return serializeRules(_probe.cssRules) === serializeRules(live);
} catch (e) { return false; }
}
/* Sheets taken out of the cascade FOR GOOD — the re-hosted <link> originals. Kept because a
* silenced sheet is still an ELEMENT in the document that still answers `cssRules`, so every later
* ask re-judges it as invasive; documentPoisoned() explains what that cost. */
const _silenced = new WeakSet();
/* Take a re-hosted <link> out of the cascade — and MEAN IT.
*
* `el.disabled = true` alone does not do it, and the failure is silent and total. The IDL attribute
* forwards to the ELEMENT's own flag; the thing that decides whether the CSS paints is
* `el.sheet.disabled`, and a <link> that is still LOADING has no `.sheet` at all. Every runtime
* injection is in exactly that state when <head>'s observer hands it here — which is the case this
* module exists for (`luci-app-banip` and `luci-app-adblock` append their <link> at module eval,
* openclash prints one from its template). So the assignment landed on nothing, the sheet came up
* ENABLED when the bytes arrived, and the app's ORIGINAL, UNFENCED CSS went on painting beside the
* fenced @import shim.
*
* Measured on the router with `* { padding: 0 !important }` behind a runtime <link>: `el.disabled`
* read back `true`, `el.sheet.disabled` was `false`, and 95 of the 338 chrome elements were
* flattened — the sidebar's own padding went 0px 88px -> 0px — while the shim's fenced copy sat
* there matching nothing. Setting `el.sheet.disabled = true` by hand restored all of it. The
* documented "openclash: 47 damaged -> 0" holds only because THAT sheet is server-rendered and has
* therefore already loaded by the time the immediate pass sees it.
*/
function silence(el) {
_silenced.add(el);
el.disabled = true;
if (el.sheet) { el.sheet.disabled = true; return; }
/* no sheet yet: re-assert once there is one. `once` — the element is marked fsLayered, so this
* never re-arms, and a sheet that never loads has nothing to silence. */
el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* ---- PAGE OWNERSHIP: contain an invasive sheet instead of spending the document ----
*
* A foreign sheet is injected by ONE page and has no business painting any other. Before this, an
* invasive sheet made the whole document spent (documentPoisoned) and the SPA fell back to a full
* load on the way OUT — correct, and paid by ORDINARY pages: `luci-app-filemanager`, a stock app,
* lands TWO <style>s in <head>. Its HexEditor module calls `injectHexEditorCSS()` at MODULE EVAL
* and the view's own `render()` calls `insertCss()` on every arrival; both are invasive on their
* BARE selectors, not on the ones pinned to `#file-manager-container` — HexEditor declares
* `:root { --span-spacing; --clr-background; … }`, the view adds `:root`, `.cbi-page-actions`,
* `.cbi-button-save:not(.custom-save-button)` and a `td:last-child` riding as the second half of
* `#file-manager-container th:last-child, td:last-child`. `luci-app-ssclash` adds four more as the
* Ace editor initialises. Invasive by the only definition that also catches `[class] { padding: 0
* !important }`.
*
* Measured on owrt2512, 25.12.4, with ownership taken out of documentPoisoned() and put back:
* leaving either page is a FULL LOAD, 5 runs of 5, and with ownership all 5 are in place —
* medians 24 ms (filemanager) and 27 ms (ssclash). The control is a page that injects nothing
* (System -> General): in place either way. Stock LuCI's own pages inject nothing into <head> on
* 24.10/25.12 — the realtime graphs style their SVG text with an inline `style=` attribute — so
* what this costs a router is decided entirely by which apps are installed on it.
*
* Removing the sheet on the way out is NOT the fix, and it is the obvious one: an append at MODULE
* TOP LEVEL happens once, because `L.require` caches the module, so a second visit re-runs nothing
* and the page renders unstyled — HexEditor's injector above is exactly that shape, and its own
* `getElementById('hexeditor-styles')` guard never gets a second chance to notice the element is
* gone, because nothing calls it again. A sheet injected from `render()` would survive removal, but
* the two cases are indistinguishable from here and only one mechanism can be right for both.
* Disabling is reversible, which is the whole difference.
*
* OWNER = body[data-page] when the sheet was re-hosted. The order that makes this sound is in
* fs-router.js: it stamps data-page (line ~371) BEFORE require()ing the view class (~450), so at the
* moment a view module evaluates and appends its <style>, the attribute already names ITS page. On a
* full load the server stamped it. Either way "now" is the sheet's own page.
*
* Recorded on the element that PAINTS, never on the one that was permanently silenced: for a <link>
* that is the @import shim, and re-enabling the original instead would undo silence() and put the
* app's unfenced CSS back over the chrome — the measured 95-of-338 flattening.
*
* THE OWNER IS THE APP, NOT THE PAGE, and that is not a guess — per-page was written first and
* swept: `luci-app-zapret2` has three pages that share ONE injected <style>
* (`.label-status { … !important }`), so it was owned by whichever loaded first and arrived DARK on
* the other two. Per-page ownership silently un-styles any app whose pages share an injector, which
* is a whole class of app, not a corner. `admin/<group>/<app>` — the first three dispatch segments —
* is the smallest key that keeps an app's own pages together while still blocking the leak this
* exists to block: onto OTHER apps and onto stock pages. The sweep is
* `tools/...`-less on purpose (it needs a live router); re-run it against a router with third-party
* apps after touching this, and look for a sheet that a full load has and an SPA arrival does not.
*
* Segments, never the dash-joined `data-page`: a dispatch segment may itself contain a dash
* (`admin/system/package-manager`), so splitting the attribute on '-' would cut inside a name. */
const _owner = new WeakMap();
const APP_DEPTH = 3;
/* The router hands this over on every navigation (it holds the resolved segments). Until it does —
* the initial full load — ask the SERVER which page it dispatched to. */
let _curKey = null;
function appKey(segs) {
return (segs || []).slice(0, APP_DEPTH).join('/');
}
/* ---- THE URL IS NOT THE PAGE, and a sheet keyed on the URL is a sheet that dies ----
*
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to; the address bar holds what
* was ASKED for, and LuCI's dispatcher walks a node down to its firstchild without rewriting it.
* Two shapes of that, both ordinary:
*
* /cgi-bin/luci/admin/status -> admin/status/overview the Status menu's OWN link
* /cgi-bin/luci/ -> admin/status/overview the landing page, on a router with no
* luci-mod-dashboard
*
* The URL says `admin/status` and `''`; the router, one navigation later, hands
* scopeToCurrentPage() the RESOLVED `admin/status/overview`. Those keys can never match, so the
* first SPA navigation away from such a page disables the sheets that page owns — and
* rehostIntoThemeLayer() has already silenced the app's original <link> for good, so nothing
* paints them again for the life of the document. A full-load key that is wrong is worse than no
* key at all: it is a sheet that works until you navigate.
*
* Measured on owrt2512 with luci-app-mwan3, whose status include injects
* `#mwan3-service-status > .alert-message { display:inline-block; width:15rem; … }`: full load on
* /admin/status renders the card as `inline-block 240px 96px`; System -> General and back leaves
* it `block 966px` — every interface card on the Overview stacked full-width. Keyed on the
* dispatch path: 240px before and after. */
function currentKey() {
if (_curKey !== null) return _curKey;
const dp = L.env && L.env.dispatchpath;
if (dp && dp.length) return appKey(dp);
/* no env to read (a document that never got the bootstrap): the URL is all there is */
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
return appKey(p ? p.split('/') : []);
}
/* Both halves, for the reason silence() documents: el.disabled is the ELEMENT's flag and
* el.sheet.disabled is what decides whether the CSS paints, and a still-loading <link> has no
* .sheet for the assignment to reach. */
function setEnabled(el, on) {
el.disabled = !on;
if (el.sheet) el.sheet.disabled = !on;
else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
}
/* Called by the router right after it stamps data-page, with the RESOLVED segments. Only sheets we
* OWN are touched: a clean sheet is harmless and an invasive one we could not attribute still
* poisons the document, so it keeps the full-load path rather than being silently disabled on its
* own page. */
function scopeToCurrentPage(segs) {
if (segs) _curKey = appKey(segs);
const key = currentKey();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (!outlivesPage(el) || !_owner.has(el)) return;
setEnabled(el, _owner.get(el) === key);
});
}
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
if (el.tagName === 'LINK') {
/* A <link>'s rules cannot be moved into a layer in place — but an @import CAN name one.
* The href is already absolute and same-origin, so the re-fetch is a cache hit. */
const s = document.createElement('style');
s.dataset.fsLayered = '1';
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme);';
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, currentKey()); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, 60); /* ~1s of frames; a cache hit lands on the first */
return;
}
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
/* No rules yet: nothing to re-host, nothing to fence — and, crucially, nothing to MARK. An app
* that appends an empty <style> and fills it with insertRule() arrives here first; marking it
* handled now would leave the sheet it is about to build unfenced for the life of the document. */
if (!rules || !rules.length) return;
/* Handled — and never twice. fenceRules() is NOT idempotent: pinnedToApp() strips a functional
* pseudo-class before looking for the app's own name, so an already-fenced selector reads as
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, currentKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
* and it is still FENCED below: the fence is pure CSSOM, needs no re-parse, and is the half that
* answers `!important` anyway.
*
* Layer by TEXT, fence by CSSOM, in that order: re-setting textContent re-parses the sheet and
* would throw away any selector we had already rewritten. A <style>'s url()s resolve against the
* document either way, so re-parsing costs nothing here — which is exactly NOT true of a <link>
* (measured: cssText serialises `url("img.png")` still relative, so inlining a linked sheet would
* silently re-base every image and font in it. That is why a <link> keeps its @import). */
if (textIsSheet(el, rules)) {
origText.set(el, el.textContent); /* dedupeViewSheets keys on this — see there */
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
}
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
}
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one. If
* cascade.css cannot be read we cannot classify, so re-host NOTHING and leave the cascade exactly
* as it is: documentPoisoned() already fails every sheet to the slow path in that case, and
* silently demoting an app we could not judge is the one move with no way back for its author. */
function rehostInvasiveSheets() {
const universe = themeNames(); /* {names, props} — NOT a bare Set; the fence wants .names */
if (!universe) return;
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (el.dataset.fsLayered) return;
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
});
}
/* ---- the one thing that IS safe to remove: a byte-identical second copy ----
*
* Not deleting view CSS costs where an app injects on EVERY render: `luci-app-podkop` calls
* injectGlobalStyles() from render() (4 KB, no guard) and `luci-app-mosdns` re-appends three
* CodeMirror <link>s, so every SPA re-visit adds a copy that never stops being parsed. Dropping an
* EXACT duplicate cannot break anyone, for the reason the sweep failed: the rules do not go away —
* the surviving copy is byte-identical, and a library's "have I already imported this?" check (what
* ACE died on) still finds its sheet. Keep the FIRST copy: it is what any handle the app kept
* points at.
*
* Key a <style> on what its APP wrote, not on what stands in the DOM: re-hosting rewrites the text
* (@layer wrapper), so a wrapped first copy and podkop's next byte-identical injection would no
* longer match — the duplicate detector would go quiet exactly where it earns its keep, and the
* copies would pile up again. A <link> keys on href, which re-hosting leaves alone.
*
* Re-hosting must therefore run BEFORE this, never after: a fresh copy of a <link> is appended to
* <head>, which is EARLIER in document order than a template's <link> down in .fs-content, so the
* "keep the first" rule would keep the raw copy, drop the re-hosted one, and strand its @import
* shim — the chrome breaking again while shims pile up once per render (measured; that is the leak
* this function exists to prevent). Re-host first and both copies are equivalent by the time they
* are compared, so whichever survives is already layered and the loser's shim is a byte-identical
* duplicate this same pass collapses. */
function sheetKey(el) {
if (el.tagName === 'LINK') return 'LINK|' + el.href;
const t = origText.get(el);
if (t !== undefined) return 'STYLE|' + t;
/* Not wrapped, so no original was kept — and this sheet's textContent may not BE its sheet (see
* textIsSheet): every insertRule-built <style> has an empty one, so keying on the text gave them
* all the same key and the second one was REMOVED as a "duplicate" of a sheet it shares nothing
* with. That is the deletion this file exists to prevent, dressed as a dedupe. Key on what is
* applying instead. */
let rules;
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return null; }
/* A sheet with no rules is a duplicate of nothing — and it is very likely a <style> an app has
* appended but not yet filled: removing it strands the handle it is about to insertRule through. */
if (!rules || !rules.length) return null;
return 'STYLE|' + serializeRules(rules);
}
function dedupeViewSheets() {
const seen = new Set();
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (!outlivesPage(el)) return;
const key = sheetKey(el);
if (key === null) return;
if (seen.has(key)) el.remove();
else seen.add(key);
});
}
/* ---- THE LAYER ORDER IS A DOCUMENT-WIDE FACT, AND A SHEET INSERTED FIRST CAN REWRITE IT ----
*
* `@layer tokens, base, theme, page;` in 00-header.css is what makes theme beat base. That
* statement only holds while cascade.css is the FIRST sheet in the document to name a layer — the
* order is fixed by first appearance, and an earlier sheet naming `theme` makes theme the FIRST
* layer, i.e. the WEAKEST. Every later name is appended after it, so the whole cascade inverts:
* `tokens, base, page` end up above `theme` and base's `* { padding: 0 }` wins over the chrome's
* own rules. Measured on the router: `.fs-content` padding 24px/28px -> 0, and the top bar, the
* tabs and every button flattened with it.
*
* Which is exactly what re-hosting an app's sheet into `@layer theme` can cause, because WHERE the
* app put its <style> is the app's choice. Ace (shipped by `luci-app-ssclash`, and by any package
* that embeds an editor) calls `dom.importCssString`, which inserts its <style> as the FIRST CHILD
* of <head> — ahead of cascade.css. Wrapping that sheet, as the fence must, moved the first mention
* of `theme` to the top of the document and took the theme layer down with it. It is lazy, too:
* Ace adds more of those sheets on first hover, which is the "reloading fixes it until I touch
* anything" in the report.
*
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
* (moving an existing one does NOT — measured, both ways): re-declare the canonical order from a
* fresh <style> placed first in <head>. Cheap, idempotent, and it states the same order 00-header.css
* does — one more copy of it, which is why the text is derived from nothing and simply repeated
* here, in the one other place that can see the whole document. */
const LAYER_ORDER = '@layer tokens, base, theme, page;';
let _layerStmt = null;
function reassertLayerOrder() {
const head = document.head;
if (!head) return;
/* The anchor is whichever of ours comes first: cascade.css, or the statement a previous pass
* already put in front of it. Only a sheet ahead of THAT can have named a layer before we did —
* on a page with no foreign sheet this is one querySelectorAll and out. */
const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
.find((l) => (/\/cascade\.css/).test(l.href || ''));
if (!own) return;
const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
.some((el) => el !== anchor && el !== own &&
(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
if (!ahead) return;
/* A FRESH element every time: re-inserting the same node is a move, and a move does not re-run
* the ordering (measured — the inverted document stayed inverted). Dropping the previous one
* keeps this at one spare <style> per document however many sheets an app injects. */
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
head.insertBefore(_layerStmt, head.firstChild);
}
/* Watch <head> rather than deduping on navigation: the copy arrives too late otherwise — podkop
* injects from its render(), which resolves AFTER the router's require() callback, so a nav-time
* sweep left the document permanently carrying one stale duplicate (bounded, never zero). The
* observer collapses the copy in the microtask it appears in. It cannot loop: a removal produces a
* mutation with no ADDED nodes, and the handler bails unless a stylesheet was added.
*
* The immediate pass is not the observer's job and cannot be: a legacy Lua page's <link> is in the
* SERVER's HTML (openclash prints it into .fs-content), so it is parsed and applying long before
* this module is even fetched — there is no mutation to observe. It is re-hosted on the first pass
* instead, which costs a brief flash of unstyled chrome before the modules land; the page then
* settles correct, where today it stays broken. Runtime injections land in <head> (podkop, banip,
* adblock, the file manager), which is what the observer watches — deliberately not the whole
* document, since LuCI's poll rewrites content every second and this would fire on every tick. */
function watchViewSheets() {
/* Dedupe the immediate pass too, in the observer's order (re-host strictly first — see there).
* It used to re-host only, which left the server-rendered duplicate — the one case this pass
* exists for — uncollapsed for the life of the document. Measured with the real
* luci-app-openclash: it prints the same <link href=oc.css> from three templates, so its
* Overwrite Settings page carried two identical links and the two @import shims we make for
* them, parsing 117 KB of CSS twice. The observer never fires for either: both are in the
* SERVER's HTML, so there is no mutation to see. */
rehostInvasiveSheets();
dedupeViewSheets();
reassertLayerOrder(); /* strictly AFTER the re-host: it is the wrap that can invert the order */
const mo = new MutationObserver((muts) => {
for (const m of muts)
for (const n of m.addedNodes)
if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
/* `continue`, not `return`: our own statement can share a batch with the very
* sheet that made it necessary, and bailing on the batch would skip that one. */
if (n === _layerStmt) continue;
rehostInvasiveSheets(); /* strictly before the dedupe — see there */
dedupeViewSheets();
reassertLayerOrder();
return;
}
});
mo.observe(document.head, { childList: true });
/* …and <body>, because `document.head.appendChild` is a CONVENTION, not a rule. An app that
* appends its <style> to <body> (or to documentElement) after chrome init was seen by nothing:
* the immediate pass had already run and the mutation was not under observation. Measured with
* `* { padding: 0 !important }` in a body-appended <style>: 95 of 338 chrome elements flattened
* and the sheet never marked. documentPoisoned() still saw it, so the SPA fell back to full
* loads — the page you are ON stayed broken, which is the half that matters.
*
* childList WITHOUT subtree, exactly as for <head>: this fires only for DIRECT children of
* <body>, and LuCI's poll rewrites content inside #view — a descendant — so the per-tick cost
* the head-only choice was protecting stays zero. */
mo.observe(document.body, { childList: true });
}
return baseclass.extend({
documentCarries,
documentPoisoned,
scopeToCurrentPage,
watchViewSheets
});