mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 07:03:56 +08:00
✨ Sync 2026-08-18 20:36:54
This commit is contained in:
1 parent
3e76c2c768
commit
5261ac811a
21 files changed
+935
-145
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "/cgi-bin/luci/",
|
||||
"name": "LuCI",
|
||||
"short_name": "LuCI",
|
||||
"start_url": "/cgi-bin/luci/",
|
||||
"scope": "/cgi-bin/luci/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f6f8fa",
|
||||
"theme_color": "#f6f8fa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/luci-static/footstrap/app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/luci-static/footstrap/app-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -143,16 +143,36 @@ let _sampling = false;
|
||||
* next frame's probe paid for a layout that had just been dirtied; and `touchstart` starts the
|
||||
* sampler, so a plain tap on a button bought ~24 of them.
|
||||
*
|
||||
* The answer cannot change without the room changing: the two layouts scroll different elements and
|
||||
* the switch between them is a width change, which `onResize` already watches and stamps. So the
|
||||
* verdict is cached against that stamp and the offset is then read from the scroller directly —
|
||||
* `scrollTop` on an element and `scrollY` on the window are both free. */
|
||||
let _scroller = null, _scrollerAt = -1;
|
||||
* WHAT IS ASKED IS ALSO NOT WHAT IT USED TO ASK. "Does this element currently overflow" is a
|
||||
* property of the CONTENT, and it was being memoised against a stamp that only moves when a WIDTH
|
||||
* does: open a short page (no overflow, answer cached as "the window scrolls"), navigate to a tall
|
||||
* one — `#view` keeps its identity and its width, so nothing bumps the stamp — and every pass went
|
||||
* on reading `window.scrollY`, which in the sidebar layout is pinned at 0 by
|
||||
* `.fs-shell { height: 100svh; overflow: hidden }`. The offset then never appeared to move, the
|
||||
* sampler never extended `_movingUntil`, and every mid-scroll guard in this file was inert on
|
||||
* exactly the pages tall enough to scroll.
|
||||
*
|
||||
* The question is "which element does this layout scroll", and the STYLESHEET is what decides it:
|
||||
* `theme/20-shell.css` gives `.fs-main` `overflow-y: auto` for the desktop sidebar layout and
|
||||
* nothing else, so the computed value IS the answer — no content-height probe, no viewport literal
|
||||
* copied out of a media query, and correct the moment the CSS changes. `getComputedStyle` resolves
|
||||
* style, not layout, and the verdict is cached against the resize stamp AND the two attributes that
|
||||
* carry a layout change (`data-layout`, `data-narrow`), so the frame loop reads neither. */
|
||||
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
|
||||
function layoutKey() {
|
||||
const root = document.documentElement;
|
||||
return (root.getAttribute('data-layout') || '') + (root.hasAttribute('data-narrow') ? '|narrow' : '');
|
||||
}
|
||||
function scroller() {
|
||||
if (_scrollerAt === _resizeSeq && (_scroller === null || _scroller.isConnected)) return _scroller;
|
||||
const key = layoutKey();
|
||||
if (_scrollerAt === _resizeSeq && _scrollerKey === key &&
|
||||
(_scroller === null || _scroller.isConnected))
|
||||
return _scroller;
|
||||
const sc = document.getElementById('maincontent');
|
||||
_scroller = (sc && sc.scrollHeight - sc.clientHeight > 4) ? sc : null;
|
||||
const flow = sc ? window.getComputedStyle(sc).overflowY : '';
|
||||
_scroller = (flow === 'auto' || flow === 'scroll') ? sc : null;
|
||||
_scrollerAt = _resizeSeq;
|
||||
_scrollerKey = key;
|
||||
return _scroller;
|
||||
}
|
||||
function scrollTop() {
|
||||
@@ -346,13 +366,14 @@ function scheduleAnchor(ref) {
|
||||
}
|
||||
function applyAnchor(ref) {
|
||||
if (!ref || !ref.el.isConnected) return;
|
||||
const sc = document.getElementById('maincontent');
|
||||
const inner = sc && sc.scrollHeight - sc.clientHeight > 4;
|
||||
const at = inner ? sc.scrollTop : window.scrollY;
|
||||
/* through scroller(), not a second probe of its own: the two asked the same question in the
|
||||
* same two lines and could already answer differently within one frame */
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
if (at <= 0) return;
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return;
|
||||
if (inner) sc.scrollTop = at + drift;
|
||||
if (sc) sc.scrollTop = at + drift;
|
||||
else window.scrollTo(0, at + drift);
|
||||
}
|
||||
|
||||
@@ -409,7 +430,13 @@ return baseclass.extend({
|
||||
if (typeof fit !== 'function') return;
|
||||
_fitters.push(fit);
|
||||
observeContent();
|
||||
fit();
|
||||
/* Caught for the same reason runAll() catches, and this was the one run that was not: a
|
||||
* fitter that throws on its FIRST run propagated out of add() and out of the theme's init(),
|
||||
* so every registration after it was never made. With the gate already raised that is a page
|
||||
* whose data tables are `display: none` for good — nothing left to write `.fs-fitted`. The
|
||||
* five passes in fs-select.js are registered separately precisely so each fails alone. */
|
||||
try { fit(); }
|
||||
catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
|
||||
},
|
||||
|
||||
/* Is the reader scrolling, and "I could not measure, wake me when they stop". A pass that has to
|
||||
@@ -426,7 +453,6 @@ return baseclass.extend({
|
||||
* observer re-fits synchronously, and that is rule 2's whole point.) */
|
||||
schedule,
|
||||
|
||||
|
||||
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
|
||||
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
|
||||
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
|
||||
|
||||
@@ -58,8 +58,26 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
let _paused = [];
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
/* LuCI'S OWN TICK IS NOT OURS TO PAUSE, and taking it made the page poll FASTER the
|
||||
* longer it was left in a background tab. `L.Poll.start()` arms its 1 s tick with a
|
||||
* plain `setInterval`, so the hook above catches it like any other id. This listener is
|
||||
* registered at module eval and wireVisibility()'s in init(), so ours ran first: it
|
||||
* cleared LuCI's tick with the raw `_ci` and dropped it from the map (leaving
|
||||
* `L.Poll.timer` naming a dead id, which made the `L.Poll.stop()` right after a no-op),
|
||||
* and on show it re-armed that tick on an id `L.Poll` knew nothing about — after which
|
||||
* `start()` armed a second one, because `active()` had nothing to see. Two steps per
|
||||
* second after one hide/show, three after two, and so on for as long as the reader kept
|
||||
* coming back. Only a client navigation swept the orphans up.
|
||||
*
|
||||
* So the tick is skipped here and wireVisibility() keeps both halves of it. When it
|
||||
* cannot be told apart from a view's timer, NOTHING is paused: a view's poller running
|
||||
* in a hidden tab costs a wasted RPC, whereas re-arming LuCI's tick behind its back
|
||||
* costs a doubling that never stops. Same judgement as clearViewIntervals(). */
|
||||
const keep = pollTickId();
|
||||
if (keep === false) return;
|
||||
_paused = [];
|
||||
for (const [ id, spec ] of _viewIntervals) {
|
||||
if (id === keep) continue;
|
||||
_paused.push(spec);
|
||||
_ci.call(window, id);
|
||||
_viewIntervals.delete(id);
|
||||
@@ -72,43 +90,52 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
}
|
||||
});
|
||||
})();
|
||||
function clearViewIntervals() {
|
||||
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
|
||||
* `start`/`stop`/`active` are the documented surface, and upstream has already marked the whole
|
||||
* `L.Poll` alias deprecated (`'require poll'` is its replacement, and neither 24.10 nor 25.12
|
||||
* ships poll.js yet, so the alias is still the only way in). If that field ever goes, `keep`
|
||||
* becomes null and this sweep would clear LuCI's tick along with the view's timers — every poll
|
||||
* on every later page silently dead, from a rename we did not notice. So the missing field is
|
||||
* not a null: it is a reason to do NOTHING, once, loudly. A view's leftover interval outliving
|
||||
* its page costs a wasted RPC; killing the global tick costs the router's live data. */
|
||||
/* Asked through the DOCUMENTED half first: active() says whether LuCI's tick is running at all,
|
||||
* and `timer` is deleted by stop() — so an absent field is the ordinary "nothing to protect"
|
||||
* case on a page with no pollers, not a sign that upstream moved it. The anomaly worth reporting
|
||||
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know.
|
||||
* Then do NOTHING, once, loudly: a view's leftover interval outliving its page costs a wasted
|
||||
* RPC, whereas clearing LuCI's own tick costs every live value on every later page. */
|
||||
/* …and the ALIAS ITSELF is the first thing that can go — it is the deprecation the paragraph
|
||||
* above is about, so this function cannot be the one place that reads it blind. Every other
|
||||
* `L.Poll` read in this file is guarded, and an unguarded one here throws where a throw costs
|
||||
* most: navigate() calls this inside the staged render, after the chrome has already switched to
|
||||
* the incoming page and before its module is required, so a TypeError would leave every click
|
||||
* showing the previous page's content under the new page's title and menu mark, with the
|
||||
* navigation dead in a rejected promise — far worse than the leaked interval this exists to
|
||||
* sweep. No alias is the same answer as an unreadable timer: do NOTHING, once, loudly. */
|
||||
/* WHICH id IS LuCI'S OWN TICK — asked in ONE place, because two callers need the answer and both
|
||||
* pay the same price for getting it wrong: the navigation sweep below, and the hidden-tab pause in
|
||||
* the interval hook above (which used to take the tick with the view timers and hand it back on an
|
||||
* id L.Poll had never heard of).
|
||||
*
|
||||
* `L.Poll.timer` is that id, and it is private state — `add`/`remove`/`start`/`stop`/`active` are
|
||||
* the documented surface, and upstream has already marked the whole `L.Poll` alias deprecated
|
||||
* (`'require poll'` is its replacement, and neither 24.10 nor 25.12 ships poll.js yet, so the alias
|
||||
* is still the only way in). If the field is ever renamed, a caller reading it blind would treat
|
||||
* LuCI's tick as a view's: cleared on the next navigation, every poll on every later page silently
|
||||
* dead. So a missing field is not a null — it is a reason to do NOTHING, once, loudly. A view's
|
||||
* leftover interval outliving its page costs a wasted RPC; losing the global tick costs the
|
||||
* router's live data.
|
||||
*
|
||||
* Asked through the DOCUMENTED half first: `active()` says whether the tick is running at all, and
|
||||
* `timer` is deleted by `stop()` — so an absent field is the ordinary "nothing to protect" case on
|
||||
* a page with no pollers, not a sign that upstream moved anything. The anomaly worth reporting is
|
||||
* the pair DISAGREEING: a tick that is running while the id it runs on has no name we know.
|
||||
*
|
||||
* The alias itself is the first thing that can go, so this cannot be the one place that reads it
|
||||
* unguarded either: the sweep is called inside the staged render, after the chrome has switched to
|
||||
* the incoming page and before its module is required, and a TypeError there would leave every
|
||||
* click showing the previous page's content under the new page's title, the navigation dead in a
|
||||
* rejected promise. No alias is the same answer as an unreadable timer.
|
||||
*/
|
||||
/* -> the tick's id; `null` when LuCI is not polling and there is nothing to protect; `false` when
|
||||
* the two cannot be told apart, which every caller reads as "leave every interval alone". */
|
||||
function pollTickId() {
|
||||
if (!L.Poll) {
|
||||
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
|
||||
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
|
||||
+ 'updating for this luci-base.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
|
||||
if (running && L.Poll.timer == null) {
|
||||
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
|
||||
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
|
||||
+ 'luci-base.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const keep = running ? L.Poll.timer : null;
|
||||
return running ? L.Poll.timer : null;
|
||||
}
|
||||
function clearViewIntervals() {
|
||||
const keep = pollTickId();
|
||||
if (keep === false) return;
|
||||
/* Map, not Set: the key is the timer id and the value is what it would take to re-arm it */
|
||||
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
|
||||
}
|
||||
@@ -1258,10 +1285,74 @@ function bootDocumentIsOurs() {
|
||||
return tree.viewClassFor(node) != null;
|
||||
}
|
||||
|
||||
/* ---- the boot contract: the luci-base surfaces this router CALLS, looked up before it wires ----
|
||||
*
|
||||
* Every module here is written against somebody else's code, and against parts of it that were never
|
||||
* an API: `L.Poll` is a deprecated alias, `L.dom.content` and `ui.instantiateView` are what `view.ut`
|
||||
* happens to use, `Request.addInterceptor` is how the session probe hears a 403. None of those is a
|
||||
* promise anyone made. tools/upstream-contract.mjs asks whether they still BEHAVE as assumed, which
|
||||
* is the deeper question — but it only ever runs here, against the two userlands this repo owns. On
|
||||
* a router carrying a luci-base that MOVED (a fork, a backport, a distribution that trims luci.js),
|
||||
* the first anyone learns of it is a click that opens nothing: the interception ran, the swap threw
|
||||
* halfway, and the user is left on a page the theme half tore down.
|
||||
*
|
||||
* So: existence is checked at boot, once, and a missing name turns the router OFF rather than on-and-
|
||||
* broken. The page is then the plain server-dispatched MPA the theme was before the router existed —
|
||||
* every link a full load, nothing else lost, and the console says WHICH name is gone so the report
|
||||
* that reaches this repo names it too.
|
||||
*
|
||||
* Deliberately existence-only. A probe that called these to see what they answer would have to run
|
||||
* them for effect (there is no dry `instantiateView`), and a boot check that navigates is worse than
|
||||
* the fault it looks for. Semantics stay in the live gate, which is why both files point at each
|
||||
* other.
|
||||
*
|
||||
* The list is what THIS file calls, and nothing else: `uci` (flushUciCache) and `L.network` are read
|
||||
* through their own guards a few lines from their use, because they are optional there — a document
|
||||
* that never loaded network.js has nothing to refill. */
|
||||
const CONTRACT = [
|
||||
[ 'L.require', () => typeof window.L.require === 'function' ],
|
||||
/* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */
|
||||
[ 'L.Class', () => typeof window.L.Class === 'function' ],
|
||||
[ 'L.dom.content', () => window.L.dom && typeof window.L.dom.content === 'function' ],
|
||||
/* the four L.env keys navigate() RE-POINTS: a view reads them to know which page it is on */
|
||||
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
|
||||
const env = window.L.env;
|
||||
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
|
||||
.every((k) => k in env);
|
||||
} ],
|
||||
[ 'L.Poll.queue', () => window.L.Poll && Array.isArray(window.L.Poll.queue) ],
|
||||
[ 'L.Poll.start/stop', () => window.L.Poll &&
|
||||
typeof window.L.Poll.start === 'function' && typeof window.L.Poll.stop === 'function' ],
|
||||
[ 'L.Request.addInterceptor', () => window.L.Request &&
|
||||
typeof window.L.Request.addInterceptor === 'function' ],
|
||||
[ 'rpc.addInterceptor', () => typeof rpc.addInterceptor === 'function' ],
|
||||
[ 'ui.instantiateView', () => typeof ui.instantiateView === 'function' ],
|
||||
[ 'ui.hideModal', () => typeof ui.hideModal === 'function' ],
|
||||
[ 'ui.hideIndicator', () => typeof ui.hideIndicator === 'function' ],
|
||||
[ 'ui.addNotification', () => typeof ui.addNotification === 'function' ]
|
||||
];
|
||||
|
||||
/* -> the names that are NOT there, in list order; empty means the document can be navigated.
|
||||
* A probe that throws counts as missing: `L` itself may be a shape nobody here expected. */
|
||||
function contractBreaks() {
|
||||
return CONTRACT.filter(([ , present ]) => {
|
||||
try { return !present(); }
|
||||
catch (e) { return true; }
|
||||
}).map(([ name ]) => name);
|
||||
}
|
||||
|
||||
function wireRouter() {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
|
||||
const broken = contractBreaks();
|
||||
if (broken.length) {
|
||||
console.error('footstrap: this luci-base has no ' + broken.join(', ') +
|
||||
' — the client router stays off and every link is a full page load, which is what the ' +
|
||||
'theme did before it existed. Please report this line: docs/spa-router.md');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bootDocumentIsOurs())
|
||||
return;
|
||||
|
||||
@@ -1409,6 +1500,9 @@ return baseclass.extend({
|
||||
wire: wireRouter,
|
||||
wireVisibility,
|
||||
onNavigate,
|
||||
/* exported for the unit suite (tests/router-contract.test.mjs), which drives it against a
|
||||
* hand-broken `L` — the one way to see the OFF branch without a router that ships one */
|
||||
contractBreaks,
|
||||
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
|
||||
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
|
||||
* round — search → router — because the router must keep no dependency on the palette. */
|
||||
|
||||
Reference in new issue
Block a user