mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
✨ Sync 2026-08-18 20:36:54
This commit is contained in:
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall
|
||||
PKG_VERSION:=26.8.12
|
||||
PKG_RELEASE:=227
|
||||
PKG_RELEASE:=228
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
|
||||
@@ -80,6 +80,12 @@ end
|
||||
o:value("direct", "Direct")
|
||||
o:depends({ custom = false })
|
||||
|
||||
o = s:option(DummyValue, "is_endpoint", "")
|
||||
o.not_rewrite = true
|
||||
o.template = m:template_path("/cbi/hidevalue")
|
||||
o.value = "1"
|
||||
o:depends({ custom = false, protocol = "wireguard" })
|
||||
|
||||
o = s:option(Value, "port", translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ custom = false })
|
||||
@@ -452,7 +458,7 @@ end
|
||||
|
||||
o = s:option(Flag, "bind_local", translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends({ custom = false })
|
||||
o:depends({ custom = false, is_endpoint = "" })
|
||||
|
||||
o = s:option(Flag, "accept_lan", translate("Accept LAN Access"), translate("When selected, it can accessed lan , this will not be safe!"))
|
||||
o.default = "0"
|
||||
|
||||
@@ -11,6 +11,10 @@ if not s1.fields["type"].default then
|
||||
s1.fields["type"].default = type_name
|
||||
end
|
||||
|
||||
if not s1.val["type"] then
|
||||
s1.val["type"] = type_name
|
||||
end
|
||||
|
||||
if s1.val["type"] and s1.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
@@ -62,11 +66,16 @@ o:value("wireguard", "WireGuard")
|
||||
o:value("dokodemo-door", "dokodemo-door")
|
||||
o:depends({ custom = false })
|
||||
|
||||
o = s:option(DummyValue, "is_endpoint", "")
|
||||
o.not_rewrite = true
|
||||
o.template = m:template_path("/cbi/hidevalue")
|
||||
o.value = "1"
|
||||
o:depends({ custom = false, protocol = "wireguard" })
|
||||
|
||||
o = s:option(Value, "port", translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ custom = false })
|
||||
|
||||
|
||||
o = s:option(DynamicList, "users", translate("User"))
|
||||
for i, v in ipairs(user_list) do
|
||||
o:value(v[".name"], v.username)
|
||||
@@ -402,7 +411,7 @@ end
|
||||
--[[acceptProxyProtocol]]
|
||||
o = s:option(Flag, "acceptProxyProtocol", translate("acceptProxyProtocol"), translate("Whether to receive PROXY protocol, when this node want to be fallback or forwarded by proxy, it must be enable, otherwise it cannot be used."))
|
||||
o.default = "0"
|
||||
o:depends({ custom = false })
|
||||
o:depends({ custom = false, is_endpoint = "" })
|
||||
|
||||
--[[Fast Open]]
|
||||
o = s:option(Flag, "tcp_fast_open", "TCP " .. translate("Fast Open"))
|
||||
|
||||
@@ -2189,9 +2189,7 @@ local execute = function()
|
||||
else
|
||||
fail_list[#fail_list + 1] = value
|
||||
end
|
||||
if url_is_local then
|
||||
value.http_code = 0
|
||||
else
|
||||
if not url_is_local then
|
||||
luci.sys.call("rm -f " .. tmp_file)
|
||||
end
|
||||
end
|
||||
@@ -2199,7 +2197,7 @@ local execute = function()
|
||||
|
||||
if #fail_list > 0 then
|
||||
for index, value in ipairs(fail_list) do
|
||||
log(string.format('【%s】订阅失败,可能是订阅地址无效,或是网络问题,请诊断![%s]', value.remark, tostring(value.http_code)))
|
||||
log(string.format('【%s】订阅失败,可能是订阅地址无效,或是网络问题,请诊断![%s]', (value.remark or ""), tostring(value.http_code or 0)))
|
||||
end
|
||||
end
|
||||
update_node(0)
|
||||
|
||||
@@ -479,7 +479,7 @@ ln_run() {
|
||||
echolog " - 找不到 ${ln_name},无法启动..."
|
||||
return 1
|
||||
}
|
||||
[ "${output}" != "/dev/null" ] && [ -n "$(echo "${output}" | grep -E "default|SOCKS_")" ] && [ "${ln_name}" != "chinadns-ng" ] && {
|
||||
[ "${output}" != "/dev/null" ] && [ -n "$(echo "${output}" | grep -E "default|socks_")" ] && [ "${ln_name}" != "chinadns-ng" ] && {
|
||||
local persist_log_path=$(config_t_get global persist_log_path)
|
||||
local sys_log=$(config_t_get global sys_log "0")
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ HOST=http://<device> COOKIE_NAME=sysauth_http COOKIE_VALUE=<from jar> \
|
||||
node ../.claude/skills/aurora-performance/scripts/bench-router.mjs <label>
|
||||
```
|
||||
|
||||
`ONLY=walk|timing|soak|back|poison` runs one scenario (`RUNS` defaults to and
|
||||
is floored at 10). `walk` visits every page
|
||||
`ONLY=walk|timing|soak|back|poison|sheets|hygiene|nodecss|expiry` runs one
|
||||
scenario (`RUNS` defaults to and is floored at 10). `walk` visits every page
|
||||
the navigation model links to (menu + each page's tab strip) through the
|
||||
router, then full-loads the same URL and diffs title, `data-page`,
|
||||
`dispatchpath`, tab strip, active nav mark, footer presence and console
|
||||
@@ -75,13 +75,58 @@ deliberately interleaves alias/firstchild URLs (read from the menu tree)
|
||||
with view URLs and asserts each step stayed same-document with the right
|
||||
URL and `data-page`. `poison` injects a foreign `<style>` into `<head>` and
|
||||
asserts the next navigation is a full load and the one after is
|
||||
same-document again.
|
||||
same-document again. `sheets` repeats that against the view pages the walk
|
||||
found actually inserting their own sheets. `hygiene` checks that no progress
|
||||
bar is left in the DOM, that the live region carries the title, and that a
|
||||
hidden tab stops polling. `nodecss` checks a `menu.d` node's `css` link is
|
||||
enabled on arrival, disabled after leaving and re-enabled without a
|
||||
duplicate. `expiry` destroys the session and must land on the login form —
|
||||
it always runs last.
|
||||
|
||||
Trap: a navigation the router does not take is a real document load and
|
||||
tears down the CDP evaluation ("Inspected target navigated"); the harness
|
||||
treats that as a fallback, waits for the new document, and re-arms its
|
||||
same-document marker there.
|
||||
|
||||
## `bench-fullload.mjs` — where one navigation's time goes (CDP)
|
||||
|
||||
`bench-router.mjs` answers *how much faster*; this answers *what the full
|
||||
load was spending the time on*, which is what the router doc's stage table
|
||||
and the architecture diagram quote.
|
||||
|
||||
```bash
|
||||
HOST=http://<device> COOKIE_NAME=sysauth_http COOKIE_VALUE=<sid> RUNS=10 \
|
||||
node ../.claude/skills/aurora-performance/scripts/bench-fullload.mjs <label>
|
||||
```
|
||||
|
||||
Per page it reports medians, read off the document's own Navigation/Resource
|
||||
Timing rather than the harness clock: `ttfb` and `htmlEnd` (dispatcher run
|
||||
#1), `trStart`/`trEnd` (the `admin/translations/<lang>` script — dispatcher
|
||||
run #2, parser-blocking), `dcl`, `viewReady`, and the `ubus` window (the
|
||||
view's own data calls). It then measures the **same page over the router in
|
||||
the same loop**, so both halves see the same device state and are
|
||||
subtractable — run-to-run spread on an embedded device is large enough that
|
||||
two separately-run harnesses will not agree.
|
||||
|
||||
Set `PAGES` to override the sample; the default is the same 8 pages
|
||||
`bench-router.mjs timing` uses.
|
||||
|
||||
## `bench-dispatch.sh` — what one CGI dispatch costs (on the device)
|
||||
|
||||
```bash
|
||||
ssh root@<device> 'sh -s' < ../.claude/skills/aurora-performance/scripts/bench-dispatch.sh
|
||||
```
|
||||
|
||||
Runs on the router, so no network is in the number. Two halves: loopback
|
||||
`curl` medians (page HTML, both i18n catalogs, `/admin/menu`, one static
|
||||
file) and the per-process cost of each phase every dispatch pays, timed over
|
||||
50 `ucode` processes (`fork + ucode VM`, `import luci.dispatcher`, menu tree
|
||||
stat + index-cache parse, `session.get` + `session.access`).
|
||||
|
||||
The `admin/translations/en` row is the control: a 13-byte response that still
|
||||
pays a full dispatch. Compare it against the static-file row to separate
|
||||
dispatch cost from payload cost. It makes and destroys its own session.
|
||||
|
||||
## Measurement discipline
|
||||
|
||||
- **Median of ≥10 runs**, never a single sample — router-side variance
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# bench-dispatch.sh — what one LuCI CGI dispatch costs the device, measured on
|
||||
# the device itself so no network is in the number.
|
||||
#
|
||||
# ssh root@192.168.1.1 'sh -s' < bench-dispatch.sh
|
||||
#
|
||||
# Two halves:
|
||||
# requests curl against 127.0.0.1, median of 15, time_starttransfer + bytes.
|
||||
# The `en` catalog is the control: a 13-byte response that still
|
||||
# pays the whole dispatch, so the cost is the dispatch, not the
|
||||
# payload.
|
||||
# steps the per-process cost of each phase every dispatch runs, timed as
|
||||
# 50 ucode processes so the shell's own overhead averages out.
|
||||
# `spawn` is the floor; subtract it to read a phase's own cost.
|
||||
#
|
||||
# Needs a LuCI session for the view-page rows; it makes one and destroys it.
|
||||
# Output is JSON on stdout, progress on stderr.
|
||||
|
||||
set -u
|
||||
N_REQ=15
|
||||
N_PROC=50
|
||||
|
||||
log() { echo "$@" >&2; }
|
||||
|
||||
# --- a throwaway session, so the authenticated rows are measurable ----------
|
||||
SID=$(ubus call session login '{"username":"root","password":""}' 2>/dev/null |
|
||||
sed -n 's/.*"ubus_rpc_session": "\([a-f0-9]*\)".*/\1/p')
|
||||
if [ -n "$SID" ]; then
|
||||
ubus call session set "{\"ubus_rpc_session\":\"$SID\",\"values\":{\"token\":\"0123456789abcdef0123456789abcdef\"}}" >/dev/null 2>&1
|
||||
else
|
||||
log "warning: no passwordless root login; authenticated rows will be skipped"
|
||||
fi
|
||||
|
||||
# median of N_REQ, printed as "<seconds> <bytes>"
|
||||
req() {
|
||||
n=0
|
||||
while [ $n -lt $N_REQ ]; do
|
||||
curl -s -o /dev/null ${SID:+-b "sysauth_http=$SID"} \
|
||||
-w "%{time_starttransfer} %{size_download}\n" "$1"
|
||||
n=$((n + 1))
|
||||
done | sort -n | sed -n "$(( (N_REQ + 1) / 2 ))p"
|
||||
}
|
||||
|
||||
row() { # name url
|
||||
set -- "$1" "$2"
|
||||
r=$(req "$2") || r="0 0"
|
||||
ms=$(echo "$r" | awk '{printf "%.1f", $1 * 1000}')
|
||||
by=$(echo "$r" | awk '{print $2}')
|
||||
log " $1: $ms ms, $by B"
|
||||
printf ' {"what":"%s","url":"%s","ms":%s,"bytes":%s}' "$1" "$2" "$ms" "$by"
|
||||
}
|
||||
|
||||
# per-process ms for a ucode snippet, over N_PROC processes
|
||||
proc() { # name expr
|
||||
name=$1; expr=$2
|
||||
cat > /tmp/.bd-loop.sh <<EOF
|
||||
i=0; while [ \$i -lt $N_PROC ]; do ucode -e '$expr' >/dev/null 2>&1; i=\$((i+1)); done
|
||||
EOF
|
||||
t=$( { time -p sh /tmp/.bd-loop.sh; } 2>&1 | awk '/^real/ {print $2}' )
|
||||
[ -n "$t" ] || t=$( { time sh /tmp/.bd-loop.sh; } 2>&1 | awk '/real/ {gsub("m"," ");print $(NF-1)*60+$NF}' )
|
||||
ms=$(echo "$t $N_PROC" | awk '{printf "%.1f", $1 * 1000 / $2}')
|
||||
log " $name: $ms ms/process"
|
||||
printf ' {"what":"%s","ms":%s}' "$name" "$ms"
|
||||
rm -f /tmp/.bd-loop.sh
|
||||
}
|
||||
|
||||
CACHE=$(ls /tmp/luci-indexcache.*.json 2>/dev/null | head -1)
|
||||
MENUD=$(ls /usr/share/luci/menu.d/*.json 2>/dev/null | wc -l)
|
||||
CACHESZ=$(wc -c < "$CACHE" 2>/dev/null || echo 0)
|
||||
LANG_UCI=$(uci get luci.main.lang 2>/dev/null || echo auto)
|
||||
|
||||
log "requests (median of $N_REQ, loopback):"
|
||||
printf '{\n'
|
||||
printf ' "device": %s,\n' "$(ubus call system board | tr -d '\n' | sed 's/ */ /g')"
|
||||
printf ' "menuFiles": %s, "indexCacheBytes": %s, "lang": "%s",\n' "$MENUD" "$CACHESZ" "$LANG_UCI"
|
||||
printf ' "requests": [\n'
|
||||
row "page HTML (view node)" "http://127.0.0.1/cgi-bin/luci/admin/status/routesj"; printf ',\n'
|
||||
row "i18n catalog (en)" "http://127.0.0.1/cgi-bin/luci/admin/translations/en"; printf ',\n'
|
||||
row "i18n catalog (zh-cn)" "http://127.0.0.1/cgi-bin/luci/admin/translations/zh-cn"; printf ',\n'
|
||||
row "/admin/menu — once per session" "http://127.0.0.1/cgi-bin/luci/admin/menu"; printf ',\n'
|
||||
row "static main.css" "http://127.0.0.1/luci-static/aurora/main.css"; printf '\n'
|
||||
printf ' ],\n'
|
||||
|
||||
log "dispatch steps (per process, over $N_PROC processes):"
|
||||
printf ' "steps": [\n'
|
||||
proc "fork + ucode VM" 'x=1;'; printf ',\n'
|
||||
proc "import luci.dispatcher" 'import d from "luci.dispatcher";'; printf ',\n'
|
||||
proc "menu tree: stat + parse cache" "import { open, glob, stat } from \"fs\"; for (let f in glob(\"/usr/share/luci/menu.d/*.json\",\"/usr/lib/lua/luci/controller/*.lua\",\"/usr/lib/lua/luci/controller/*/*.lua\")) stat(f); json(open(\"$CACHE\",\"r\"));"; printf ',\n'
|
||||
proc "session.get + session.access" "import { connect } from \"ubus\"; let u = connect(); u.call(\"session\",\"get\",{ubus_rpc_session:\"$SID\"}); u.call(\"session\",\"access\",{ubus_rpc_session:\"$SID\"});"; printf '\n'
|
||||
printf ' ]\n}\n'
|
||||
|
||||
[ -n "$SID" ] && ubus call session destroy "{\"ubus_rpc_session\":\"$SID\"}" >/dev/null 2>&1
|
||||
exit 0
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bench-fullload.mjs <label> — where the time goes in ONE full-page LuCI
|
||||
* navigation, split between the device and the browser, over raw CDP
|
||||
* (headless Chrome, no npm deps, node >= 22).
|
||||
*
|
||||
* bench-router.mjs answers "how much faster is the same-document router";
|
||||
* this answers "what is the full load actually spending its time on", which
|
||||
* is what the router.md stage table and the architecture diagram quote.
|
||||
*
|
||||
* Every measured load is preceded by another full load, so static assets are
|
||||
* already cached and only the uncacheable work is left — the same warm state
|
||||
* bench-router.mjs measures the router in.
|
||||
*
|
||||
* env: HOST (default http://192.168.1.1), COOKIE_NAME, COOKIE_VALUE,
|
||||
* CHROME_BIN, RUNS (default and minimum 10), PAGES (comma-separated
|
||||
* paths under /cgi-bin/luci; defaults to the router.md sample).
|
||||
*
|
||||
* Per page it reports the medians of, all on the document's own clock
|
||||
* (time origin = navigation start):
|
||||
* ttfb responseStart of the page HTML — dispatcher run #1
|
||||
* htmlEnd responseEnd of the page HTML
|
||||
* trStart/End the admin/translations/<lang> script — dispatcher run #2,
|
||||
* a parser-blocking <script> in <head>
|
||||
* dcl domContentLoadedEventEnd
|
||||
* viewReady first non-spinner child inside #view — the view is painted
|
||||
* serverMs htmlEnd + (trEnd - trStart): wall time owned by the device
|
||||
* bytes transferEncodedBodyLength of the HTML and the catalog
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const HOST = (process.env.HOST ?? "http://192.168.1.1").replace(/\/+$/, "");
|
||||
const LABEL = process.argv[2] ?? "run";
|
||||
// measuring.md: medians of at least 10 runs.
|
||||
const RUNS = Math.max(10, +(process.env.RUNS ?? 10) || 10);
|
||||
const CHROME =
|
||||
process.env.CHROME_BIN ??
|
||||
(process.platform === "darwin"
|
||||
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
: "google-chrome");
|
||||
if (!process.env.COOKIE_NAME || !process.env.COOKIE_VALUE)
|
||||
throw new Error("COOKIE_NAME and COOKIE_VALUE are required");
|
||||
const COOKIE = {
|
||||
name: process.env.COOKIE_NAME,
|
||||
value: process.env.COOKIE_VALUE,
|
||||
domain: new URL(HOST).hostname,
|
||||
path: "/",
|
||||
};
|
||||
// The bench-router.mjs timing sample, verbatim, so the two runs compare.
|
||||
const PAGES = (
|
||||
process.env.PAGES ??
|
||||
[
|
||||
"admin/status/routesj",
|
||||
"admin/status/nftables",
|
||||
"admin/status/logs",
|
||||
"admin/status/processes",
|
||||
"admin/status/channel_analysis",
|
||||
"admin/status/realtime",
|
||||
"admin/system/system",
|
||||
"admin/system/admin",
|
||||
].join(",")
|
||||
)
|
||||
.split(",")
|
||||
.map((p) => `${HOST}/cgi-bin/luci/${p.trim().replace(/^\/+/, "")}`);
|
||||
// Loaded between measured loads so the measured one is never the first hit.
|
||||
const WARMUP = `${HOST}/cgi-bin/luci/admin/status/overview`;
|
||||
|
||||
const median = (v) => {
|
||||
const s = [...v].sort((a, b) => a - b);
|
||||
const m = s.length >> 1;
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
};
|
||||
const r0 = (v) => (v == null ? null : +v.toFixed(0));
|
||||
|
||||
/* ---------- chrome + CDP ---------- */
|
||||
const profile = mkdtempSync(join(tmpdir(), "cdp-aurora-fullload-"));
|
||||
const chrome = spawn(
|
||||
CHROME,
|
||||
[
|
||||
"--headless=new",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${profile}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--ignore-certificate-errors",
|
||||
"about:blank",
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
let wsUrl = "";
|
||||
chrome.stderr.setEncoding("utf8");
|
||||
chrome.stderr.on("data", (d) => {
|
||||
const m = d.match(/ws:\/\/[^\s]+/);
|
||||
if (m && !wsUrl) wsUrl = m[0];
|
||||
});
|
||||
const deadline = Date.now() + 20000;
|
||||
while (!wsUrl && Date.now() < deadline) await sleep(50);
|
||||
if (!wsUrl) throw new Error("chrome did not report a devtools endpoint");
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise((res, rej) => {
|
||||
ws.addEventListener("open", res, { once: true });
|
||||
ws.addEventListener("error", rej, { once: true });
|
||||
});
|
||||
let msgId = 0;
|
||||
const pending = new Map();
|
||||
const handlers = new Set();
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
const { res, rej } = pending.get(m.id);
|
||||
pending.delete(m.id);
|
||||
m.error ? rej(new Error(m.error.message)) : res(m.result);
|
||||
return;
|
||||
}
|
||||
for (const h of handlers) h(m);
|
||||
});
|
||||
function send(method, params = {}, sessionId) {
|
||||
const id = ++msgId;
|
||||
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
return new Promise((res, rej) => pending.set(id, { res, rej }));
|
||||
}
|
||||
function waitEvent(method, sessionId, timeout = 30000) {
|
||||
return new Promise((res, rej) => {
|
||||
const t = setTimeout(() => (handlers.delete(h), rej(new Error(`timeout ${method}`))), timeout);
|
||||
const h = (m) => {
|
||||
if (m.method !== method || (sessionId && m.sessionId !== sessionId)) return;
|
||||
clearTimeout(t);
|
||||
handlers.delete(h);
|
||||
res(m.params);
|
||||
};
|
||||
handlers.add(h);
|
||||
});
|
||||
}
|
||||
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
await send("Page.enable", {}, sessionId);
|
||||
await send("Runtime.enable", {}, sessionId);
|
||||
|
||||
await send("Storage.setCookies", { cookies: [COOKIE] });
|
||||
await send(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
{ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false },
|
||||
sessionId,
|
||||
);
|
||||
// Same view-paint clock bench-router.mjs uses for full loads.
|
||||
await send(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{
|
||||
source: `
|
||||
window.__viewReady = null;
|
||||
new MutationObserver(() => {
|
||||
const v = document.getElementById('view');
|
||||
if (v && v.querySelector(':scope > :not(.spinning):not(script)') && window.__viewReady == null)
|
||||
window.__viewReady = performance.now();
|
||||
}).observe(document, { childList: true, subtree: true });`,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
|
||||
async function evaljs(expression, awaitPromise = false, timeout = 30000) {
|
||||
const r = await Promise.race([
|
||||
send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, sessionId),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error("evaluate stalled")), timeout)),
|
||||
]);
|
||||
if (r.exceptionDetails)
|
||||
throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
}
|
||||
async function waitViewSettled(timeout = 15000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const ok = await evaljs(`(() => {
|
||||
const v = document.getElementById('view');
|
||||
return !v || !!v.querySelector(':scope > :not(.spinning):not(script)') ||
|
||||
(v.childElementCount === 0 && document.readyState === 'complete' && (window.L?.loaded ?? false));
|
||||
})()`);
|
||||
if (ok) return;
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
async function fullLoad(url) {
|
||||
const load = waitEvent("Page.loadEventFired", sessionId);
|
||||
await send("Page.navigate", { url }, sessionId);
|
||||
await load;
|
||||
await waitViewSettled();
|
||||
if (await evaljs(`!!document.querySelector('input[name="luci_username"]')`))
|
||||
throw new Error(`landed on the login form for ${url} — session expired?`);
|
||||
}
|
||||
|
||||
/* Everything below is read off the document's own performance timeline, so
|
||||
* the numbers are the browser's, not the harness's. */
|
||||
const SAMPLE = `(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
const res = performance.getEntriesByType('resource');
|
||||
const tr = res.find(e => /\\/admin\\/translations\\//.test(e.name));
|
||||
// The view's own data calls: the one piece of device work both paths share.
|
||||
const ub = res.filter(e => /\\/ubus/.test(e.name));
|
||||
return JSON.stringify({
|
||||
ubusStart: ub.length ? Math.min(...ub.map(e => e.startTime)) : null,
|
||||
ubusEnd: ub.length ? Math.max(...ub.map(e => e.responseEnd)) : null,
|
||||
ubusCount: ub.length,
|
||||
ttfb: nav.responseStart,
|
||||
htmlEnd: nav.responseEnd,
|
||||
htmlBytes: nav.transferSize,
|
||||
trStart: tr ? tr.startTime : null,
|
||||
trEnd: tr ? tr.responseEnd : null,
|
||||
trBytes: tr ? tr.transferSize : null,
|
||||
trUrl: tr ? new URL(tr.name).pathname : null,
|
||||
dcl: nav.domContentLoadedEventEnd,
|
||||
load: nav.loadEventEnd,
|
||||
viewReady: window.__viewReady,
|
||||
// Every request the document made, so nothing counted here is invisible.
|
||||
requests: res.length,
|
||||
});
|
||||
})()`;
|
||||
|
||||
/* The same page over the same-document router, measured in the same loop so
|
||||
* both halves of the comparison see the same device state. Mirrors
|
||||
* bench-router.mjs: hover first (a user does), then navigate and await the
|
||||
* navigation's own `finished` promise. */
|
||||
const SPA = (url) => `(async () => {
|
||||
const link = [...document.querySelectorAll('a[href]')].find(a => a.href === ${JSON.stringify(url)});
|
||||
if (link) { link.dispatchEvent(new Event('pointerover', { bubbles: true })); await new Promise(r => setTimeout(r, 400)); }
|
||||
performance.clearResourceTimings();
|
||||
const t0 = performance.now();
|
||||
let error = null;
|
||||
try {
|
||||
await Promise.race([navigation.navigate(${JSON.stringify(url)}).finished,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('router did not finish in 20 s')), 20000))]);
|
||||
} catch (e) { error = String(e); }
|
||||
const t1 = performance.now();
|
||||
const res = performance.getEntriesByType('resource').filter(e => e.startTime >= t0);
|
||||
const ub = res.filter(e => /\\/ubus/.test(e.name));
|
||||
return JSON.stringify({
|
||||
ms: t1 - t0, error, sameDoc: window.__sameDocMarker === 1,
|
||||
ubusStart: ub.length ? Math.min(...ub.map(e => e.startTime)) - t0 : null,
|
||||
ubusEnd: ub.length ? Math.max(...ub.map(e => e.responseEnd)) - t0 : null,
|
||||
ubusCount: ub.length, requests: res.length,
|
||||
});
|
||||
})()`;
|
||||
|
||||
const rows = [];
|
||||
for (const url of PAGES) {
|
||||
const s = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await fullLoad(WARMUP);
|
||||
await fullLoad(url);
|
||||
const one = JSON.parse(await evaljs(SAMPLE));
|
||||
if (one.viewReady != null) s.push(one);
|
||||
}
|
||||
// Router path: one document, warmed on the start page, RUNS round trips.
|
||||
const spa = [];
|
||||
await fullLoad(WARMUP);
|
||||
await evaljs("window.__sameDocMarker = 1");
|
||||
await evaljs(SPA(url), true); // cold: module not yet required in this document
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const back = JSON.parse(await evaljs(SPA(WARMUP), true));
|
||||
if (!back.sameDoc) { await evaljs("window.__sameDocMarker = 1"); continue; }
|
||||
const r = JSON.parse(await evaljs(SPA(url), true));
|
||||
if (!r.error && r.sameDoc) spa.push(r);
|
||||
}
|
||||
if (!s.length) {
|
||||
rows.push({ page: url.replace(`${HOST}/cgi-bin/luci`, ""), n: 0 });
|
||||
continue;
|
||||
}
|
||||
const med = (k) => median(s.map((x) => x[k]).filter((v) => v != null));
|
||||
const smed = (k) => (spa.length ? median(spa.map((x) => x[k]).filter((v) => v != null)) : null);
|
||||
const row = {
|
||||
page: url.replace(`${HOST}/cgi-bin/luci`, ""),
|
||||
n: s.length,
|
||||
spaN: spa.length,
|
||||
spaMs: r0(smed("ms")),
|
||||
spaUbusStart: r0(smed("ubusStart")),
|
||||
spaUbusEnd: r0(smed("ubusEnd")),
|
||||
spaUbusCount: r0(smed("ubusCount")),
|
||||
spaRequests: r0(smed("requests")),
|
||||
ttfb: r0(med("ttfb")),
|
||||
htmlEnd: r0(med("htmlEnd")),
|
||||
trStart: r0(med("trStart")),
|
||||
trEnd: r0(med("trEnd")),
|
||||
dcl: r0(med("dcl")),
|
||||
viewReady: r0(med("viewReady")),
|
||||
ubusStart: r0(med("ubusStart")),
|
||||
ubusEnd: r0(med("ubusEnd")),
|
||||
ubusCount: r0(med("ubusCount")),
|
||||
htmlBytes: r0(med("htmlBytes")),
|
||||
trBytes: r0(med("trBytes")),
|
||||
trUrl: s[0].trUrl,
|
||||
requests: r0(med("requests")),
|
||||
};
|
||||
// Wall time the device owns: the HTML response plus the blocking catalog.
|
||||
row.serverMs = r0((row.htmlEnd ?? 0) + ((row.trEnd ?? 0) - (row.trStart ?? 0)));
|
||||
row.browserMs = r0((row.viewReady ?? 0) - row.serverMs);
|
||||
rows.push(row);
|
||||
console.error(
|
||||
`${row.page}: ttfb ${row.ttfb} · html ${row.htmlEnd} · tr ${row.trStart}→${row.trEnd} · dcl ${row.dcl} · view ${row.viewReady} · ubus ${row.ubusStart}→${row.ubusEnd} (${row.ubusCount}) || router ${row.spaMs} · ubus ${row.spaUbusStart}→${row.spaUbusEnd} (${row.spaUbusCount}) n=${row.spaN}`,
|
||||
);
|
||||
}
|
||||
|
||||
const agg = (k) => r0(median(rows.filter((r) => r.n).map((r) => r[k])));
|
||||
const out = {
|
||||
label: LABEL,
|
||||
host: HOST,
|
||||
runs: RUNS,
|
||||
pages: rows,
|
||||
median: {
|
||||
ttfb: agg("ttfb"),
|
||||
htmlEnd: agg("htmlEnd"),
|
||||
trStart: agg("trStart"),
|
||||
trEnd: agg("trEnd"),
|
||||
dcl: agg("dcl"),
|
||||
viewReady: agg("viewReady"),
|
||||
serverMs: agg("serverMs"),
|
||||
browserMs: agg("browserMs"),
|
||||
ubusStart: agg("ubusStart"),
|
||||
ubusEnd: agg("ubusEnd"),
|
||||
spaMs: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaMs))),
|
||||
spaUbusStart: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaUbusStart))),
|
||||
spaUbusEnd: r0(median(rows.filter((r) => r.spaN).map((r) => r.spaUbusEnd))),
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
|
||||
await send("Target.closeTarget", { targetId });
|
||||
ws.close();
|
||||
chrome.kill();
|
||||
// Chrome unlinks its profile lazily; removing it under the exiting process
|
||||
// races and throws ENOTEMPTY, which would mask a clean run.
|
||||
await sleep(400);
|
||||
try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
|
||||
@@ -8,7 +8,7 @@ or to any view** — the router is additive theme JS plus three small template
|
||||
hooks (a patch manifest, a `<footer>` boundary, and `data-aurora-*` markers
|
||||
on the stylesheets header.ut itself renders).
|
||||
|
||||

|
||||

|
||||
|
||||
## Prior art
|
||||
|
||||
@@ -29,40 +29,79 @@ transition — each its own section below.
|
||||
|
||||
## Why it pays, measured
|
||||
|
||||
One full-load navigation on an `ipq60xx` router (RE-SS-01) over plain
|
||||
HTTP, median of 10 over the 8 sample pages below, this branch (2026-08-16):
|
||||
**Cudy TR3000** (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, this
|
||||
branch deployed, plain HTTP, warm cache, 2026-08-18. Both paths are measured
|
||||
in one loop (`bench-fullload.mjs`, RUNS=10, medians over the 8 pages below)
|
||||
so they see the same device state. Run-to-run spread is ±40 ms on a full
|
||||
load: the ratios are the claim, the digits are not.
|
||||
|
||||
| stage | ms | note |
|
||||
### Where a full load's time goes
|
||||
|
||||
| stage | ms | what it is |
|
||||
|---|--:|---|
|
||||
| dispatcher renders the page HTML | 106 | TTFB; menu tree + ACL + template |
|
||||
| `admin/translations/<lang>` | 106→205 | a second dispatcher run, render-blocking, uncacheable |
|
||||
| DOMContentLoaded | 211 | right behind the translations |
|
||||
| view module + ubus calls + render | 211→313 | static assets are already 0-byte cache hits |
|
||||
| dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` |
|
||||
| dispatch #2 — `admin/translations/<lang>` | 124→209 | a *second* CGI process, parser-blocking, uncacheable |
|
||||
| DOMContentLoaded | 215 | the shell is back, byte-identical to the one just discarded |
|
||||
| view module + ubus data + render | 215→321 | static assets are already cache hits |
|
||||
|
||||
≈ 210 ms of the 313 is the router-side work of rebuilding a document whose
|
||||
content is rendered client-side anyway. A same-document swap keeps only the
|
||||
last row (view module from cache + data RPCs + render): the same 8 pages
|
||||
land at a median of 99 ms warm. Speculation-rules prefetch cannot reach
|
||||
that number on HTTP at all (secure-context API) and on HTTPS only hides the
|
||||
first row.
|
||||
**209 of the 321 ms passes before anything page-specific has happened.** Both
|
||||
dispatches re-derive a shell the browser already had on screen; the view's own
|
||||
ubus calls do not start until 227 ms. A same-document swap deletes both
|
||||
dispatches and keeps the last row — the same 8 pages land at a median of
|
||||
**91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
|
||||
|
||||
End to end, click → view painted, median of 10 on the same device
|
||||
(`bench-router.mjs timing`, RUNS=10, 2026-08-16):
|
||||
### What one dispatch costs the device
|
||||
|
||||
Measured on the device itself, so no network is in the number
|
||||
(`bench-dispatch.sh`).
|
||||
|
||||
| request | ms | bytes |
|
||||
|---|--:|--:|
|
||||
| page HTML, a `view` node | 75.4 | 18,583 |
|
||||
| `admin/translations/en` | 62.7 | **13** |
|
||||
| `admin/translations/zh-cn` | 60.3 | 229,503 |
|
||||
| `admin/menu` — once per *session*, not per navigation | 68.2 | 45,022 |
|
||||
| static `main.css` | **0.8** | 191,899 |
|
||||
|
||||
| inside one dispatch, per process | ms |
|
||||
|---|--:|
|
||||
| fork + ucode VM | 2.2 |
|
||||
| `import luci.dispatcher` (runtime, http, ubus, uci, core, authplugins) | 37.2 |
|
||||
| menu tree: stat 8 `menu.d` files + parse the 28,307 B index cache | 13.8 |
|
||||
| `session.get` + `session.access` over ubus | 6.0 |
|
||||
|
||||
The `en` row is the control: a **13-byte** response still costs 62.7 ms, while
|
||||
a 191,899-byte static file is served in 0.8 ms. The cost is the dispatch, not
|
||||
the payload — and a full page load pays that block **twice**. (`zh-cn` clients
|
||||
additionally re-transfer 229,503 B every navigation: `write_headers()` in
|
||||
`http.uc` sets `Cache-Control: no-cache` and `Expires: 0` with no `ETag` or
|
||||
`Last-Modified`, so there is nothing to revalidate against.)
|
||||
|
||||
### End to end, click → view painted
|
||||
|
||||
Median of 10 per page, full load = navigation start → first non-spinner child
|
||||
of `#view`; router = click → the navigation's `finished` promise.
|
||||
|
||||
| page | full load | router (warm) | faster |
|
||||
|---|--:|--:|--:|
|
||||
| status/routesj | 286 | 112 | 61 % |
|
||||
| status/nftables | 277 | 85 | 69 % |
|
||||
| status/logs | 309 | 124 | 60 % |
|
||||
| status/processes | 455 | 251 | 45 % |
|
||||
| status/channel_analysis | 412 | 63 | 85 % |
|
||||
| status/realtime | 241 | 49 | 80 % |
|
||||
| system/system | 544 | 194 | 64 % |
|
||||
| system/admin | 268 | 63 | 76 % |
|
||||
| status/routesj | 326 | 92 | 72 % |
|
||||
| status/nftables | 316 | 90 | 72 % |
|
||||
| status/logs | 281 | 100 | 64 % |
|
||||
| status/processes | 457 | 228 | 50 % |
|
||||
| status/channel_analysis | 401 | 54 | 87 % |
|
||||
| status/realtime | 211 | 37 | 82 % |
|
||||
| system/system | 496 | 132 | 73 % |
|
||||
| system/admin | 231 | 40 | 83 % |
|
||||
|
||||
Median **67 % faster** (45–85 % across the sample). Absolute numbers move
|
||||
with CPU, network and page; the ratio is the point — the same-document swap
|
||||
skips the router-side rebuild above and keeps only the view render.
|
||||
Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an
|
||||
independent harness, was run twice the same day and landed at 72 % and
|
||||
74.5 % — all of that is inside the device's own spread, so treat the range,
|
||||
not the digit, as the result.
|
||||
|
||||
Speculation-rules prefetch cannot reach it: the API is secure-context only,
|
||||
so it is inert on HTTP, and on HTTPS a document prefetch hides only the
|
||||
first dispatch — the catalog is a subresource, fetched after the document
|
||||
arrives.
|
||||
|
||||
## Why it is possible
|
||||
|
||||
@@ -98,13 +137,17 @@ Why this API rather than the History API footstrap uses:
|
||||
- **The fallback is free.** Where the API is missing, the theme is the
|
||||
MPA it already was; nothing has to be polyfilled or feature-forked.
|
||||
|
||||
**Browsers without the API stay MPA.** Feature-detected at module eval:
|
||||
`window.navigation?.addEventListener` and `NavigateEvent.prototype.intercept`.
|
||||
Chrome/Edge 102+, Safari 26.2+, Firefox 147+ get the router; the theme's
|
||||
declared floor (Chrome 111 / Safari 16.4 / Firefox 128) keeps working as it
|
||||
does today. This is a deliberate trade: one code path, correct by
|
||||
construction, over a second history-API path that would double the surface
|
||||
of everything below.
|
||||
**Browsers without the API stay MPA.** `footer.ut` only requires the module
|
||||
when `window.navigation` exists, and `__init__` re-checks the surface it
|
||||
actually uses: `navigation.addEventListener`, `NavigateEvent`, and
|
||||
`intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
|
||||
147+ get the router — 105, not the 102 that first shipped the Navigation
|
||||
API, because the method was called `transitionWhile()` until Chrome 108 and
|
||||
`canIntercept` was `canTransition`; gating on `intercept` is what makes the
|
||||
floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 /
|
||||
Firefox 128) keeps working as it does today. This is a deliberate trade:
|
||||
one code path, correct by construction, over a second history-API path that
|
||||
would double the surface of everything below.
|
||||
|
||||
## Compatibility
|
||||
|
||||
@@ -124,26 +167,54 @@ detection only — the gate is the same API surface, not a UA sniff.
|
||||
|
||||
### OpenWrt / LuCI
|
||||
|
||||
The theme already requires OpenWrt 23.05+ (ucode templates). The router
|
||||
touches only luci-base surfaces that exist unchanged in the `openwrt-23.05`,
|
||||
`openwrt-24.10`, `openwrt-25.12` and `master` branches of `openwrt/luci`
|
||||
(checked by source, 2026-08): `L.require` with instance caching and
|
||||
The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
|
||||
two version-scoped items called out below, the router touches only luci-base
|
||||
surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
|
||||
`openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the
|
||||
branch sources, 2026-08): `L.require` with instance caching and
|
||||
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
|
||||
registry, `L.env.{scriptname, base_url, resource_version, media,
|
||||
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
|
||||
`L.Poll.{queue, tick, timer, start, stop}` and the `poll-start/poll-stop`
|
||||
events, `ui.menu.load()`'s session-cached tree with `satisfied` /
|
||||
`L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
|
||||
`tick`, which is what re-arms an incoming view's first poll),
|
||||
`ui.menu.load()`'s session-cached tree with `satisfied` /
|
||||
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
|
||||
`firstchild`, `template`), `ui.instantiateView`, `ui.hideIndicator`,
|
||||
`firstchild`, `template`), the `poll-status` indicator id `setupDOM`'s
|
||||
`poll-start` handler registers (the teardown hides it by that name),
|
||||
`ui.instantiateView`, `ui.hideIndicator`,
|
||||
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
|
||||
`network.js`'s uci-backed state, `Request.addInterceptor` /
|
||||
`rpc.addInterceptor` and the `-32002` → `session.access` probe in
|
||||
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding and `node.css`
|
||||
(schema since 7c6d8ff, 2026-08 — older trees simply carry no `css`),
|
||||
`view.ut`'s `#view` + inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
|
||||
`node_weight` / alias re-dispatch semantics (ported verbatim). Live
|
||||
verification so far: OpenWrt SNAPSHOT (2026-08, ipq60xx) — 23.05/24.10 by
|
||||
inspection, not yet on device.
|
||||
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
|
||||
inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
|
||||
`node_weight` / alias re-dispatch semantics (ported line for line).
|
||||
|
||||
**Two surfaces are not the same across those branches**, and the resolver is
|
||||
written against the newer one:
|
||||
|
||||
- **`node.css`** entered `build_pagetree`'s schema in master only
|
||||
(7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so
|
||||
`nodeCss()` returns `null` and the feature is simply inert there.
|
||||
- **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in
|
||||
23.05 or 24.10 — an absent key just falls back to `node.action`, which is
|
||||
what those releases do anyway, so that part is safe. The *resolution rule*
|
||||
around it is not: 25.12 and master descend into a matching `satisfied`
|
||||
child before treating trailing segments as args, while 23.05 and 24.10
|
||||
capture every remaining segment the moment a `wildcard` node is reached.
|
||||
The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has
|
||||
both `foo/*` and a real `foo/bar` child would therefore resolve differently
|
||||
in the router than in the dispatcher — the exact "click opens one page, F5
|
||||
opens another" failure this resolver exists to avoid. Both the rule and
|
||||
`wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated
|
||||
purpose is to let `path/*` carry an action distinct from the bare path, so
|
||||
the shape had no defined behaviour before it and a tree written for
|
||||
23.05/24.10 is unlikely to use it — but that is an argument, not a survey
|
||||
of every installed `menu.d`, and the router has not been run on either
|
||||
release. Treat 23.05/24.10 as inspected, not verified.
|
||||
|
||||
Live verification so far: OpenWrt SNAPSHOT r0-20d94d5 (2026-08,
|
||||
mediatek/filogic) and an earlier SNAPSHOT on ipq60xx. 23.05 / 24.10 / 25.12
|
||||
by branch source only, not on device.
|
||||
|
||||
That list is also executable: `contract()` in `router-aurora.js` looks every
|
||||
one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`,
|
||||
@@ -194,10 +265,18 @@ Resolved with a port of the dispatcher's own rules, not a paraphrase:
|
||||
`auth.login`; a `firstchild` candidate counts only if it resolves further;
|
||||
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
|
||||
skipped because `/admin/menu` is already filtered for the session;
|
||||
- `wildcard` nodes accept trailing segments as request args, and — as the
|
||||
dispatcher does — run the node's `wildcardaction` (the `path/*` entry's
|
||||
own action) when args are present and `action` for the bare path;
|
||||
- a hop counter breaks cycles in a foreign `menu.d`.
|
||||
- `wildcard` nodes are descended into first — a segment that matches a
|
||||
`satisfied` child wins over arg capture — and only the remainder becomes
|
||||
request args; with args present the node's `wildcardaction` (the `path/*`
|
||||
entry's own action) runs, and `action` for the bare path. This is the
|
||||
25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node
|
||||
instead — see "OpenWrt / LuCI" above;
|
||||
- a hop counter (32) breaks cycles in a foreign `menu.d`;
|
||||
- **any segment that does not match a `satisfied` child ends the attempt.**
|
||||
The dispatcher would fall back to the deepest satisfied ancestor and
|
||||
re-resolve from there; the router returns `null` and hands the navigation
|
||||
to the server. Deliberate: the fallback costs one full load, guessing the
|
||||
ancestor wrong costs the wrong page.
|
||||
|
||||
Two tracks are kept, as a full load keeps them: **requested** segments →
|
||||
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
|
||||
@@ -212,8 +291,11 @@ one page while F5 opens another — that is why the resolver is a port.
|
||||
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
|
||||
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
|
||||
|
||||
`rewrite` is deliberately not resolved: it is not in the tree and a splice
|
||||
mistake opens the wrong page, which is worse than the reload it falls back to.
|
||||
`rewrite` is deliberately not resolved. The node and its action *are* in the
|
||||
tree, but following it means re-implementing `dispatcher.uc`'s
|
||||
`splice(request_path, 0, action.remove)` and re-dispatching from the result;
|
||||
an off-by-one there opens the wrong page, which is worse than the reload it
|
||||
falls back to.
|
||||
|
||||
### Template nodes: the server's own shell, never a hand port
|
||||
|
||||
@@ -247,11 +329,13 @@ real full load, not against expectation.
|
||||
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
|
||||
handler in order:
|
||||
|
||||
1. **Generation.** `gen = ++navGen`; every later DOM write is gated on it.
|
||||
1. **Generation.** `const gen = ++this.gen`; every later DOM write is gated
|
||||
on it.
|
||||
`event.signal` aborts our own awaits, but it cannot cancel a LuCI XHR
|
||||
(`L.Request` never exposes its handle) or a `View.__init__` chain already
|
||||
running, so the generation is the correctness mechanism and the signal is
|
||||
hygiene.
|
||||
(`L.Request` hands back a bare promise; the `XMLHttpRequest` only surfaces
|
||||
on the *resolved* `Response`, too late to abort) or a `View.__init__`
|
||||
chain already running, so the generation is the correctness mechanism and
|
||||
the signal is hygiene.
|
||||
2. **Teardown of the departing document state**, i.e. what a document
|
||||
death would have done for free:
|
||||
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
|
||||
@@ -267,7 +351,8 @@ handler in order:
|
||||
package's request promise — a rejected one included — until
|
||||
`unload()`, so a failed load left there would be handed to every later
|
||||
view). Then, if `L.network` has been loaded,
|
||||
`load(['network','wireless','luci'])` is re-issued and **awaited** —
|
||||
`load(['network','luci'])` — plus `'wireless'` when
|
||||
`L.hasSystemFeature('wifi')` — is re-issued and **awaited** —
|
||||
and a rejection propagates to the hard-load fallback rather than
|
||||
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
|
||||
from then on answers out of the uci cache (`getWifiDevices()` *is*
|
||||
@@ -277,8 +362,10 @@ handler in order:
|
||||
full load; saved changes live on the server and the Unsaved-changes
|
||||
indicator is unaffected.
|
||||
- bare `setInterval`s registered since the router booted are cleared
|
||||
(`setInterval` is hooked at module eval; the one interval `L.Poll`
|
||||
owns is preserved). `setTimeout` and rAF are **not** touched: the
|
||||
(`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
|
||||
`L.require('router-aurora')` instantiates the class; `poll.timer`, the
|
||||
one interval `L.Poll` owns, is skipped). `setTimeout` and rAF are
|
||||
**not** touched: the
|
||||
core keeps tooltips, notification timeouts and a request timeout on
|
||||
`setTimeout`, and there is no self-rescheduling timeout in any
|
||||
shipped view.
|
||||
@@ -296,9 +383,15 @@ handler in order:
|
||||
credited to the class and released when a later warm render of the
|
||||
same class registers the same target/type, which proves them
|
||||
per-render.
|
||||
- `ui.hideIndicator('poll-status')` — luci-base leaves a
|
||||
*Refreshing* / *Paused* indicator behind that a document death would
|
||||
have taken with it.
|
||||
- `ui.hideModal()`, the theme's own surfaces (mega
|
||||
menu, mobile drawer, palette) close.
|
||||
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
|
||||
|
||||
The uci flush is the one part of this that is awaited rather than
|
||||
fire-and-forget, so it is a separate step after `teardown()` returns.
|
||||
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
|
||||
`body[data-page]`, `document.title`. An alias is re-dispatched
|
||||
server-side, so `requestpath` and `data-page` carry the alias target while
|
||||
@@ -365,23 +458,29 @@ handler in order:
|
||||
already ran; LuCI's class system sets `prototype.constructor`, so
|
||||
`new instance.constructor()` runs a fresh `__init__` → `load()` →
|
||||
`render()` → `dom.content('#view')`, exactly what a full load starts
|
||||
from.
|
||||
from. Either way the required value is checked with
|
||||
`instanceof L.view`; anything else throws into the hard-load path
|
||||
rather than staging a non-view.
|
||||
- **completion** is observed, not assumed: a `MutationObserver` on the
|
||||
staged element resolves when a non-spinner child lands (or the spinner
|
||||
is removed for an empty render). Not completing within 15 s is a
|
||||
**failure**, not a completion: committing the spinner and releasing the
|
||||
serialization would let the still-running chain paint into a later
|
||||
navigation's `#view`, so the timeout rejects and the catch path
|
||||
hard-loads the destination. On completion —
|
||||
and only if this navigation is still the latest — the outgoing region
|
||||
hard-loads the destination. On completion — and only if this navigation
|
||||
is still the latest — the outgoing region
|
||||
(everything between `#tabmenu` and `<footer>` except the staged
|
||||
element) is removed and the staged view is unhidden inside
|
||||
`document.startViewTransition()` when available and reduced motion is
|
||||
off; the navigation's `finished` promise resolves after that swap.
|
||||
Each departing element goes through `L.dom.content(el, null)` before
|
||||
`remove()`: that is what drops its `data-idref` registry entries, which
|
||||
would otherwise hold the detached subtree and its class instances
|
||||
alive — which is what the soak test below measures.
|
||||
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
|
||||
`View.__init__` chain can be cancelled (`L.Request` never exposes its
|
||||
handle), and every chain paints into *whichever* `#view` is first at
|
||||
paint time. So a navigation first awaits the previous one's completion
|
||||
`View.__init__` chain can be cancelled (same reason), and every chain
|
||||
paints into *whichever* `#view` is first at paint time. So a navigation
|
||||
first awaits the previous one's completion
|
||||
(bounded by the same timeout) before it tears anything down or stages
|
||||
anything — the previous chain finishes into its own staged element,
|
||||
which is then discarded. Rapid A→B→C therefore never interleaves:
|
||||
@@ -394,8 +493,8 @@ handler in order:
|
||||
may still paint. That first render also runs inside a render window
|
||||
(opened after the router's own listeners are registered), so the
|
||||
listeners it adds are credited to its class like a cold render's; the
|
||||
ones it registered before the router loaded are out of reach. The cost is
|
||||
that a click during a slow load waits for that load; the alternative —
|
||||
ones it registered before the router loaded are out of reach. The cost
|
||||
is that a click during a slow load waits for that load; the alternative —
|
||||
wrapping `prototype.render` per class and repairing stale cold renders
|
||||
by re-navigating — leaves a real window open and needs three mechanisms
|
||||
where one suffices.
|
||||
@@ -420,7 +519,11 @@ handler in order:
|
||||
motion drops the transitions, not the bar.
|
||||
10. Any exception → `console.error` (a silent fallback makes every router
|
||||
regression look like "the page is just slow") → `location.href =
|
||||
destination` — a hard full load, never a stuck page.
|
||||
destination` — a hard full load, never a stuck page. A `bypass` flag is
|
||||
set first so the `navigate` event that write produces passes straight
|
||||
through instead of being intercepted back into the failing path, and the
|
||||
handler then parks on a never-settling promise so nothing else runs
|
||||
against a document that is on its way out.
|
||||
|
||||
## The expiry gate
|
||||
|
||||
@@ -453,16 +556,16 @@ every page after it (a shipped file manager hides Save/Reset on every config
|
||||
page with one unlayered `!important` rule). Removing it is not an option: a
|
||||
library that imports CSS at module eval never runs again, so deletion is
|
||||
one-way (an editor page came back as a black rectangle two million pixels
|
||||
tall). Hence a gate, not a sweep: before intercepting, any sheet outside
|
||||
`#view` that is not one of the theme's own — header.ut marks everything it
|
||||
renders (`data-aurora-shell` on `main.css`, the font, custom and token
|
||||
tall). Hence a gate, not a sweep: before intercepting, any sheet outside `#view`
|
||||
that is not one of the theme's own marks the document **poisoned** and the
|
||||
navigation is a full load — the fresh document carries no view CSS, so the
|
||||
router resumes immediately. "Own" means *marked*: header.ut stamps everything
|
||||
it renders (`data-aurora-shell` on `main.css`, the font, custom and token
|
||||
`<style>`s; `data-aurora-patch` on patches; `data-aurora-node-css` on the
|
||||
menu.d node css) — marks the document **poisoned** and the navigation is a
|
||||
full load — the fresh document carries no view CSS, so the router resumes
|
||||
immediately. The markers, not a boot snapshot, define "own": the boot page's
|
||||
modules load concurrently with the router, and a sheet they inserted before
|
||||
it snapshotted would have counted as the theme's for the rest of the
|
||||
document. Correctness over speed, never the other way.
|
||||
menu.d node css). The boot snapshot the gate compares against is filtered by
|
||||
those markers, so a sheet the boot page's own modules inserted before the
|
||||
router loaded still counts as foreign instead of being grandfathered in for
|
||||
the rest of the document. Correctness over speed, never the other way.
|
||||
|
||||
An owner-based refinement (stamp each sheet with the inserting module off
|
||||
the call stack, enable it for pages whose dependency closure holds that
|
||||
@@ -479,10 +582,11 @@ Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
|
||||
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
|
||||
which would render it. The URL is built byte-for-byte as `LuCI.require()`
|
||||
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
|
||||
misses the HTTP cache. The walk is transitive: the fetched body is scanned
|
||||
for its leading `'require x'` string literals with a regex that is **not**
|
||||
line-anchored (shipped files are minified onto one line), and dotted names
|
||||
are warmed the same way; dotless names are either luci-base's file-less
|
||||
misses the HTTP cache. The walk is transitive: the fetched body's **first
|
||||
4 KB** is scanned for the leading run of `'require x'` string literals with
|
||||
a regex that is **not** line-anchored (shipped files are minified onto one
|
||||
line), and dotted
|
||||
names are warmed the same way; dotless names are either luci-base's file-less
|
||||
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
|
||||
libraries the chrome has already loaded, so they are declined outright.
|
||||
Deduplicated per class name; stops once a navigation to that link has
|
||||
@@ -497,8 +601,9 @@ already 0-byte cache hits.
|
||||
a hover prefetch of a document the router will never load is pure router
|
||||
CPU. Browsers without the Navigation API keep the rules and the MPA path.
|
||||
- **No `unload`/`beforeunload`**, ever (bfcache).
|
||||
- **No cancellation of in-flight XHR** — `L.Request` gives no handle; the
|
||||
generation gate makes it a waste, not a bug. Upstream-only.
|
||||
- **No cancellation of in-flight XHR** — there is no handle to cancel with
|
||||
(see step 1); the generation gate makes it a waste, not a bug.
|
||||
Upstream-only.
|
||||
- **No sweeping of a view's global listeners or timeouts** — one-way
|
||||
deletions of module-eval registrations. If a per-render offender ever
|
||||
appears, the answer is a targeted teardown, not a global hook.
|
||||
@@ -539,5 +644,14 @@ already 0-byte cache hits.
|
||||
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
|
||||
set of enabled node-css links and the live-region text against the full
|
||||
load, and reports which pages carry sheets that are not the theme's.
|
||||
- The perf skill's N1 is rewritten to describe this router and its
|
||||
boundaries; N2 keeps document prefetch for the non-router path.
|
||||
- Device (`bench-fullload.mjs`, CDP): where one full load's time goes —
|
||||
dispatch #1, the parser-blocking catalog, DOMContentLoaded, the view's own
|
||||
ubus window — and the same page over the router, both in one loop so the
|
||||
two are subtractable.
|
||||
- Device (`bench-dispatch.sh`, run on the router): what one CGI dispatch costs
|
||||
before any page-specific work — process, module graph, menu tree, session
|
||||
probe — plus the loopback cost and size of each response a navigation pulls.
|
||||
The `en`-catalog row is the control that separates dispatch cost from payload.
|
||||
- The perf skill (`.claude/skills/aurora-performance/`) documents all three
|
||||
harnesses in `references/measuring.md`; the server-side cost this router
|
||||
removes is the S1/S2 budget in `references/server.md`.
|
||||
|
||||
@@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
|
||||
LUCI_DEPENDS:=+luci-base
|
||||
|
||||
PKG_VERSION:=1.2.3
|
||||
PKG_RELEASE:=68
|
||||
PKG_RELEASE:=69
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
|
||||
FOOTSTRAP_VERSION?=
|
||||
ifneq ($(FOOTSTRAP_VERSION),)
|
||||
PKG_VERSION:=$(FOOTSTRAP_VERSION)
|
||||
PKG_RELEASE:=23
|
||||
PKG_RELEASE:=24
|
||||
endif
|
||||
|
||||
LUCI_TITLE:=Footstrap Theme
|
||||
|
||||
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. */
|
||||
|
||||
@@ -654,9 +654,23 @@
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* .fade-in's animation: theme/45-misc.css (fs-fade) — absorbed. The @keyframes above
|
||||
* STAY though nothing here rides them: `fade-in` is an upstream animation NAME a
|
||||
* luci-app-* may ride (coverage contract, docs/conventions.md). */
|
||||
/* THE `fade-in` KEYFRAMES ABOVE STAY THOUGH NOTHING HERE RIDES THEM: `fade-in` is an
|
||||
* upstream animation NAME a luci-app-* may ride (coverage contract, docs/conventions.md).
|
||||
* The theme's own rule on the `.fade-in` CLASS is gone on purpose — luci-mod-status re-marks
|
||||
* with it on every poll tick, so anything hung there replayed once a second across the whole
|
||||
* column (theme/45-misc.css says it at length).
|
||||
*
|
||||
* `fs-fade` is the theme's own, and it is one-shot: the two rules that ride it — the rail
|
||||
* flyout (theme/20-shell.css) and the search overlay (theme/75-search.css) — each play it
|
||||
* once, when the surface opens, which is why the 4px rise is right there and was wrong on a
|
||||
* class the poll rewrites. It sits here beside `fs-spin` because every keyframes in this
|
||||
* theme does; it was deleted together with the poll's rule and took both fades with it
|
||||
* silently, since an unresolvable animation-name is simply dropped. */
|
||||
@keyframes fs-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
animation: fade-out var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
@@ -436,6 +436,34 @@
|
||||
{% endif %}
|
||||
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
|
||||
<link rel="icon" href="{{ media }}/logo.svg" sizes="any">
|
||||
{# THE ROUTER ON A HOME SCREEN. A phone is where LuCI is opened when something is wrong with
|
||||
the network, and a browser tab is a poor place to keep it: the manifest lets the admin
|
||||
install this page as an app, which gets it an icon, its own window and no address bar.
|
||||
|
||||
Both files are STATIC and cannot be otherwise — a theme may not register a dispatcher node,
|
||||
so there is nothing here that could render them per request. That fixes two values:
|
||||
|
||||
start_url/scope — `/cgi-bin/luci/`, uhttpd's own default and the path this template is
|
||||
being served from on every stock install. An admin who moved LuCI elsewhere gets an
|
||||
installed app that opens the standard path; the browser tab is unaffected either way.
|
||||
background/theme_color — the DEFAULT palette's page colour (#f6f8fa). The manifest is
|
||||
read once at install time and cannot follow a live Appearance change, so it carries
|
||||
the default rather than a value that would be wrong for everyone who changed it. It
|
||||
only ever paints the splash and the window chrome, never the page.
|
||||
|
||||
Chrome offers its install prompt on a secure context only, so over plain HTTP this is
|
||||
iOS's Add to Home Screen (which reads the manifest and this apple-touch-icon) plus the
|
||||
icons themselves. Nothing here is on the page's critical path: a manifest is fetched at
|
||||
idle priority and never blocks a paint.
|
||||
|
||||
`.json`, NOT the conventional `.webmanifest`, and that is the server's doing: uhttpd's
|
||||
built-in MIME map has no entry for that extension. Measured on the stand — the same bytes
|
||||
served as `app.webmanifest` come back `application/octet-stream`, as `t.manifest`
|
||||
`text/cache-manifest`, and only as `.json` do they come back with a JSON type, which is
|
||||
what every browser accepts a manifest as. There is no per-extension setting to fix from
|
||||
here: the map is compiled into uhttpd. #}
|
||||
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
|
||||
<link rel="apple-touch-icon" href="{{ media }}/apple-touch-icon.png">
|
||||
{% if (dispatched?.css): %}
|
||||
{# The `css` property of the dispatched menu.d node — an app naming a stylesheet the server
|
||||
should link for its page, instead of the view injecting one at module eval (which is what
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=modeminfo
|
||||
PKG_VERSION:=0.4.8
|
||||
PKG_RELEASE:=10
|
||||
PKG_RELEASE:=11
|
||||
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
ATE0
|
||||
AT+COPS=3,0; +COPS?; +CREG?
|
||||
AT+CEREG=2; +CEREG?
|
||||
AT+CGREG=2; +CGREG?; +CSQ
|
||||
AT+CEREG=2
|
||||
AT+CEREG?
|
||||
AT+CGREG=2
|
||||
AT+CGREG?
|
||||
AT+CSQ
|
||||
AT+GTCCINFO?; +GTCAINFO?
|
||||
AT*CNTI=0
|
||||
AT+MTSM=1
|
||||
|
||||
Reference in New Issue
Block a user