mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 02:44:57 +08:00
803 lines
42 KiB
JavaScript
803 lines
42 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
|
|
/* ---- a view's injected CSS: never DELETE it; leave a poisoned document to a real load ----
|
|
*
|
|
* A view's <style> dies with the document on a full load, but SPA nav never reloads, so it restyles
|
|
* every page after. `luci-app-filemanager` injects
|
|
* `.cbi-button-save:not(.custom-save-button) { display: none !important }` — unlayered and
|
|
* important, outranking every cascade layer, so one visit removes Save/Reset from every config page.
|
|
*
|
|
* Deleting such sheets on navigation is not the answer: a stylesheet only returns if its injector
|
|
* runs again, and a library importing CSS at module eval never does (the module is cached for the
|
|
* life of the document). ACE's 14 KB editor sheet is imported once, and after a sweep its editor
|
|
* came back as a black rectangle two million pixels tall. Deletion is silently one-way.
|
|
*
|
|
* So: a sheet matching only its own app's widgets is inert elsewhere and is left alone. One
|
|
* reaching into the widget universe the THEME styles can repaint any page, which spends the
|
|
* document — the router then refuses to hand it to another view and falls back to a real page load,
|
|
* trading speed and never correctness (fs-router.js owns that refusal; this module answers the
|
|
* question).
|
|
*
|
|
* `invasiveSheet()` is that test, and its universe is read back from cascade.css itself rather than
|
|
* from a hand-written list, so it tracks the theme; 0.3 ms per nav. Exempt: anything the server
|
|
* marked `[data-fs-shell]` (partials/head.ut) and anything inside `#view`, which dies with the
|
|
* content swap. LuCI core injects no <style> at runtime (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: the vocabulary the invasive-sheet test
|
|
* is written in. One copy, because widening it in the harvester alone puts names into `names` that
|
|
* pinnedToApp() can never match, and a selector that does reach the chrome then reads as pinned and
|
|
* is left unfenced.
|
|
*
|
|
* Shared safely because every use is String.match(): a /g regex is stateful under .test(), while
|
|
* [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 would otherwise read it as syntax ----
|
|
*
|
|
* `[title="a,b"]` is one selector part carrying a comma, `[href*="("]` one attribute carrying an
|
|
* unbalanced paren, `[data-x=".foo"]` names no class at all. Read literally each scanner gets a
|
|
* different wrong answer: selectorParts() splits the part in two and the tail reads as unpinned,
|
|
* fenceRules() rejoins it as `[title="a, b"]` and silently rewrites the app's own selector, and
|
|
* stripPseudoArgs() drives `depth` into a hole and eats the rest of the selector.
|
|
*
|
|
* One masker answers it for all three. It replaces the CONTENT of every quoted string with spaces,
|
|
* length-preserving 1:1, so a scanner can read the mask and still slice the ORIGINAL — the fence
|
|
* must write back the app's own bytes. 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, and dedupeViewSheets keys on the
|
|
* original — otherwise the app's next identical copy stops looking like a duplicate */
|
|
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() masks, so it could never
|
|
* match — see NAME_RE */
|
|
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 must exist for it to match, so
|
|
* it can reach neither another page nor our chrome. A part made entirely of names the theme knows
|
|
* (`*`, `.nav`, `ul.nav > li > a`) has nothing pinning it anywhere.
|
|
*
|
|
* Functional pseudo-class arguments are stripped before looking for the pin, which is the whole
|
|
* difference between podkop and the file manager: `.cbi-button-save:not(.custom-save-button)` names
|
|
* an app class inside a NEGATION, so it excludes the app's markup rather than requiring it.
|
|
*
|
|
* Shared by invasiveSheet() and fenceRules(), which must agree by construction: a part able to
|
|
* reach another page is exactly a part able to reach the chrome. */
|
|
/* Split a selector list on its TOP-LEVEL commas. `String.split(',')` cannot: `:not(.a, .b)` is one
|
|
* part carrying a comma, and both halves then reach pinnedToApp() as garbage — the tail keeps a
|
|
* visible app name, so the file manager's motivating rule reads as pinned and is neither judged nor
|
|
* fenced.
|
|
*
|
|
* Scans the mask and slices the original (see maskStrings): a comma inside `[title="a,b"]` is a
|
|
* character in a value, and fenceRules() joins the parts 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. A regex cannot: stopping at the
|
|
* first `)` leaves `:not(:is(.app))` as a stray paren plus a `.app` that looks like a pin.
|
|
*
|
|
* Works on the mask, so a paren inside `[href*="("]` cannot drive `depth` into a hole and a `.foo`
|
|
* inside an attribute value cannot read as the app's own pin. The output is read by NAME_RE and
|
|
* never written back to the CSSOM. */
|
|
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`, `*`) cannot touch us if none of its DECLARATIONS
|
|
* can: a custom property this theme never reads is inert wherever it lands, which is the difference
|
|
* between an app costing a full page load and not.
|
|
*
|
|
* Still invasive: any standard property on a bare selector (`:root { color-scheme: light dark }`
|
|
* re-points every UA widget at the OS preference), and any custom property the theme reads — the
|
|
* private `--fs-*` tier exists so an app writing `--accent` on `:root` cannot repaint us. */
|
|
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 must be: once 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.
|
|
*
|
|
* Only `true` is kept. A clean sheet can still grow hostile rules — an app building its CSS with
|
|
* insertRule() has an empty sheet the first time we look — so a clean verdict stays provisional. */
|
|
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, so
|
|
* judgeSheet's "unreadable -> invasive" default fires for every linked app stylesheet the
|
|
* instant the observer sees it; remembering that turns a benign sheet into a permanently spent
|
|
* document and every navigation from that page into a full load.
|
|
*
|
|
* Re-taking is safe and does not break the rule above, because the cases do not overlap: a
|
|
* <style> in the document always has a sheet, so its verdict is cached when taken, and a <link>
|
|
* is never edited (the element is disabled and the href re-imported), so a later read still sees
|
|
* the app's untouched CSS.
|
|
*
|
|
* The conservative half is preserved: `v` is returned as taken, so an unreadable sheet is still
|
|
* fenced on sight. 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 pinned inside the
|
|
* app's own markup? A part with no such pin matches the same widgets everywhere,
|
|
* and that is what invasive means.
|
|
*
|
|
* Do not ask "does it name anything the theme styles?" first: a pin is a name the
|
|
* theme does NOT know, so "names nothing of ours" reads as pinned when it often
|
|
* means the opposite — `*:not(#zzz)` and `[class]` are both unpinned and both match
|
|
* the whole document, and the sheet was judged clean while flattening 95 of 338
|
|
* chrome elements. */
|
|
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, including our own re-hosting shim. An unreadable import is 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: `luci-app-banip` and `luci-app-adblock` append a <link> to <head> at
|
|
* module eval styling stock widgets, unlayered, on every page. A <link> inside the view tree 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,
|
|
* and the answer is where the element sits: a sheet inside the view tree dies with the swap, so it
|
|
* can neither poison the next page, nor need scoping, nor be a duplicate worth removing. Named
|
|
* because `!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` — already carried by this document in a form that SURVIVES a
|
|
* swap? The router asks before committing a client navigation, since only a server render emits
|
|
* that <link> and a page whose stylesheet is missing must arrive by full load (fs-router.js). A
|
|
* link inside #view does not count: it is about to be deleted with the view.
|
|
*
|
|
* Whole path, never a suffix. head.ut prints `{{ resource }}/{{ dispatched.css }}?v=…` from the
|
|
* same base value the runtime holds, so the server's href is reconstructable rather than guessable
|
|
* — only the cache key comes off. A suffix match would let a third-party node declaring
|
|
* `"css": "custom.css"` read as already-carried on the strength of `luci-app-adblock`'s own
|
|
* custom.css, and the router would swap into a page whose stylesheet was never linked.
|
|
*
|
|
* Equality also fails in the direction that only costs speed: L.path() drops a path containing
|
|
* characters it does not allow (no `+ ~ ( ) @ ! ' $ &`, nothing non-ASCII), while head.ut
|
|
* interpolates `css` raw, so such a node's `want` collapses to the bare base, matches nothing, and
|
|
* every entry into that page is a full load. No in-tree node sets `css`, so the shape is
|
|
* third-party only. */
|
|
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. One we could not attribute (an @import at the top, a sheet built with
|
|
* insertRule(), anything unreadable) still spends the document, which is the conservative half.
|
|
*
|
|
* A SILENCED sheet is contained too. Re-hosting a <link> owns the @import shim and silences the
|
|
* original for good, but the original stays in the document and a disabled sheet still answers
|
|
* `cssRules` — counting it would re-judge it invasive on every ask, owned by nobody, and turn the
|
|
* SPA router off for the life of the document on exactly the apps this module was written for.
|
|
*
|
|
* Sound because `el.sheet.disabled = true` is what decides whether CSS paints (see silence()) and
|
|
* nothing re-enables it: scopeToCurrentPage() only touches sheets in `_owner`, and the silenced
|
|
* original is deliberately not one. */
|
|
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, where 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 `* { margin: 0; padding: 0 }` owns the
|
|
* chrome outright — on a layer-less theme the same `*` (0,0,0) loses to any class selector. The
|
|
* layers are what hand it the win (issue #8).
|
|
*
|
|
* So put the sheet back on specificity footing by re-hosting it into the EXISTING `theme` layer;
|
|
* only same-layer arbitrates by specificity. Measured 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
|
|
*
|
|
* Do not take the third shape: the theme would then beat the app at any specificity, including the
|
|
* rules the app aims at its own page (openclash's `#tab-header ul.cbi-tabmenu li` against our
|
|
* `ul.cbi-tabmenu li`). 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 names.
|
|
*
|
|
* 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. An unlayered `*` beats base
|
|
* today too, and the only way out would re-break the app.
|
|
*
|
|
* Never delete the sheet instead. Re-hosting moves where a rule lands in the cascade and 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> up by href still finds it. */
|
|
/* ---- the fence: the chrome is ours, so make a foreign rule unable to MATCH it ----
|
|
*
|
|
* Re-hosting settles a fight on specificity but not one against `!important`, which ranks above
|
|
* layers. The only pure-CSS answer to a foreign flag is our own flag in an earlier layer — ~550 of
|
|
* them, and the `color`/`background` ones would beat this theme's own forced-colors block.
|
|
*
|
|
* 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 except us, and `!important` has nothing left to win.
|
|
*
|
|
* The chrome is not one element, so the fence names no element: naming `.fs-sidebar` left the skip
|
|
* link and the search overlay exposed while every test said the chrome was defended. With
|
|
* `data-fs-chrome` an element declares that it is ours where it is written, and the fence and the
|
|
* pin follow. `npm run chrome-fence` holds the three together.
|
|
*
|
|
* `:where()` is load-bearing: it contributes zero specificity, so 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 re-order the app's stylesheet against itself.
|
|
*
|
|
* Only unpinned parts are fenced: a pinned part cannot reach the chrome anyway.
|
|
*
|
|
* Two silent traps, 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, leaving `*, ul` as `*, ul:where(…)`;
|
|
* - a pseudo-element must stay last — `a::after` plus a tail append serialises to
|
|
* `a::after:where()`, the argument silently eaten, matching nothing, and the setter reports
|
|
* success. The fence goes before it: `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 `::`, legacy `:before` included. The split
|
|
* point is found on the mask and sliced out of the original, because `::` is legal inside a
|
|
* quoted attribute value (an IPv6 literal): read raw, the fence lands inside the quotes and the
|
|
* app's rule comes back matching a value it never wrote, as valid CSS the setter accepts. */
|
|
const i = maskStrings(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 a failure leaves the rule unfenced rather than
|
|
* half-written */
|
|
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 separately fetched sheet, so they are not there when the shim is
|
|
* inserted: retry until they are, then fence. A sheet that never becomes readable stays unfenced,
|
|
* which is where we already were.
|
|
*
|
|
* The bound is a deadline in ms, not a frame count — 60 frames is 1 s at 60 Hz and 2 s on a 30 Hz
|
|
* panel, so the budget would move with the display. Frames remain the retry tick, since a cache hit
|
|
* lands on the first one. */
|
|
function fenceImported(styleEl, names, until) {
|
|
/* no initialiser: every path below assigns, so `= null` would be a dead store
|
|
* (no-useless-assignment) */
|
|
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 (Date.now() < until) { requestAnimationFrame(() => fenceImported(styleEl, names, until)); return; }
|
|
/* Giving up is reportable: the irreversible half already happened — the app's original is
|
|
* silenced and the shim owns the page — so a fence that never lands leaves the app's rules
|
|
* reaching the chrome while every later pass skips the sheet as handled. rAF does not fire in a
|
|
* background tab, so a page opened in one can pass the deadline without a single retry. */
|
|
console.error('footstrap: could not read the re-hosted @import within the deadline — the sheet '
|
|
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
|
|
}
|
|
|
|
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
|
|
* them. Serialised only to compare, never re-parsed. */
|
|
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
|
|
|
|
/* ---- a <style>'s textContent is NOT its sheet ----
|
|
*
|
|
* Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
|
|
* deleted — by the one fix in this file whose thesis is that deleting a view's CSS is one-way. Two
|
|
* shapes where the text does not describe the sheet: a <style> filled by insertRule() (the text is
|
|
* empty while the rules apply, so the wrap writes `@layer theme {}` over a live sheet), and one
|
|
* carrying @import (invalid inside @layer, so the wrapped copy comes back without it).
|
|
*
|
|
* So ask the exact question — does re-parsing this text give back the sheet that is applying? —
|
|
* rather than enumerate the shapes, which is what missed insertRule(). The probe is a constructible
|
|
* sheet: never adopted, so nothing paints and no observer sees it. It also drops @import per spec,
|
|
* so that case needs no test of its own.
|
|
*
|
|
* No probe means no answer, and the honest answer to "may I re-parse this?" is then no: the sheet
|
|
* keeps every rule and the fence still holds the chrome without the wrap. */
|
|
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. Remembered because a
|
|
* silenced sheet is still an element that answers `cssRules`, so every later ask re-judges it
|
|
* invasive; see documentPoisoned(). */
|
|
const _silenced = new WeakSet();
|
|
|
|
/* Take a re-hosted <link> out of the cascade, and mean it.
|
|
*
|
|
* `el.disabled = true` alone does not: the IDL attribute forwards to the ELEMENT's flag, while
|
|
* `el.sheet.disabled` is what decides whether the CSS paints — and a <link> that is still loading
|
|
* has no `.sheet` for the assignment to reach. Every runtime injection is in that state when the
|
|
* observer hands it here, so the sheet comes up enabled when the bytes arrive and the app's
|
|
* original, unfenced CSS paints beside the fenced shim (95 of 338 chrome elements flattened). */
|
|
function silence(el) {
|
|
_silenced.add(el);
|
|
setEnabled(el, false);
|
|
}
|
|
|
|
/* ---- 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. Treating every
|
|
* invasive sheet as spending the document is correct but is paid by ordinary pages: stock
|
|
* `luci-app-filemanager` lands two <style>s in <head>, both invasive on their bare selectors, and
|
|
* `luci-app-ssclash` adds four more as the Ace editor initialises. With ownership, leaving either
|
|
* page stays an in-place SPA navigation (medians 24 ms and 27 ms) instead of a full load.
|
|
*
|
|
* Removing the sheet on the way out is the obvious fix and the wrong one: an append at module top
|
|
* level happens once, because `L.require` caches the module, so a second visit renders unstyled.
|
|
* Disabling is reversible, which is the whole difference.
|
|
*
|
|
* Owner = the page in `body[data-page]` when the sheet was re-hosted. fs-router's navigate() stamps
|
|
* data-page BEFORE it require()s the view class, so when a view module evaluates and appends its
|
|
* <style> the attribute already names its page; on a full load the server stamped it.
|
|
*
|
|
* Recorded on the element that PAINTS, never on the permanently silenced one: for a <link> that is
|
|
* the @import shim, and re-enabling the original would undo silence() and put the app's unfenced
|
|
* CSS back over the chrome.
|
|
*
|
|
* The owner is the APP, not the page: an app whose pages share one injected <style> would have it
|
|
* owned by whichever page loaded first and arrive dark on the others. `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 onto other apps and onto stock pages.
|
|
*
|
|
* Segments, never the dash-joined `data-page`: a dispatch segment may 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; 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 ----
|
|
*
|
|
* `L.env.dispatchpath` is the leaf the SERVER resolved this request to, while the address bar holds
|
|
* what was asked for: LuCI's dispatcher walks a node down to its firstchild without rewriting the
|
|
* URL, so `/cgi-bin/luci/admin/status` and `/cgi-bin/luci/` both dispatch to
|
|
* `admin/status/overview`. Keyed on the URL, the first SPA navigation away disables the sheets that
|
|
* page owns — and the app's original <link> is already silenced for good, so nothing paints them
|
|
* again for the life of the document. */
|
|
/* Who a sheet injected right now belongs to, when that is not the page the chrome is showing.
|
|
*
|
|
* A require in flight cannot be stopped: click a page whose module injects CSS, click away before
|
|
* it lands, and the <style> appears after the router has stamped data-page for the page that
|
|
* superseded it. Credited to currentKey() it is bound to the wrong page for the life of the
|
|
* document — disabled on its own page and enabled on one it has no business painting. So the router
|
|
* names the owner for the duration of such a require (fs-router.js), and this is that hint.
|
|
*
|
|
* One slot, stamped with the navigation that set it. The router only names an owner for a require
|
|
* that has yet to evaluate its module. Two cold requires can still overlap, and then the newer one
|
|
* wins the slot — it is the page the user is looking at, and crediting its sheet to the superseded
|
|
* page would leave the visible page unpainted. The generation stamp keeps the older require's
|
|
* `.finally` from clearing a slot the newer one now holds.
|
|
*
|
|
* Nothing better is available: LuCI evaluates a view module inside `eval()` in its own require(),
|
|
* so nothing observable says which module is running when a <style> appears. The remaining hole is
|
|
* two cold requires overlapping. */
|
|
let _ownerHint = null;
|
|
let _ownerGen = -1;
|
|
function attributeTo(segs, gen) {
|
|
/* a stale require letting go of a slot somebody else now holds: leave it alone */
|
|
if (segs == null && gen !== _ownerGen) return;
|
|
_ownerHint = (segs == null) ? null : appKey(segs);
|
|
_ownerGen = (segs == null) ? -1 : gen;
|
|
}
|
|
|
|
function ownerKey() {
|
|
return (_ownerHint !== null) ? _ownerHint : currentKey();
|
|
}
|
|
|
|
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() gives: el.disabled is the element's flag, el.sheet.disabled
|
|
* decides whether the CSS paints, and a still-loading <link> has no .sheet for the assignment to
|
|
* reach — so a switch-off re-asserts once the bytes arrive. `once`, because the element is marked
|
|
* fsLayered by then and a sheet that never loads has nothing to silence. */
|
|
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 and keeps the full-load path rather than being disabled on its own page.
|
|
*
|
|
* `keep` is the page still ON SCREEN. A client navigation has a window in which two pages are real
|
|
* — the incoming one renders into a hidden stage while the outgoing one is still being read — and
|
|
* scoping is needed before that staged render, since a view must not measure itself through a sheet
|
|
* that does not own its page. Doing both halves then would strip the outgoing page's stylesheet off
|
|
* content the user is looking at, so the router calls this twice: with `keep` before the staged
|
|
* render, and with nothing at the swap. */
|
|
function scopeToCurrentPage(segs, keep) {
|
|
if (segs) _curKey = appKey(segs);
|
|
const key = currentKey();
|
|
const spared = (keep && keep.length) ? appKey(keep) : null;
|
|
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
|
if (!outlivesPage(el) || !_owner.has(el)) return;
|
|
const owner = _owner.get(el);
|
|
if (spared !== null && owner === spared && owner !== key) return;
|
|
setEnabled(el, owner === key);
|
|
});
|
|
}
|
|
|
|
/* Take a sheet and scope it in the same breath. scopeToCurrentPage() runs on NAVIGATION, so it
|
|
* only sees sheets that were already here; one arriving afterwards is scoped by nobody until the
|
|
* next click — which is exactly the cold-require case the owner hint exists for, where the sheet
|
|
* paints a page it does not belong to in the meantime.
|
|
*
|
|
* `ownerKey()` is the page the sheet belongs to and `currentKey()` the page on screen; on every
|
|
* ordinary arrival they are the same string and this is a no-op. */
|
|
function claimOwner(el) {
|
|
const key = ownerKey();
|
|
_owner.set(el, key);
|
|
if (outlivesPage(el)) setEnabled(el, key === currentKey());
|
|
}
|
|
|
|
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 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);
|
|
claimOwner(s);
|
|
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
|
|
return;
|
|
}
|
|
|
|
let rules;
|
|
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
|
/* No rules yet: nothing to re-host, nothing to fence and nothing to MARK. An app that appends an
|
|
* empty <style> and fills it with insertRule() arrives here first, and marking it handled 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, because pinnedToApp() strips a
|
|
* functional pseudo-class before looking for the app's name, so an already-fenced selector reads
|
|
* as unpinned again and a second pass appends a second fence. */
|
|
el.dataset.fsLayered = '1';
|
|
|
|
/* Wrap only if the text still IS the sheet (textIsSheet). When it is not, the sheet stays
|
|
* unlayered rather than lose rules, and 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 and would throw
|
|
* away any selector already rewritten. A <style>'s url()s resolve against the document either
|
|
* way, which is not true of a <link> — cssText serialises `url("img.png")` still relative, so
|
|
* inlining a linked sheet would re-base every image and font in it. */
|
|
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 */ }
|
|
/* Last, because the line above may have re-parsed the sheet: assigning textContent throws the
|
|
* old CSSStyleSheet away and the new one comes back ENABLED, so claiming before the wrap
|
|
* switches a sheet off and back on within the same call. ownerKey() is read here either way,
|
|
* while the router's hint still names the page whose module is evaluating. */
|
|
claimOwner(el); /* a <style> is re-hosted IN PLACE, so it paints itself */
|
|
}
|
|
|
|
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one, so if
|
|
* cascade.css cannot be read, re-host nothing: documentPoisoned() already fails every sheet to the
|
|
* slow path, and demoting an app we could not judge is the one move with no way back. */
|
|
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;
|
|
/* A sheet with no rules yet is not an innocent sheet: `insertRule()` produces no mutation
|
|
* record, so an app that appends an empty <style> and fills it later is judged empty here
|
|
* and never looked at again — measured, a `* { padding: 0 !important }` built that way
|
|
* flattened the chrome and stayed unfenced for the life of the document. One deferred look
|
|
* per such element is enough, and costs nothing where a <style> arrives with its rules
|
|
* already in it. */
|
|
let rules = null;
|
|
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
|
|
if (rules && !rules.length && !el.__fsRecheck) {
|
|
el.__fsRecheck = true;
|
|
window.setTimeout(() => {
|
|
let now;
|
|
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
|
if (now && now.length) rehostInvasiveSheets();
|
|
}, 2000);
|
|
}
|
|
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` injects 4 KB
|
|
* from render() with no guard, `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, because the rules do not go away — the surviving copy is byte-identical, so a library's
|
|
* "have I already imported this?" check 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,
|
|
* so a wrapped first copy and the app's next identical injection would stop matching and the copies
|
|
* would pile up again. A <link> keys on href, which re-hosting leaves alone.
|
|
*
|
|
* Re-hosting must therefore run BEFORE this: a re-hosted <link>'s shim is appended to <head>, which
|
|
* is earlier in document order than a template's <link> in .fs-content, so "keep the first" would
|
|
* keep the raw copy and strand the shim. Re-host first and both copies are equivalent by the time
|
|
* they are compared. */
|
|
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 the textContent may not BE the sheet (textIsSheet):
|
|
* every insertRule-built <style> has an empty one, so keying on the text gives them all the same
|
|
* key and removes the second as a "duplicate" of a sheet it shares nothing with. 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 is very likely a <style> an app has
|
|
* appended but not yet filled: removing it strands the handle it will 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, and it holds
|
|
* only while cascade.css is the FIRST sheet in the document to name a layer: the order is fixed by
|
|
* first appearance, so an earlier sheet naming `theme` makes theme the weakest layer and inverts
|
|
* the whole cascade — base's `* { padding: 0 }` then wins over the chrome's own rules.
|
|
*
|
|
* Re-hosting an app's sheet into `@layer theme` can cause exactly that, because where the app put
|
|
* its <style> is the app's choice: Ace inserts its <style> as the FIRST CHILD of <head>, ahead of
|
|
* cascade.css, and adds more of them on first hover.
|
|
*
|
|
* The repair is one declaration, and it works because inserting a NEW sheet re-runs the ordering
|
|
* while moving an existing one does not (measured both ways): re-declare the canonical order from
|
|
* a fresh <style> placed first in <head>. Cheap and idempotent, and the one other place that can
|
|
* see the whole document, which is why the order is repeated here rather than derived. */
|
|
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
|
|
* put in front of it — since only a sheet ahead of that can have named a layer before we did */
|
|
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. Dropping the previous one keeps this at one spare <style> per document. */
|
|
if (_layerStmt) _layerStmt.remove();
|
|
_layerStmt = document.createElement('style');
|
|
_layerStmt.textContent = LAYER_ORDER;
|
|
head.insertBefore(_layerStmt, head.firstChild);
|
|
}
|
|
|
|
/* Watch <head> rather than deduping on navigation: an app injecting from render() resolves after
|
|
* the router's require() callback, so a nav-time sweep leaves the document permanently carrying one
|
|
* stale duplicate. The observer collapses the copy in the microtask it appears in, and cannot loop
|
|
* — a removal produces a mutation with no added nodes and the handler bails.
|
|
*
|
|
* The immediate pass cannot be the observer's job: a legacy Lua page's <link> is in the SERVER's
|
|
* HTML, parsed and applying before this module is fetched, so there is no mutation to observe. It
|
|
* is re-hosted on the first pass instead, at the cost of a brief flash of unstyled chrome.
|
|
*
|
|
* <head>, deliberately not the whole document: LuCI's poll rewrites content on every tick and this
|
|
* would fire on every tick. */
|
|
function watchViewSheets() {
|
|
/* Dedupe on the immediate pass too, in the observer's order (re-host strictly first): a page
|
|
* whose server HTML prints the same <link> twice produces no mutation for the observer to see,
|
|
* so without this it carries both links and both @import shims for the life of the document —
|
|
* 117 KB of CSS parsed twice on luci-app-openclash's Overwrite Settings page. */
|
|
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 sheet
|
|
* that made it necessary */
|
|
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: a <style>
|
|
* appended to <body> after chrome init is seen by nothing otherwise — the immediate pass has
|
|
* run and the mutation is not observed — and the page the user is ON stays broken.
|
|
*
|
|
* childList without subtree, as for <head>: this fires only for direct children of <body>, while
|
|
* LuCI's poll rewrites content inside #view, a descendant. */
|
|
mo.observe(document.body, { childList: true });
|
|
}
|
|
|
|
return baseclass.extend({
|
|
attributeTo,
|
|
documentCarries,
|
|
documentPoisoned,
|
|
scopeToCurrentPage,
|
|
watchViewSheets
|
|
});
|