✨ Sync 2026-08-18 20:36:54

This commit is contained in:
github-actions[bot] committed 2026-08-18 20:36:54 +08:00
1 parent 3e76c2c768
commit 5261ac811a
21 files changed
+935 -145

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.12 PKG_VERSION:=26.8.12
PKG_RELEASE:=227 PKG_RELEASE:=228
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -80,6 +80,12 @@ end
o:value("direct", "Direct") o:value("direct", "Direct")
o:depends({ custom = false }) 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 = s:option(Value, "port", translate("Listen Port"))
o.datatype = "port" o.datatype = "port"
o:depends({ custom = false }) 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 = s:option(Flag, "bind_local", translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
o.default = "0" 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 = s:option(Flag, "accept_lan", translate("Accept LAN Access"), translate("When selected, it can accessed lan , this will not be safe!"))
o.default = "0" o.default = "0"
@@ -11,6 +11,10 @@ if not s1.fields["type"].default then
s1.fields["type"].default = type_name s1.fields["type"].default = type_name
end end
if not s1.val["type"] then
s1.val["type"] = type_name
end
if s1.val["type"] and s1.val["type"] ~= type_name then if s1.val["type"] and s1.val["type"] ~= type_name then
return return
end end
@@ -62,11 +66,16 @@ o:value("wireguard", "WireGuard")
o:value("dokodemo-door", "dokodemo-door") o:value("dokodemo-door", "dokodemo-door")
o:depends({ custom = false }) 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 = s:option(Value, "port", translate("Listen Port"))
o.datatype = "port" o.datatype = "port"
o:depends({ custom = false }) o:depends({ custom = false })
o = s:option(DynamicList, "users", translate("User")) o = s:option(DynamicList, "users", translate("User"))
for i, v in ipairs(user_list) do for i, v in ipairs(user_list) do
o:value(v[".name"], v.username) o:value(v[".name"], v.username)
@@ -402,7 +411,7 @@ end
--[[acceptProxyProtocol]] --[[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 = 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.default = "0"
o:depends({ custom = false }) o:depends({ custom = false, is_endpoint = "" })
--[[Fast Open]] --[[Fast Open]]
o = s:option(Flag, "tcp_fast_open", "TCP " .. translate("Fast Open")) o = s:option(Flag, "tcp_fast_open", "TCP " .. translate("Fast Open"))
@@ -2189,9 +2189,7 @@ local execute = function()
else else
fail_list[#fail_list + 1] = value fail_list[#fail_list + 1] = value
end end
if url_is_local then if not url_is_local then
value.http_code = 0
else
luci.sys.call("rm -f " .. tmp_file) luci.sys.call("rm -f " .. tmp_file)
end end
end end
@@ -2199,7 +2197,7 @@ local execute = function()
if #fail_list > 0 then if #fail_list > 0 then
for index, value in ipairs(fail_list) do 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
end end
update_node(0) update_node(0)
@@ -479,7 +479,7 @@ ln_run() {
echolog " - 找不到 ${ln_name},无法启动..." echolog " - 找不到 ${ln_name},无法启动..."
return 1 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 persist_log_path=$(config_t_get global persist_log_path)
local sys_log=$(config_t_get global sys_log "0") 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> node ../.claude/skills/aurora-performance/scripts/bench-router.mjs <label>
``` ```
`ONLY=walk|timing|soak|back|poison` runs one scenario (`RUNS` defaults to and `ONLY=walk|timing|soak|back|poison|sheets|hygiene|nodecss|expiry` runs one
is floored at 10). `walk` visits every page 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 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`, router, then full-loads the same URL and diffs title, `data-page`,
`dispatchpath`, tab strip, active nav mark, footer presence and console `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 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 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 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 Trap: a navigation the router does not take is a real document load and
tears down the CDP evaluation ("Inspected target navigated"); the harness tears down the CDP evaluation ("Inspected target navigated"); the harness
treats that as a fallback, waits for the new document, and re-arms its treats that as a fallback, waits for the new document, and re-arms its
same-document marker there. 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 ## Measurement discipline
- **Median of ≥10 runs**, never a single sample — router-side variance - **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 {}
+200 -86
View File
@@ -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 hooks (a patch manifest, a `<footer>` boundary, and `data-aurora-*` markers
on the stylesheets header.ut itself renders). on the stylesheets header.ut itself renders).
![Full reload vs the same-document router: timings above, the router's five stages below](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.png) ![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg)
## Prior art ## Prior art
@@ -29,40 +29,79 @@ transition — each its own section below.
## Why it pays, measured ## Why it pays, measured
One full-load navigation on an `ipq60xx` router (RE-SS-01) over plain **Cudy TR3000** (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, this
HTTP, median of 10 over the 8 sample pages below, this branch (2026-08-16): 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 | | dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` |
| `admin/translations/<lang>` | 106→205 | a second dispatcher run, render-blocking, uncacheable | | dispatch #2 — `admin/translations/<lang>` | 124→209 | a *second* CGI process, parser-blocking, uncacheable |
| DOMContentLoaded | 211 | right behind the translations | | DOMContentLoaded | 215 | the shell is back, byte-identical to the one just discarded |
| view module + ubus calls + render | 211→313 | static assets are already 0-byte cache hits | | 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 **209 of the 321 ms passes before anything page-specific has happened.** Both
content is rendered client-side anyway. A same-document swap keeps only the dispatches re-derive a shell the browser already had on screen; the view's own
last row (view module from cache + data RPCs + render): the same 8 pages ubus calls do not start until 227 ms. A same-document swap deletes both
land at a median of 99 ms warm. Speculation-rules prefetch cannot reach dispatches and keeps the last row — the same 8 pages land at a median of
that number on HTTP at all (secure-context API) and on HTTPS only hides the **91 ms** warm, with the data calls starting at 2 ms instead of 227 ms.
first row.
End to end, click → view painted, median of 10 on the same device ### What one dispatch costs the device
(`bench-router.mjs timing`, RUNS=10, 2026-08-16):
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 | | page | full load | router (warm) | faster |
|---|--:|--:|--:| |---|--:|--:|--:|
| status/routesj | 286 | 112 | 61 % | | status/routesj | 326 | 92 | 72 % |
| status/nftables | 277 | 85 | 69 % | | status/nftables | 316 | 90 | 72 % |
| status/logs | 309 | 124 | 60 % | | status/logs | 281 | 100 | 64 % |
| status/processes | 455 | 251 | 45 % | | status/processes | 457 | 228 | 50 % |
| status/channel_analysis | 412 | 63 | 85 % | | status/channel_analysis | 401 | 54 | 87 % |
| status/realtime | 241 | 49 | 80 % | | status/realtime | 211 | 37 | 82 % |
| system/system | 544 | 194 | 64 % | | system/system | 496 | 132 | 73 % |
| system/admin | 268 | 63 | 76 % | | system/admin | 231 | 40 | 83 % |
Median **67 % faster** (45–85 % across the sample). Absolute numbers move Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an
with CPU, network and page; the ratio is the point — the same-document swap independent harness, was run twice the same day and landed at 72 % and
skips the router-side rebuild above and keeps only the view render. 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 ## 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 - **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. MPA it already was; nothing has to be polyfilled or feature-forked.
**Browsers without the API stay MPA.** Feature-detected at module eval: **Browsers without the API stay MPA.** `footer.ut` only requires the module
`window.navigation?.addEventListener` and `NavigateEvent.prototype.intercept`. when `window.navigation` exists, and `__init__` re-checks the surface it
Chrome/Edge 102+, Safari 26.2+, Firefox 147+ get the router; the theme's actually uses: `navigation.addEventListener`, `NavigateEvent`, and
declared floor (Chrome 111 / Safari 16.4 / Firefox 128) keeps working as it `intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox
does today. This is a deliberate trade: one code path, correct by 147+ get the router — 105, not the 102 that first shipped the Navigation
construction, over a second history-API path that would double the surface API, because the method was called `transitionWhile()` until Chrome 108 and
of everything below. `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 ## Compatibility
@@ -124,26 +167,54 @@ detection only — the gate is the same API surface, not a UA sniff.
### OpenWrt / LuCI ### OpenWrt / LuCI
The theme already requires OpenWrt 23.05+ (ucode templates). The router The theme already requires OpenWrt 23.05+ (ucode templates). Except for the
touches only luci-base surfaces that exist unchanged in the `openwrt-23.05`, two version-scoped items called out below, the router touches only luci-base
`openwrt-24.10`, `openwrt-25.12` and `master` branches of `openwrt/luci` surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`,
(checked by source, 2026-08): `L.require` with instance caching and `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` `prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
registry, `L.env.{scriptname, base_url, resource_version, media, registry, `L.env.{scriptname, base_url, resource_version, media,
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`, requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
`L.Poll.{queue, tick, timer, start, stop}` and the `poll-start/poll-stop` `L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of
events, `ui.menu.load()`'s session-cached tree with `satisfied` / `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_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()`, `ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
`network.js`'s uci-backed state, `Request.addInterceptor` / `network.js`'s uci-backed state, `Request.addInterceptor` /
`rpc.addInterceptor` and the `-32002` → `session.access` probe in `rpc.addInterceptor` and the `-32002` → `session.access` probe in
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding and `node.css` `setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` +
(schema since 7c6d8ff, 2026-08 — older trees simply carry no `css`), inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
`view.ut`'s `#view` + inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` / `node_weight` / alias re-dispatch semantics (ported line for line).
`node_weight` / alias re-dispatch semantics (ported verbatim). Live
verification so far: OpenWrt SNAPSHOT (2026-08, ipq60xx) — 23.05/24.10 by **Two surfaces are not the same across those branches**, and the resolver is
inspection, not yet on device. 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 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`, 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; `auth.login`; a `firstchild` candidate counts only if it resolves further;
`firstchild_ineligible` excluded; ties keep key order. The ACL check is `firstchild_ineligible` excluded; ties keep key order. The ACL check is
skipped because `/admin/menu` is already filtered for the session; skipped because `/admin/menu` is already filtered for the session;
- `wildcard` nodes accept trailing segments as request args, and — as the - `wildcard` nodes are descended into first — a segment that matches a
dispatcher does — run the node's `wildcardaction` (the `path/*` entry's `satisfied` child wins over arg capture — and only the remainder becomes
own action) when args are present and `action` for the bare path; request args; with args present the node's `wildcardaction` (the `path/*`
- a hop counter breaks cycles in a foreign `menu.d`. 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 → Two tracks are kept, as a full load keeps them: **requested** segments →
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved** `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 | | `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load | | Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
`rewrite` is deliberately not resolved: it is not in the tree and a splice `rewrite` is deliberately not resolved. The node and its action *are* in the
mistake opens the wrong page, which is worse than the reload it falls back to. 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 ### 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' })`, `intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
handler in order: 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 `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 (`L.Request` hands back a bare promise; the `XMLHttpRequest` only surfaces
running, so the generation is the correctness mechanism and the signal is on the *resolved* `Response`, too late to abort) or a `View.__init__`
hygiene. 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 2. **Teardown of the departing document state**, i.e. what a document
death would have done for free: death would have done for free:
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush - `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 package's request promise — a rejected one included — until
`unload()`, so a failed load left there would be handed to every later `unload()`, so a failed load left there would be handed to every later
view). Then, if `L.network` has been loaded, 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 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 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* 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 full load; saved changes live on the server and the Unsaved-changes
indicator is unaffected. indicator is unaffected.
- bare `setInterval`s registered since the router booted are cleared - bare `setInterval`s registered since the router booted are cleared
(`setInterval` is hooked at module eval; the one interval `L.Poll` (`setInterval`/`clearInterval` are hooked in `__init__`, i.e. when
owns is preserved). `setTimeout` and rAF are **not** touched: the `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 core keeps tooltips, notification timeouts and a request timeout on
`setTimeout`, and there is no self-rescheduling timeout in any `setTimeout`, and there is no self-rescheduling timeout in any
shipped view. shipped view.
@@ -296,9 +383,15 @@ handler in order:
credited to the class and released when a later warm render of the credited to the class and released when a later warm render of the
same class registers the same target/type, which proves them same class registers the same target/type, which proves them
per-render. 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 - `ui.hideModal()`, the theme's own surfaces (mega
menu, mobile drawer, palette) close. menu, mobile drawer, palette) close.
- page-scoped patch CSS is disabled and its JS patch unmounted (below). - 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`, 3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
`body[data-page]`, `document.title`. An alias is re-dispatched `body[data-page]`, `document.title`. An alias is re-dispatched
server-side, so `requestpath` and `data-page` carry the alias target while 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 already ran; LuCI's class system sets `prototype.constructor`, so
`new instance.constructor()` runs a fresh `__init__` → `load()` → `new instance.constructor()` runs a fresh `__init__` → `load()` →
`render()` → `dom.content('#view')`, exactly what a full load starts `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 - **completion** is observed, not assumed: a `MutationObserver` on the
staged element resolves when a non-spinner child lands (or the spinner 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 is removed for an empty render). Not completing within 15 s is a
**failure**, not a completion: committing the spinner and releasing the **failure**, not a completion: committing the spinner and releasing the
serialization would let the still-running chain paint into a later serialization would let the still-running chain paint into a later
navigation's `#view`, so the timeout rejects and the catch path navigation's `#view`, so the timeout rejects and the catch path
hard-loads the destination. On completion — hard-loads the destination. On completion — and only if this navigation
and only if this navigation is still the latest — the outgoing region is still the latest — the outgoing region
(everything between `#tabmenu` and `<footer>` except the staged (everything between `#tabmenu` and `<footer>` except the staged
element) is removed and the staged view is unhidden inside element) is removed and the staged view is unhidden inside
`document.startViewTransition()` when available and reduced motion is `document.startViewTransition()` when available and reduced motion is
off; the navigation's `finished` promise resolves after that swap. 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 - **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
`View.__init__` chain can be cancelled (`L.Request` never exposes its `View.__init__` chain can be cancelled (same reason), and every chain
handle), and every chain paints into *whichever* `#view` is first at paints into *whichever* `#view` is first at paint time. So a navigation
paint time. So a navigation first awaits the previous one's completion first awaits the previous one's completion
(bounded by the same timeout) before it tears anything down or stages (bounded by the same timeout) before it tears anything down or stages
anything — the previous chain finishes into its own staged element, anything — the previous chain finishes into its own staged element,
which is then discarded. Rapid A→B→C therefore never interleaves: 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 may still paint. That first render also runs inside a render window
(opened after the router's own listeners are registered), so the (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 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 ones it registered before the router loaded are out of reach. The cost
that a click during a slow load waits for that load; the alternative — is that a click during a slow load waits for that load; the alternative —
wrapping `prototype.render` per class and repairing stale cold renders wrapping `prototype.render` per class and repairing stale cold renders
by re-navigating — leaves a real window open and needs three mechanisms by re-navigating — leaves a real window open and needs three mechanisms
where one suffices. where one suffices.
@@ -420,7 +519,11 @@ handler in order:
motion drops the transitions, not the bar. motion drops the transitions, not the bar.
10. Any exception → `console.error` (a silent fallback makes every router 10. Any exception → `console.error` (a silent fallback makes every router
regression look like "the page is just slow") → `location.href = 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 ## 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 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 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 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 tall). Hence a gate, not a sweep: before intercepting, any sheet outside `#view`
`#view` that is not one of the theme's own — header.ut marks everything it that is not one of the theme's own marks the document **poisoned** and the
renders (`data-aurora-shell` on `main.css`, the font, custom and token 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 `<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 menu.d node css). The boot snapshot the gate compares against is filtered by
full load — the fresh document carries no view CSS, so the router resumes those markers, so a sheet the boot page's own modules inserted before the
immediately. The markers, not a boot snapshot, define "own": the boot page's router loaded still counts as foreign instead of being grandfathered in for
modules load concurrently with the router, and a sheet they inserted before the rest of the document. Correctness over speed, never the other way.
it snapshotted would have counted as the theme's 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 An owner-based refinement (stamp each sheet with the inserting module off
the call stack, enable it for pages whose dependency closure holds that 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()`, 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()` 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 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 misses the HTTP cache. The walk is transitive: the fetched body's **first
for its leading `'require x'` string literals with a regex that is **not** 4 KB** is scanned for the leading run of `'require x'` string literals with
line-anchored (shipped files are minified onto one line), and dotted names a regex that is **not** line-anchored (shipped files are minified onto one
are warmed the same way; dotless names are either luci-base's file-less 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 built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
libraries the chrome has already loaded, so they are declined outright. libraries the chrome has already loaded, so they are declined outright.
Deduplicated per class name; stops once a navigation to that link has 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 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. CPU. Browsers without the Navigation API keep the rules and the MPA path.
- **No `unload`/`beforeunload`**, ever (bfcache). - **No `unload`/`beforeunload`**, ever (bfcache).
- **No cancellation of in-flight XHR** — `L.Request` gives no handle; the - **No cancellation of in-flight XHR** — there is no handle to cancel with
generation gate makes it a waste, not a bug. Upstream-only. (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 - **No sweeping of a view's global listeners or timeouts** — one-way
deletions of module-eval registrations. If a per-render offender ever deletions of module-eval registrations. If a per-render offender ever
appears, the answer is a targeted teardown, not a global hook. 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 The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
set of enabled node-css links and the live-region text against the full 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. 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 - Device (`bench-fullload.mjs`, CDP): where one full load's time goes —
boundaries; N2 keeps document prefetch for the non-router path. 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`.
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
LUCI_DEPENDS:=+luci-base LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.2.3 PKG_VERSION:=1.2.3
PKG_RELEASE:=68 PKG_RELEASE:=69
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:= LUCI_MINIFY_CSS:=
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=23 PKG_RELEASE:=24
endif endif
LUCI_TITLE:=Footstrap Theme 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 * 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. * 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 * WHAT IS ASKED IS ALSO NOT WHAT IT USED TO ASK. "Does this element currently overflow" is a
* the switch between them is a width change, which `onResize` already watches and stamps. So the * property of the CONTENT, and it was being memoised against a stamp that only moves when a WIDTH
* verdict is cached against that stamp and the offset is then read from the scroller directly — * does: open a short page (no overflow, answer cached as "the window scrolls"), navigate to a tall
* `scrollTop` on an element and `scrollY` on the window are both free. */ * one — `#view` keeps its identity and its width, so nothing bumps the stamp — and every pass went
let _scroller = null, _scrollerAt = -1; * 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() { 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'); 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; _scrollerAt = _resizeSeq;
_scrollerKey = key;
return _scroller; return _scroller;
} }
function scrollTop() { function scrollTop() {
@@ -346,13 +366,14 @@ function scheduleAnchor(ref) {
} }
function applyAnchor(ref) { function applyAnchor(ref) {
if (!ref || !ref.el.isConnected) return; if (!ref || !ref.el.isConnected) return;
const sc = document.getElementById('maincontent'); /* through scroller(), not a second probe of its own: the two asked the same question in the
const inner = sc && sc.scrollHeight - sc.clientHeight > 4; * same two lines and could already answer differently within one frame */
const at = inner ? sc.scrollTop : window.scrollY; const sc = scroller();
const at = sc ? sc.scrollTop : window.scrollY;
if (at <= 0) return; if (at <= 0) return;
const drift = ref.el.getBoundingClientRect().top - ref.top; const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return; if (Math.abs(drift) < 1) return;
if (inner) sc.scrollTop = at + drift; if (sc) sc.scrollTop = at + drift;
else window.scrollTo(0, at + drift); else window.scrollTo(0, at + drift);
} }
@@ -409,7 +430,13 @@ return baseclass.extend({
if (typeof fit !== 'function') return; if (typeof fit !== 'function') return;
_fitters.push(fit); _fitters.push(fit);
observeContent(); 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 /* 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.) */ * observer re-fits synchronously, and that is rule 2's whole point.) */
schedule, schedule,
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs /* 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 * 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 * 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 = []; let _paused = [];
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { 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 = []; _paused = [];
for (const [ id, spec ] of _viewIntervals) { for (const [ id, spec ] of _viewIntervals) {
if (id === keep) continue;
_paused.push(spec); _paused.push(spec);
_ci.call(window, id); _ci.call(window, id);
_viewIntervals.delete(id); _viewIntervals.delete(id);
@@ -72,43 +90,52 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
} }
}); });
})(); })();
function clearViewIntervals() { /* WHICH id IS LuCI'S OWN TICK — asked in ONE place, because two callers need the answer and both
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/ * pay the same price for getting it wrong: the navigation sweep below, and the hidden-tab pause in
* `start`/`stop`/`active` are the documented surface, and upstream has already marked the whole * the interval hook above (which used to take the tick with the view timers and hand it back on an
* `L.Poll` alias deprecated (`'require poll'` is its replacement, and neither 24.10 nor 25.12 * id L.Poll had never heard of).
* 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 * `L.Poll.timer` is that id, and it is private state — `add`/`remove`/`start`/`stop`/`active` are
* on every later page silently dead, from a rename we did not notice. So the missing field is * the documented surface, and upstream has already marked the whole `L.Poll` alias deprecated
* not a null: it is a reason to do NOTHING, once, loudly. A view's leftover interval outliving * (`'require poll'` is its replacement, and neither 24.10 nor 25.12 ships poll.js yet, so the alias
* its page costs a wasted RPC; killing the global tick costs the router's live data. */ * is still the only way in). If the field is ever renamed, a caller reading it blind would treat
/* Asked through the DOCUMENTED half first: active() says whether LuCI's tick is running at all, * LuCI's tick as a view's: cleared on the next navigation, every poll on every later page silently
* and `timer` is deleted by stop() — so an absent field is the ordinary "nothing to protect" * dead. So a missing field is not a null — it is a reason to do NOTHING, once, loudly. A view's
* case on a page with no pollers, not a sign that upstream moved it. The anomaly worth reporting * leftover interval outliving its page costs a wasted RPC; losing the global tick costs the
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know. * router's live data.
* 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. */ * Asked through the DOCUMENTED half first: `active()` says whether the tick is running at all, and
/* …and the ALIAS ITSELF is the first thing that can go — it is the deprecation the paragraph * `timer` is deleted by `stop()` — so an absent field is the ordinary "nothing to protect" case on
* above is about, so this function cannot be the one place that reads it blind. Every other * a page with no pollers, not a sign that upstream moved anything. The anomaly worth reporting is
* `L.Poll` read in this file is guarded, and an unguarded one here throws where a throw costs * the pair DISAGREEING: a tick that is running while the id it runs on has no name we know.
* 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 * The alias itself is the first thing that can go, so this cannot be the one place that reads it
* showing the previous page's content under the new page's title and menu mark, with the * unguarded either: the sweep is called inside the staged render, after the chrome has switched to
* navigation dead in a rejected promise — far worse than the leaked interval this exists to * the incoming page and before its module is required, and a TypeError there would leave every
* sweep. No alias is the same answer as an unreadable timer: do NOTHING, once, loudly. */ * 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) { if (!L.Poll) {
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be ' 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 ' + 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
+ 'updating for this luci-base.'); + 'updating for this luci-base.');
return; return false;
} }
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null); const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
if (running && L.Poll.timer == null) { if (running && L.Poll.timer == null) {
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view ' 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 ' + 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
+ 'luci-base.'); + '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 */ /* 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); }); _viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
} }
@@ -1258,10 +1285,74 @@ function bootDocumentIsOurs() {
return tree.viewClassFor(node) != null; 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() { function wireRouter() {
if (_wired) return; if (_wired) return;
_wired = true; _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()) if (!bootDocumentIsOurs())
return; return;
@@ -1409,6 +1500,9 @@ return baseclass.extend({
wire: wireRouter, wire: wireRouter,
wireVisibility, wireVisibility,
onNavigate, 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 /* 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 * 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. */ * round — search → router — because the router must keep no dependency on the palette. */
+17 -3
View File
@@ -654,9 +654,23 @@
100% { opacity: 0; } 100% { opacity: 0; }
} }
/* .fade-in's animation: theme/45-misc.css (fs-fade) — absorbed. The @keyframes above /* THE `fade-in` KEYFRAMES ABOVE STAY THOUGH NOTHING HERE RIDES THEM: `fade-in` is an
* STAY though nothing here rides them: `fade-in` is an upstream animation NAME a * upstream animation NAME a luci-app-* may ride (coverage contract, docs/conventions.md).
* 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 { .fade-out {
animation: fade-out var(--fs-dur-fade); animation: fade-out var(--fs-dur-fade);
} }
@@ -436,6 +436,34 @@
{% endif %} {% endif %}
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48"> <link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
<link rel="icon" href="{{ media }}/logo.svg" sizes="any"> <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): %} {% if (dispatched?.css): %}
{# The `css` property of the dispatched menu.d node — an app naming a stylesheet the server {# 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 should link for its page, instead of the view injecting one at module eval (which is what
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=modeminfo PKG_NAME:=modeminfo
PKG_VERSION:=0.4.8 PKG_VERSION:=0.4.8
PKG_RELEASE:=10 PKG_RELEASE:=11
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru> PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
@@ -1,7 +1,10 @@
ATE0 ATE0
AT+COPS=3,0; +COPS?; +CREG? AT+COPS=3,0; +COPS?; +CREG?
AT+CEREG=2; +CEREG? AT+CEREG=2
AT+CGREG=2; +CGREG?; +CSQ AT+CEREG?
AT+CGREG=2
AT+CGREG?
AT+CSQ
AT+GTCCINFO?; +GTCAINFO? AT+GTCCINFO?; +GTCAINFO?
AT*CNTI=0 AT*CNTI=0
AT+MTSM=1 AT+MTSM=1