🏅 Sync 2026-07-25 20:22:21

This commit is contained in:
github-actions[bot] committed 2026-07-25 20:22:21 +08:00
1 parent 9c077adbc3
commit db661eb60a
19 files changed
+1051 -118

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gowebdav
PKG_VERSION:=5.14.0
PKG_RELEASE:=17
PKG_VERSION:=5.14.1
PKG_RELEASE:=18
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)?
@@ -773,12 +773,6 @@ function renderListeners(s, uciconfig, isClient) {
o.depends({type: 'shadowquic'});
o.modalonly = true;
o = s.taboption('field_plugin', form.Flag, 'plugin_opts_quic_version_probe', _('QUIC version probe'),
_('Probe the QUIC version of the handshake target during the first connection.'));
o.default = o.disabled;
o.depends({type: 'shadowquic'});
o.modalonly = true;
/* Vless Encryption fields */
o = s.taboption('field_general', form.Flag, 'vless_decryption', _('decryption'));
o.default = o.disabled;
@@ -340,8 +340,7 @@ export function parseListener(cfg) {
addr: cfg.plugin_opts_handshake_dest,
sni: cfg.plugin_opts_host,
proxy: cfg.plugin_opts_dest_proxy, // raw data need post-processing
"rate-limit": strToInt(cfg.plugin_opts_rate_limit),
"quic-version-probe": strToBool(cfg.plugin_opts_quic_version_probe)
"rate-limit": strToInt(cfg.plugin_opts_rate_limit)
} : null,
cwnd: strToInt(cfg.shadowquic_cwnd),
"max-datagram-frame-size": strToInt(cfg.shadowquic_max_datagram_frame_size),
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-qosmate
PKG_VERSION:=1.0.14
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_MAINTAINER:=Markus Hütter <mh@hudra.net>
PKG_LICENSE:=GPL-3.0-or-later
@@ -50,6 +50,31 @@ function isSfoEnabled() {
return uci.get('firewall', '@defaults[0]', 'flow_offloading') === '1';
}
// The auto-setup runs detached, because a speed test regularly takes longer than uhttpd's
// script_timeout (60s by default) and a request waiting for the whole run gets killed. Progress is
// followed in the output file, which the backend ends with the AUTO_SETUP_DONE marker.
function pollAutoSetupOutput(outputFile, tries, lastError) {
// read_direct, because ubus file.read stops at the first NUL byte and the output file can
// contain NUL padding while it is still being written
return fs.read_direct(outputFile).catch(function(err) { lastError = err; return ''; }).then(function(output) {
if (/^AUTO_SETUP_DONE/m.test(output))
return output;
if (tries <= 0)
return Promise.reject(new Error(lastError
? _('Auto setup did not finish, last error: ') + lastError
: _('Auto setup did not finish within 5 minutes.')));
var status = document.getElementById('qosmate-auto-setup-status');
var lines = output.replace(/\r/g, '\n').trim().split('\n');
if (status && lines[lines.length - 1])
status.textContent = lines[lines.length - 1];
return new Promise(function(resolve) { window.setTimeout(resolve, 2000); })
.then(function() { return pollAutoSetupOutput(outputFile, tries - 1, lastError); });
});
}
function fetchVersionInfo() {
return fs.exec_direct('/etc/init.d/qosmate', ['check_version'])
.then(function(output) {
@@ -615,35 +640,52 @@ return view.extend({
'click': ui.createHandlerFn(this, function() {
var gamingIp = document.getElementById('gaming_ip').value;
ui.showModal(_('Running Auto Setup'), [
E('p', { 'class': 'spinning' }, _('Please wait while the auto setup is in progress...')),
E('p', { 'class': 'spinning', 'id': 'qosmate-auto-setup-status' }, _('Please wait while the auto setup is in progress...')),
E('div', { 'style': 'margin-top: 1em; border-top: 1px solid #ccc; padding-top: 1em;' }, [
E('p', { 'style': 'font-weight: bold;' }, _('Note:')),
E('p', _('Router-based speed tests may underestimate actual speeds. These results serve as a starting point and may require manual adjustment for optimal performance.'))
])
]);
return fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_noninteractive', gamingIp])
return fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_detached', gamingIp])
.then(function(res) {
var outputFile = res.trim();
return fs.read(outputFile).then(function(output) {
var outputFile = (res || '').trim();
// a backend without auto_setup_detached prints its usage instead of a path
var run = (outputFile.charAt(0) === '/')
? pollAutoSetupOutput(outputFile, 150)
: fs.exec_direct('/etc/init.d/qosmate', ['auto_setup_noninteractive', gamingIp])
.then(function(res2) { return fs.read_direct((res2 || '').trim()); });
return run.then(function(output) {
ui.hideModal();
var wanInterface = output.match(/Detected WAN interface: (.+)/);
var downloadSpeed = output.match(/Download speed: (.+) Mbit\/s/);
var uploadSpeed = output.match(/Upload speed: (.+) Mbit\/s/);
var downrate = output.match(/DOWNRATE: (.+) kbps/);
var uprate = output.match(/UPRATE: (.+) kbps/);
var downloadSpeed = output.match(/Download speed: ([\d.]+) Mbit\/s/);
var uploadSpeed = output.match(/Upload speed: ([\d.]+) Mbit\/s/);
var downrate = output.match(/DOWNRATE: (\d+) kbps/);
var uprate = output.match(/UPRATE: (\d+) kbps/);
if (!downloadSpeed || !uploadSpeed || parseFloat(downloadSpeed[1]) <= 0 || parseFloat(uploadSpeed[1]) <= 0 ||
!downrate || !uprate || parseInt(downrate[1]) <= 0 || parseInt(uprate[1]) <= 0) {
ui.addNotification(null, E('p', _('Invalid speed test results. Please try again or set values manually.')), 'error');
ui.addNotification(null, [
E('p', _('Invalid speed test results. Please try again or set values manually.')),
E('pre', { 'style': 'white-space: pre-wrap; max-height: 12em; overflow: auto;' },
output.trim().split('\n').slice(-10).join('\n'))
], 'error');
return;
}
var gamingRules = output.match(/Gaming device rules added for IP: (.+)/);
}
// 'rules?' keeps this working with backends that report a single rule
var gamingRules = output.match(/Gaming device rules? added for IP: (.+)/);
var existingRule = output.match(/A rule for IP (.+) already exists/);
var warnings = output.match(/^Warning: .+$/gm) || [];
ui.showModal(_(''), [
E('h2', { 'style': 'text-align:center; margin-bottom: 1em;' }, _('Auto Setup Results')),
E('h3', { 'style': 'margin-bottom: 0.5em;' }, _('Speed Test Results')),
E('p', { 'style': 'color: orange; margin-bottom: 1em;' }, _('Note: Router-based speed tests may underestimate actual speeds. For best results, consider running tests from a LAN device and manually entering the values. These results serve as a starting point.')),
warnings.length ? E('div', { 'class': 'alert-message warning', 'style': 'margin-bottom: 1em;' },
warnings.map(function(line) { return E('p', {}, line.replace(/^Warning: /, '')); })) : '',
E('div', { 'style': 'display: table; width: 100%;' }, [
E('div', { 'style': 'display: table-row;' }, [
E('div', { 'style': 'display: table-cell; padding: 5px; font-weight: bold;' }, _('WAN Interface')),
@@ -669,9 +711,10 @@ return view.extend({
E('div', { 'style': 'display: table-cell; padding: 5px;' }, uprate ? uprate[1] + ' kbps' : _('Not set'))
])
]),
gamingRules ? E('div', { 'style': 'margin-top: 1em;' }, [
(gamingRules || existingRule) ? E('div', { 'style': 'margin-top: 1em;' }, [
E('div', { 'style': 'font-weight: bold;' }, _('Gaming Rules')),
E('div', {}, _('Added for IP: ') + gamingRules[1])
E('div', {}, gamingRules ? _('Added for IP: ') + gamingRules[1]
: _('Rule already exists for IP: ') + existingRule[1])
]) : '',
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
E('button', {
+20 -1
View File
@@ -173,6 +173,22 @@ Two rules of thumb that follow from prefix matching:
> Unlike the `_`-prefixed partials (which are `@import`-only fragments), patch filenames have no `_` prefix — each is a real build entry that ships to `htdocs/`.
### Mock Pages
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading.
- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and press <kbd>Alt/Option+Shift+S</kbd> (or call `__auroraMockCapture()` in the console). The injected `scripts/mock-capture.client.js` helper POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline `<style>`, and the theme's own header/nav markup. Drop a snapshot captured under `luci-theme-shadcn` in here (or vice versa) and the page renders **completely unstyled**, because a dev server only serves its own `/luci-static/<theme>/` prefix. The giveaway is a terminal line naming the other theme's stylesheet:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
Mirroring, which that generic hint suggests, is the wrong fix here — re-capture the page from a device running this theme. If you must reuse a foreign snapshot anyway, only the app's own content region means anything: point its stylesheet links at this theme, and delete the inline `<style>` block, or the captured device's colors override this checkout's tokens.
- **View:** `pnpm dev`, then open <http://localhost:5173/mocks/> — an auto-generated index lists every snapshot with its `data-page` and age. The `mock-pages-plugin` (in `vite.config.ts`) serves each page with the Vite HMR client injected, so editing any theme source (`main.css`, a component, a `patches/*.css`, or served JS) triggers the usual full reload (see [Live Reload Behavior](#live-reload-behavior)). The snapshot keeps its absolute `/luci-static/…` links; theme CSS/JS, fonts and images resolve locally and compile on the fly. Serving prepends the `<!doctype html>` that `outerHTML` captures drop, so mocks render in standards mode exactly like the real page.
- **Navigate between snapshots in place:** `scripts/mock-nav.client.js` (injected into every mock) resolves clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`) against the captured snapshots by `data-page` (exact match) and jumps straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. A floating switcher (bottom-left) lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons), and links back to the index.
- **Third-party assets:** an app's own css/js the snapshot references (e.g. `qmodem-next.css`, or a device-only custom logo) isn't in this repo. To serve it, mirror its URL under `.dev/mocks/static/` (e.g. `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`); files there are served as-is (no HMR). Misses requested by a mock page 404 immediately — never proxied to the router, so mocks stay fully offline-capable — and each miss prints a one-time terminal hint with the exact mirror path. (CSS-initiated requests, e.g. nav icons, carry the stylesheet's URL as referer and can't be attributed to the mock page; any `/luci-static` request that falls through to the proxy is therefore bounded to 5s and answers 504 when the router is unreachable.) The theme still applies without them.
- **No auth, no runtime:** a snapshot is static DOM, so `mock-pages-plugin` strips LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js` and `/cgi-bin/` endpoints) and injects a no-op `L`/`LuCI`/`XHR` stub before serving. Without this, LuCI boots, polls the backend, gets 403 (no session) and pops the "Session expired" modal. The trade-off: framework-dependent theme JS (e.g. `menu-aurora`) no-ops in mocks — the captured DOM is already rendered, so it still looks right. The theme's own inline scripts (dark mode, toolbar state) and any `src/media/` JS still run.
### Design Tokens
`src/media/_tokens.css` is **generated** — its header says "DO NOT EDIT". The source of truth is the standalone [`@eamonxg/aurora-tokens`](https://github.com/eamonxg/aurora-tokens) npm package, consumed here as a devDependency:
@@ -284,12 +300,15 @@ luci-theme-aurora/
├── .dev/ # Development environment
│ ├── docs/ # Project documentation
│ │ └── DEVELOPMENT.md # Development guide (this file)
│ ├── mocks/ # Local page snapshots for /mocks/ (git-ignored, see Mock Pages)
│ ├── public/aurora/ # Public static assets
│ │ ├── fonts/ # Web fonts (Lato)
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts
│ ├── scripts/ # Build scripts + dev-server client helpers
│ │ ├── clean.js # Build cleanup utility
│ │ ├── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
│ │ ├── mock-capture.client.js # Injected into proxied device pages — hotkey capture to /mocks/__save
│ │ ├── mock-nav.client.js # Injected into /mocks/ pages — link takeover + floating switcher
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
@@ -0,0 +1,75 @@
/**
* Aurora dev helper — injected by the /cgi-bin dev proxy (vite.config.ts)
* into real device-served LuCI pages. Never ships to the router.
*
* Alt/Option+Shift+S — or __auroraMockCapture() from the console — POSTs the
* live DOM to /mocks/__save, which writes .dev/mocks/<data-page>.html for the
* /mocks/ workflow (see "Mock Pages" in .dev/docs/DEVELOPMENT.md).
*/
(() => {
"use strict";
// Mock pages are served, not proxied, so this only runs on real pages —
// the guard is just belt and braces against a stale captured tag.
if (window.__auroraMockCapture || location.pathname.startsWith("/mocks/"))
return;
let toastEl;
let toastTimer;
const toast = (text, ok) => {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.style.cssText =
"position:fixed;left:50%;bottom:32px;transform:translateX(-50%);" +
"z-index:2147483647;padding:9px 14px;border-radius:10px;" +
"font:13px/1.5 system-ui,-apple-system,sans-serif;" +
"background:rgba(18,18,24,.9);box-shadow:0 8px 32px rgba(0,0,0,.35);" +
"backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"transition:opacity 160ms ease;pointer-events:none;max-width:80vw";
document.documentElement.appendChild(toastEl);
}
toastEl.textContent = text;
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
toastEl.style.opacity = "1";
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toastEl.style.opacity = "0"), 3000);
};
const capture = async () => {
try {
const response = await fetch("/mocks/__save", {
method: "POST",
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Aurora-Capture": "1",
},
body: "<!doctype html>\n" + document.documentElement.outerHTML,
});
const info = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(info.error || `HTTP ${response.status}`);
toast(`已捕获 ${info.file} — 在 /mocks/ 打开`, true);
} catch (err) {
toast(`捕获失败:${(err && err.message) || err}`, false);
}
};
window.__auroraMockCapture = capture;
addEventListener("keydown", (event) => {
if (
event.altKey &&
event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
event.code === "KeyS"
) {
event.preventDefault();
capture();
}
});
console.info(
"[aurora] mock capture ready — Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
);
})();
@@ -0,0 +1,289 @@
/**
* Aurora dev helper — injected by mock-pages-plugin (vite.config.ts) into
* every page served under /mocks/. Never ships to the router.
*
* 1. Navigation takeover: clicks on the snapshot's own LuCI links
* (/cgi-bin/luci/…) resolve against the captured snapshots — matched by
* each snapshot's <body data-page>, exact match — and jump to the matching
* mock instead of falling through to the proxied router. Uncaptured
* targets are blocked with a hint naming the missing snapshot.
* 2. Floating switcher (bottom-left; the theme's own floating toolbar owns
* the bottom-right corner). Shadow DOM, so theme/patch CSS can't restyle
* it and its styles can't leak into the page under review. Lists every
* snapshot, cycles with the ‹/› buttons or the [ and ] keys, links back
* to the /mocks/ index.
*
* Data contract (injected inline before this script):
* window.__AURORA_MOCKS__ = { current: "<file>", mocks: [{ file, page }] }
*/
(() => {
"use strict";
const data = window.__AURORA_MOCKS__;
if (!data || !Array.isArray(data.mocks) || !data.mocks.length) return;
const mocks = data.mocks;
const byPage = new Map(
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
);
const current = Math.max(
mocks.findIndex((m) => m.file === data.current),
0,
);
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
const displayName = (file) => file.replace(/\.html$/, "");
const escapeHtml = (s) =>
s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
// ---- floating switcher ---------------------------------------------------
const host = document.createElement("aurora-mock-nav");
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2147483000;
font: 12px/1.5 system-ui, -apple-system, sans-serif;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
padding: 0;
}
.pill {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 999px;
background: rgba(18, 18, 24, 0.82);
color: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.step {
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 14px;
opacity: 0.75;
}
.step:hover {
background: rgba(255, 255, 255, 0.14);
opacity: 1;
}
.name {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 6px;
border-radius: 999px;
}
.name:hover {
background: rgba(255, 255, 255, 0.14);
}
.count {
opacity: 0.6;
margin-left: 4px;
}
.panel {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: 260px;
max-width: 360px;
max-height: 60vh;
overflow: auto;
padding: 6px;
border-radius: 12px;
background: rgba(18, 18, 24, 0.92);
color: #fff;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.panel[hidden] {
display: none;
}
.panel a {
display: block;
padding: 6px 10px;
border-radius: 8px;
color: #fff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel a:hover {
background: rgba(255, 255, 255, 0.12);
}
.panel a.current {
color: #4ade80;
}
.panel a.current::before {
content: "● ";
font-size: 9px;
vertical-align: 1px;
}
.foot {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
padding: 6px 10px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.55);
}
.foot a {
display: inline;
padding: 0;
color: inherit;
}
.foot a:hover {
color: #fff;
background: none;
}
.toast {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
max-width: 320px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(18, 18, 24, 0.92);
color: #ffd166;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 160ms ease;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
<div class="panel" hidden></div>
<div class="toast"></div>
<div class="pill">
<button class="step prev" title="上一个快照 [">‹</button>
<button class="name" title="全部快照"></button>
<button class="step next" title="下一个快照 ]">›</button>
</div>
`;
const panel = root.querySelector(".panel");
const toastEl = root.querySelector(".toast");
const nameBtn = root.querySelector(".name");
nameBtn.innerHTML =
escapeHtml(displayName(data.current)) +
`<span class="count">${current + 1}/${mocks.length}</span>`;
panel.innerHTML =
mocks
.map(
(m) =>
`<a href="${mockUrl(m.file)}"` +
(m.file === data.current ? ' class="current"' : "") +
` title="${escapeHtml(m.page || m.file)}">` +
`${escapeHtml(displayName(m.file))}</a>`,
)
.join("") +
'<div class="foot"><a href="/mocks/">全部快照 ↗</a><span>[ / ] 切换</span></div>';
const cycle = (step) => {
if (mocks.length < 2) return;
const next = mocks[(current + step + mocks.length) % mocks.length];
location.href = mockUrl(next.file);
};
root.querySelector(".prev").addEventListener("click", () => cycle(-1));
root.querySelector(".next").addEventListener("click", () => cycle(1));
nameBtn.addEventListener("click", () => {
panel.hidden = !panel.hidden;
});
document.addEventListener("click", (event) => {
if (!panel.hidden && !event.composedPath().includes(host)) {
panel.hidden = true;
}
});
let toastTimer;
const toast = (text) => {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600);
};
document.documentElement.appendChild(host);
// ---- navigation takeover -------------------------------------------------
const pageFromHref = (href) => {
try {
const url = new URL(href, location.href);
if (url.origin !== location.origin) return null;
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
if (!match) return null;
return decodeURIComponent(match[1] || "")
.split("/")
.filter(Boolean)
.join("-");
} catch {
return null;
}
};
document.addEventListener(
"click",
(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const target = event.target instanceof Element ? event.target : null;
const anchor = target && target.closest("a[href]");
if (!anchor) return;
const page = pageFromHref(anchor.getAttribute("href"));
if (page === null) return;
// Never let a click inside a mock fall through to the proxied router.
event.preventDefault();
const file = byPage.get(page);
if (file) location.href = mockUrl(file);
else
toast(
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
);
},
true,
);
const isEditable = (el) =>
el instanceof Element &&
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditable(event.target)) return;
if (event.key === "[") cycle(-1);
else if (event.key === "]") cycle(1);
});
})();
@@ -6,9 +6,14 @@
@import "tailwindcss";
@import "./_tokens.css";
@import "./_reduced-motion.css";
@import "./_base.css";
/* See main.css: keep dev-only tooling out of Tailwind's source detection. */
@source not "../../vite.config.ts";
@source not "../../scripts/*.client.js";
@custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *));
@keyframes divider-in {
@@ -5,6 +5,13 @@
@import "tailwindcss";
/* Dev-only tooling holds prose and plain CSS whose bare words (static,
transition, relative, …) Tailwind's automatic source detection would
otherwise read as utility candidates and emit into this shipped stylesheet.
Nothing here renders markup, so none of it belongs in the scan. */
@source not "../../vite.config.ts";
@source not "../../scripts/*.client.js";
@custom-variant dark (&:where([data-darkmode=true], [data-darkmode=true] *));
@plugin "tailwind-scrollbar" {
+427 -1
View File
@@ -6,8 +6,9 @@
import tailwindcss from "@tailwindcss/vite";
import browserslist from "browserslist";
import { exec } from "child_process";
import { existsSync, readdirSync, statSync } from "fs";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import type { IncomingMessage, ServerResponse } from "http";
import { browserslistToTargets } from "lightningcss";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { minify as terserMinify } from "terser";
@@ -182,6 +183,420 @@ function createLocalServePlugin(): Plugin {
};
}
// Mock pages: render saved LuCI page snapshots against the live theme, so a
// third-party app's page can be styled without the app (or a device) installed.
// Snapshots live in .dev/mocks/*.html and are served at /mocks/<name>.html
// with the Vite HMR client injected, so theme edits (main.css, components,
// patches/*.css, served JS) trigger the existing full-reload in handleHotUpdate.
// Any third-party asset a snapshot needs (the app's own css/js, e.g.
// qmodem-next.css) goes under .dev/mocks/static/ mirroring its /luci-static/…
// URL and is served as-is (no HMR). Served snapshots additionally get
// scripts/mock-nav.client.js (links between captured pages navigate in place,
// plus a floating switcher), and proxied device pages get
// scripts/mock-capture.client.js (hotkey → POST /mocks/__save captures the
// open page as a snapshot). See "Mock Pages" in .dev/docs/DEVELOPMENT.md.
const MOCK_ROUTE = "/mocks";
const MOCKS_DIR = resolve(CURRENT_DIR, "mocks");
const MOCKS_STATIC_DIR = join(MOCKS_DIR, "static");
const MOCK_NAV_CLIENT = resolve(CURRENT_DIR, "scripts/mock-nav.client.js");
const MOCK_CAPTURE_CLIENT = resolve(
CURRENT_DIR,
"scripts/mock-capture.client.js",
);
// A captured LuCI page is tens of KB — anything past this is not a page.
const MOCK_SAVE_LIMIT = 20 * 1024 * 1024;
const MOCK_MIME: Record<string, string> = {
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".html": "text/html; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json; charset=utf-8",
};
function mockContentType(file: string): string {
const ext = file.slice(file.lastIndexOf("."));
return MOCK_MIME[ext] ?? "application/octet-stream";
}
// document.documentElement.outerHTML (the documented manual capture) drops the
// doctype; without one the browser renders the mock in quirks mode and layout
// no longer matches the real page. Every serve and save path funnels through
// this.
function ensureDoctype(html: string): string {
return /^\s*<!doctype/i.test(html) ? html : `<!doctype html>\n${html}`;
}
const escapeHtml = (s: string): string =>
s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!,
);
// Inject the Vite HMR client so a served snapshot joins the WS channel and
// receives handleHotUpdate's full-reload broadcast on any theme source change.
function injectHmrClient(html: string): string {
if (html.includes("/@vite/client")) return html;
const tag = `\n <script type="module" src="/@vite/client"></script>\n`;
return html.includes("</head>")
? html.replace("</head>", `${tag} </head>`)
: tag + html;
}
// A snapshot is static DOM. Left alone, LuCI's runtime (luci.js + the inline
// `new LuCI(...)` bootstrap) boots, polls the backend, gets 403 (no session)
// and pops the "Session expired" modal. Neutralise it for mock rendering:
// strip the scripts that phone home (luci.js/cbi.js/xhr.js and /cgi-bin/
// endpoints) and pre-define a no-op `L`/`LuCI`/`XHR` stub so the remaining
// inline bootstraps (`new LuCI(...)`, `L.require(...)`, legacy `XHR.poll(...)`)
// run harmlessly. Theme CSS/JS and the theme's own inline scripts (dark mode,
// toolbar) are untouched; framework-dependent theme JS such as menu-aurora
// simply no-ops — the captured DOM is already fully rendered, so a static
// review still looks right.
const MOCK_RUNTIME_GUARD = `<script>
(function () {
var stub = new Proxy(function () {}, {
get: function () { return stub; },
apply: function () { return stub; },
construct: function () { return stub; },
});
window.L = stub;
window.LuCI = stub;
window.XHR = stub;
})();
</script>`;
function neutralizeLuciRuntime(html: string): string {
const stripped = html.replace(
/<script\b[^>]*\bsrc="[^"]*(?:\/luci-static\/resources\/(?:luci|cbi|xhr)\.js|\/cgi-bin\/)[^"]*"[^>]*>\s*<\/script>/gi,
"",
);
return stripped.includes("</head>")
? stripped.replace(/<head\b[^>]*>/i, (m) => `${m}\n ${MOCK_RUNTIME_GUARD}`)
: MOCK_RUNTIME_GUARD + stripped;
}
// Snapshot inventory for the index and for in-mock navigation. A snapshot's
// identity is its <body data-page> (filenames are free), so each file is
// scanned for it once and re-read only when its mtime changes.
interface MockEntry {
file: string;
page: string | null;
mtimeMs: number;
}
const mockMeta = new Map<string, { mtimeMs: number; page: string | null }>();
function listMocks(): MockEntry[] {
if (!existsSync(MOCKS_DIR)) return [];
return readdirSync(MOCKS_DIR, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith(".html"))
.map((d) => {
const path = join(MOCKS_DIR, d.name);
const mtimeMs = statSync(path).mtimeMs;
let meta = mockMeta.get(d.name);
if (!meta || meta.mtimeMs !== mtimeMs) {
const page = readFileSync(path, "utf-8").match(
/<body\b[^>]*\bdata-page="([^"]*)"/i,
)?.[1];
meta = { mtimeMs, page: page || null };
mockMeta.set(d.name, meta);
}
return { file: d.name, page: meta.page, mtimeMs };
})
.sort((a, b) => a.file.localeCompare(b.file));
}
// Handing the snapshot list to the page lets mock-nav.client.js take over the
// snapshot's own /cgi-bin/luci/ links (jump to the matching mock instead of
// falling through to the proxied router) and render the floating switcher.
function injectMockNav(html: string, current: string): string {
const payload = JSON.stringify({
current,
mocks: listMocks().map(({ file, page }) => ({ file, page })),
}).replace(/</g, "\\u003c");
const tags =
`<script>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
` <script defer src="${MOCK_ROUTE}/__nav.js"></script>\n `;
return html.includes("</head>")
? html.replace("</head>", `${tags}</head>`)
: html + tags;
}
function readRequestBody(req: IncomingMessage, limit: number): Promise<string> {
return new Promise((resolveBody, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > limit) {
reject(Object.assign(new Error("body too large"), { statusCode: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
function relTime(mtimeMs: number): string {
const mins = Math.round((Date.now() - mtimeMs) / 60000);
if (mins < 60) return `${Math.max(mins, 0)}m`;
if (mins < 60 * 24) return `${Math.round(mins / 60)}h`;
return `${Math.round(mins / (60 * 24))}d`;
}
function renderMockIndex(entries: MockEntry[]): string {
const rows = entries.length
? entries
.map((e) => {
const meta = [e.page ?? "no data-page", relTime(e.mtimeMs)]
.map(escapeHtml)
.join(" · ");
return (
`<li><a href="${MOCK_ROUTE}/${encodeURIComponent(e.file)}">` +
`${escapeHtml(e.file)}<span class="meta">${meta}</span></a></li>`
);
})
.join("\n ")
: `<li class="empty">No snapshots yet — open a page through the dev proxy and press <code>Alt/Option+Shift+S</code>, or drop a page's HTML into <code>.dev/mocks/</code></li>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Aurora mock pages</title>
<style>
body{font:15px/1.6 system-ui,-apple-system,sans-serif;max-width:680px;margin:48px auto;padding:0 20px;color:#1a1a1a}
h1{font-size:20px;margin:0 0 4px}
p.sub{color:#888;font-size:13px;margin:0 0 24px}
ul{list-style:none;padding:0;margin:0}
li{margin:8px 0}
li a{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:8px 14px;border:1px solid #d0d0d0;border-radius:8px;text-decoration:none;color:#0a7d4b;font-weight:500}
li a .meta{color:#888;font-size:12px;font-weight:400;white-space:nowrap}
li a:hover{background:#f5f5f5}
li.empty{color:#888}
code{background:#f0f0f0;padding:1px 6px;border-radius:4px;font-size:13px}
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}li a{border-color:#333;color:#4ade80}li a:hover{background:#1c1c1c}code{background:#222}}
</style>
</head>
<body>
<h1>Aurora mock pages</h1>
<p class="sub">Saved snapshots served against the live theme. Edit theme CSS/JS → the open page hot-reloads; links between captured pages navigate in place.</p>
<ul>
${rows}
</ul>
</body>
</html>`;
}
function createMockPlugin(): Plugin {
const sendJson = (res: ServerResponse, status: number, body: unknown) => {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
};
// Each missing mock asset gets one terminal hint, not one per reload.
const mockMissLogged = new Set<string>();
return {
name: "mock-pages-plugin",
apply: "serve",
enforce: "pre",
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (!req.url) return next();
const [pathname] = req.url.split("?");
const inMocks =
pathname === MOCK_ROUTE || pathname.startsWith(`${MOCK_ROUTE}/`);
if (!inMocks && !pathname.startsWith("/luci-static/")) return next();
// Every branch answers inside this try: a stray request (malformed
// %-encoding, a directory named *.html, …) must degrade to an error
// response — thrown, it becomes an unhandled rejection and takes the
// whole dev server down with it.
try {
// Dev-helper client scripts (kept as real files for editability).
if (
pathname === `${MOCK_ROUTE}/__nav.js` ||
pathname === `${MOCK_ROUTE}/__capture.js`
) {
const file = pathname.endsWith("/__nav.js")
? MOCK_NAV_CLIENT
: MOCK_CAPTURE_CLIENT;
res.statusCode = 200;
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.end(await readFile(file, "utf-8"));
return;
}
// Capture endpoint, written to by mock-capture.client.js from
// proxied device pages. The custom header doubles as a CORS gate: a
// foreign origin can't send it without a preflight this server never
// approves, so drive-by cross-site POSTs are rejected.
if (pathname === `${MOCK_ROUTE}/__save`) {
if (req.method !== "POST")
return sendJson(res, 405, { error: "POST only" });
if (req.headers["x-aurora-capture"] !== "1")
return sendJson(res, 403, { error: "missing capture header" });
const html = (await readRequestBody(req, MOCK_SAVE_LIMIT))
// The live DOM carries the dev-only tags this server injected;
// a snapshot must stay a clean capture of the real page.
.replace(
/<script[^>]*\bsrc="(?:\/@vite\/client|\/mocks\/__capture\.js)"[^>]*>\s*<\/script>\s*/gi,
"",
);
const page =
html.match(/<body\b[^>]*\bdata-page="([^"]*)"/i)?.[1] ?? "";
const safe = page.replace(/[^\w.-]/g, "-").replace(/^\.+/, "");
const name = `${safe.slice(0, 120) || `snapshot-${Date.now()}`}.html`;
await mkdir(MOCKS_DIR, { recursive: true });
await writeFile(
join(MOCKS_DIR, name),
ensureDoctype(html),
"utf-8",
);
console.log(
`${tag("Mocks")} captured ${name}${page ? ` (${page})` : ""}`,
);
return sendJson(res, 200, { file: name, page: page || null });
}
// Index of available snapshots.
if (pathname === MOCK_ROUTE || pathname === `${MOCK_ROUTE}/`) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.end(renderMockIndex(listMocks()));
return;
}
// A single snapshot: LuCI runtime neutralised, HMR client + in-mock
// navigation injected, doctype restored.
if (inMocks && pathname.endsWith(".html")) {
const name = basename(decodeURIComponent(pathname));
const file = resolve(MOCKS_DIR, name);
if (
!file.startsWith(MOCKS_DIR + sep) ||
!existsSync(file) ||
!statSync(file).isFile()
) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
`<!doctype html><meta charset="utf-8"><p>Mock not found: ${escapeHtml(name)} — <a href="${MOCK_ROUTE}/">back to index</a>`,
);
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
const snapshot = await readFile(file, "utf-8");
res.end(
ensureDoctype(
injectMockNav(
injectHmrClient(neutralizeLuciRuntime(snapshot)),
name,
),
),
);
return;
}
// Third-party static drop-ins the snapshot references (the app's own
// css/js) — reached only when local-serve didn't already claim the
// path.
if (pathname.startsWith("/luci-static/")) {
const file = resolve(
MOCKS_STATIC_DIR,
decodeURIComponent(pathname.slice(1)),
);
if (
file.startsWith(MOCKS_STATIC_DIR + sep) &&
existsSync(file) &&
statSync(file).isFile()
) {
res.statusCode = 200;
res.setHeader("Content-Type", mockContentType(file));
res.setHeader("Cache-Control", "no-store");
res.end(await readFile(file));
return;
}
// A miss requested BY a mock page must fail fast, not fall
// through to the device proxy: with no reachable router the
// request just hangs, pinning the tab's load event for minutes
// (device-only assets like a custom logo hit this). Real proxied
// pages keep their normal fallthrough.
const referer = req.headers.referer ?? "";
if (
new URL(referer, "http://_").pathname.startsWith(`${MOCK_ROUTE}/`)
) {
if (!mockMissLogged.has(pathname)) {
mockMissLogged.add(pathname);
console.log(
`${tag("Mocks")} miss ${pathname} → 404 (mirror it at .dev/mocks/static${pathname} to serve it)`,
);
}
res.statusCode = 404;
res.setHeader("Cache-Control", "no-store");
res.end();
return;
}
// Whatever else reaches here on /luci-static/ is destined for the
// device proxy. Static files answer in milliseconds on a real
// router, so bound the wait: CSS-initiated requests (nav icons)
// carry the stylesheet's URL as referer and dodge the mock 404
// above — with no reachable device they would otherwise pin the
// tab's load event for minutes. Vite's proxy error handler
// tolerates an already-ended response, and /cgi-bin is untouched
// (dynamic endpoints can be legitimately slow).
const deadline = setTimeout(() => {
if (!res.headersSent && res.writable) {
console.log(
`${tag("Proxy")} ${pathname} → 504 after 5s (router unreachable?)`,
);
res.statusCode = 504;
res.end();
}
}, 5000);
res.on("close", () => clearTimeout(deadline));
}
next();
} catch (err: any) {
const status =
err?.statusCode ?? (err instanceof URIError ? 400 : 500);
console.error(`${tag("Mocks")} ${req.url}: ${err?.message ?? err}`);
if (res.headersSent) {
res.destroy();
return;
}
if (pathname === `${MOCK_ROUTE}/__save`) {
sendJson(res, status, { error: String(err?.message ?? err) });
} else {
res.statusCode = status;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(status === 400 ? "Bad request" : "Mock route error");
}
}
});
},
};
}
const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora");
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
@@ -371,6 +786,7 @@ export default defineConfig(({ mode }) => {
tailwindcss(),
createRedirectPlugin(),
createLocalServePlugin(),
createMockPlugin(),
createUtSyncPlugin(OPENWRT_SSH_HOST),
createLuciJsCompressPlugin(),
],
@@ -445,6 +861,16 @@ export default defineConfig(({ mode }) => {
) {
html = html.replace("</head>", `${client}\n\t</head>`);
}
// Real device pages also get the mock-capture helper:
// Alt/Option+Shift+S → POST /mocks/__save stores the open
// page as a snapshot for the /mocks/ workflow.
const capture = `<script defer src="${MOCK_ROUTE}/__capture.js"></script>`;
if (
html.includes("</head>") &&
!html.includes("/__capture.js")
) {
html = html.replace("</head>", `${capture}\n\t</head>`);
}
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
res.writeHead(status, {
...headers,
+3
View File
@@ -17,3 +17,6 @@ docs/superpowers/
# On-device perf baselines — record device model and LAN address; keep local
/.claude/skills/aurora-performance/baselines/
# Local page snapshots for the dev /mocks/ route — see .dev/docs/DEVELOPMENT.md
/.dev/mocks/
+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
PKG_VERSION:=1.1.2
PKG_RELEASE:=54
PKG_RELEASE:=55
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
@@ -0,0 +1,2 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-content:"";--tw-font-weight:initial;--tw-duration:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}[data-page^=admin-modem-qmodem] .copyright-section{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline);background-color:var(--surface-sunken);text-align:center!important;color:var(--text-muted)!important}[data-page^=admin-modem-qmodem] fieldset.cbi-section>legend{float:left;margin-bottom:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--hairline);width:100%;padding-inline:0;padding-bottom:calc(var(--spacing) * 3);--tw-leading:var(--leading-tight,1.25);line-height:var(--leading-tight,1.25);color:var(--text)}[data-page^=admin-modem-qmodem] fieldset.cbi-section{display:flow-root}[data-page^=admin-modem-qmodem] fieldset.cbi-section>:not(legend){clear:both}[data-page^=admin-modem-qmodem] .cbi-section-table-titles.named:before{content:var(--tw-content);border-top-left-radius:calc(var(--radius-base) * 2);content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);background-color:var(--surface-sunken);border-bottom-style:var(--tw-border-style)!important;border-bottom-width:1px!important;border-color:var(--hairline)!important}[data-page^=admin-modem-qmodem-sms-conversation] #sms-messages-area{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-sunken)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-bubble{border-style:var(--tw-border-style);border-width:1px;border-color:var(--hairline)!important;background-color:var(--surface-overlay)!important;color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-content{color:var(--text)!important}[data-page^=admin-modem-qmodem-sms-conversation] .sms-message-meta{color:var(--text-muted)!important}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li{cursor:pointer;border-radius:calc(var(--radius-base) * 4);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);text-align:center;font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)));--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500);white-space:nowrap;color:var(--text-muted);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s));--tw-duration:.15s;transition-duration:.15s}@media (hover:hover){[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li:hover{background-color:var(--hover-faint);color:var(--text)}}[data-page=admin-modem-qmodem-config_advanced] .cbi-tabmenu li.cbi-tab{background-color:var(--text);--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600);color:var(--surface);--tw-shadow:var(--app-shadow-sm);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}[data-page=admin-modem-qmodem-sms] .td>div{margin-top:calc(var(--spacing) * 1.5)!important;font-size:var(--text-xs,.75rem)!important;line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))!important;--tw-leading:var(--leading-relaxed,1.625)!important;line-height:var(--leading-relaxed,1.625)!important;color:var(--text-muted)!important}[data-page=admin-modem-qmodem-sms] .td>span[style*=margin-left]{color:var(--text);margin-left:calc(var(--spacing) * 2)!important}[data-page=admin-modem-qmodem-sms] .sms-conversations{margin-top:calc(var(--spacing) * 5);margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations>.cbi-section{border-style:var(--tw-border-style)!important;--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important;background-color:#0000!important;border-width:0!important;margin:0!important;padding:0!important}[data-page=admin-modem-qmodem-sms] .sms-conversations fieldset.cbi-section{margin-bottom:0}[data-page=admin-modem-qmodem-sms] .sms-conversations .table.cbi-Line truncated
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=qosmate
PKG_VERSION:=0.5.48
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_MAINTAINER:=Markus Hütter <mh@hudra.net>
PKG_LICENSE:=GPL-3.0-or-later
+8 -22
View File
@@ -377,6 +377,8 @@ logread | grep qosmate-autorate
QoSmate allows you to define custom DSCP (Differentiated Services Code Point) marking rules to prioritize specific types of traffic. These rules are defined in the `/etc/config/qosmate` file under the `rule` sections and via luci-app-qosmate.
> **Rules are only evaluated in the upload direction.** QoSmate stores the DSCP of a connection in conntrack on egress, and `tc-ctinfo` restores it on incoming packets before the firewall sees them. WAN → LAN traffic therefore bypasses rule evaluation completely. One rule that matches the upload direction (for example the `src_ip` of your device) automatically covers the download direction of the same connection. A mirrored "inbound" rule is not needed - it can never match, and its counter stays at 0.
| Config option | Description | Type | Default |
| ------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------- |
| name | A unique name for the rule. Used for identification and logging. | string | |
@@ -424,50 +426,33 @@ config rule
option class 'cs1'
option counter '1'
```
Explanation: This rule assigns low priority to P2P traffic (like BitTorrent) by marking it as CS1. Using `list` for both `src_port` and `dest_port` covers both incoming and outgoing P2P traffic.
Explanation: This rule assigns low priority to P2P traffic (like BitTorrent) by marking it as CS1. Both port lists are matched on outgoing packets: `src_port` catches traffic sent from your local peer's listening port, `dest_port` catches traffic sent to a remote peer's listening port.
3. Call of Duty Game Traffic:
```
config rule
option name 'cod1'
option name 'cod'
option proto 'udp'
option src_ip '192.168.1.208'
option src_port '3074'
option dest_port '30000-65535'
option class 'cs5'
option counter '1'
config rule
option name 'cod2'
option proto 'udp'
option dest_ip '192.168.1.208'
option dest_port '3074'
option class 'cs5'
option counter '1'
```
Explanation: These rules prioritize Call of Duty game traffic. The first rule targets outgoing traffic from the game console (IP 192.168.1.208), while the second rule handles incoming traffic. Both use CS5 class, which is typically used for gaming traffic due to its high priority. The wide range of destination ports in the first rule covers the game's server ports.
Explanation: This rule prioritizes Call of Duty game traffic sent by the console (IP 192.168.1.208) from port 3074 to the game's server ports. It uses the CS5 class, which is typically used for gaming traffic due to its high priority. The incoming traffic of these connections is handled automatically via conntrack, so a second rule for the download direction is not required.
4. Generic Game Console/Gaming PC Traffic:
```
config rule
option name 'Game_Console_Outbound'
option name 'Game_Console'
option proto 'udp'
option src_ip '192.168.1.208'
list dest_port '!=80'
list dest_port '!=443'
option class 'cs5'
option counter '1'
config rule
option name 'Game_Console_Inbound'
option proto 'udp'
option dest_ip '192.168.1.208'
list src_port '!=80'
list src_port '!=443'
option class 'cs5'
option counter '1'
```
Explanation: These rules provide a more generic approach to prioritizing game console/gaming pc traffic. The outbound rule prioritizes all UDP traffic from the console (192.168.1.208) except for ports 80 and 443 (common web traffic). The inbound rule does the same for incoming traffic. This approach ensures that game-related traffic gets priority while allowing normal web browsing to use default priorities. The use of '!=' (not equal) in the port lists demonstrates how to exclude specific ports from the rule.
Explanation: This rule provides a more generic approach to prioritizing game console/gaming pc traffic. It prioritizes all UDP traffic from the console (192.168.1.208) except for ports 80 and 443 (common web traffic), which ensures that game-related traffic gets priority while allowing normal web browsing to use default priorities. The use of '!=' (not equal) in the port list demonstrates how to exclude specific ports from the rule.
This is more or less equivalent to the `realtime4` and `realtime6` variables from the SimpleHFSCgamer script. However, this rule is even better as it excludes UDP port 80 and 443, which are often used for QUIC. This is likely less of an issue on a gaming console than on a gaming PC, where a YouTube video using QUIC might be running alongside the game.
This rule is also applied when the auto-setup is used via CLI or UI and a Gaming Device IP (optional) is entered.
@@ -1453,6 +1438,7 @@ QoSmate handles traffic in both directions (upload and download) using the follo
- If the conditional bit (128) is set, the original DSCP value is restored:
- DSCP is extracted as `mark & 63`
- This DSCP value is applied to incoming packets
- Since the DSCP is already restored at this point, WAN → LAN packets skip the nftables marking chain, which is why rule counters only increase for the upload direction
3. **IFB (Intermediate Functional Block):**
- Linux can only shape outgoing traffic
+5 -15
View File
@@ -83,6 +83,10 @@ config autorate 'autorate'
option flash_history '0'
option log_changes '0'
# Rules are only evaluated on egress (LAN -> WAN): QoSmate stores the DSCP in conntrack
# and tc-ctinfo restores it on ingress. One rule matching the upload direction therefore
# covers both directions of a connection - a mirrored inbound rule would never match.
#config rule 'cod1'
# option name 'cod1'
# option proto 'udp'
@@ -92,14 +96,6 @@ config autorate 'autorate'
# option class 'cs5'
# option counter '1'
#config rule 'cod2'
# option name 'cod3'
# option proto 'udp'
# option dest_ip '192.168.1.208'
# option dest_port '3074'
# option class 'cs5'
# option counter '1'
#config rule
# option name 'TeamViewer'
# option proto 'tcp'
@@ -109,17 +105,11 @@ config autorate 'autorate'
# option counter '1'
#config rule
# option name 'ServerDownPrio1'
# option name 'ServerDownPrio'
# option src_ip '192.168.15.0/24'
# option class 'cs1'
# option counter '1'
#config rule
# option name 'ServerDownPrio2'
# option dest_ip '192.168.15.0/24'
# option class 'cs1'
# option counter '1'
#config ipset
# option name 'gaming_devices'
# option mode 'static'
+135 -45
View File
@@ -9,12 +9,13 @@ USE_PROCD=1
VERSION="dev"
UPD_CHANNEL="release"
EXTRA_COMMANDS="check_version update auto_setup auto_setup_noninteractive validate_custom_rules health_check"
EXTRA_COMMANDS="check_version update auto_setup auto_setup_noninteractive auto_setup_detached validate_custom_rules health_check"
EXTRA_HELP="
check_version Check for updates
update Update qosmate
auto_setup Automatically configure qosmate
auto_setup_noninteractive Automatically configure qosmate with no interaction
auto_setup_detached Start the non-interactive auto-setup in the background
validate_custom_rules Validate custom rules
health_check Check if QoSmate is properly configured and running"
@@ -54,6 +55,7 @@ QOSMATE_DEFAULTS_FILE=${QOSMATE_D}/qosmate-defaults
QOSMATE_CUSTOM_RULES_FILE=${QOSMATE_D}/custom_rules.nft
QOSMATE_INLINE_RULES_FILE=${QOSMATE_D}/inline_dscptag.nft
QOSMATE_RUN_DIR=/tmp/qosmate
QOSMATE_AUTO_SETUP_OUTPUT=/tmp/qosmate_auto_setup_output.txt
# Local path of JS files
QOSMATE_UPD_DIR=/var/run/qosmate-update
@@ -197,7 +199,7 @@ print_msg() {
# logs each argument separately and prints to a separate line
# optional arguments: '-err', '-warn' to set logged error level
log_msg() {
local msgs_prefix='' _arg err_l=info msgs_dest
local msgs_prefix='' _arg err_l=info
local IFS="$DEFAULT_IFS"
for _arg in "$@"
@@ -207,11 +209,12 @@ log_msg() {
"-warn") err_l=warn msgs_prefix="Warning: " ;;
'') printf '\n' ;; # print out empty lines
*)
# Write to the inherited descriptors instead of redirecting to /dev/stdout: that
# re-opens the target, and when stdout is a regular file '>' truncates it.
case "$err_l" in
err|warn) msgs_dest="/dev/stderr" ;;
*) msgs_dest="/dev/stdout"
err|warn) printf '%s\n' "${msgs_prefix}${_arg}" >&2 ;;
*) printf '%s\n' "${msgs_prefix}${_arg}"
esac
printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest"
logger -t qosmate -p user."$err_l" "${msgs_prefix}${_arg}"
msgs_prefix=''
esac
@@ -2218,6 +2221,48 @@ parse_speedtest_output() {
:
}
# Prints WAN throughput in Mbit/s once per second as "<rx> <tx>", until killed.
# 1 - network device
sample_wan_rates() {
local stats="/sys/class/net/${1}/statistics" prev_rx prev_tx rx tx
read -r prev_rx < "$stats/rx_bytes" && read -r prev_tx < "$stats/tx_bytes" || return 1
while sleep 1; do
read -r rx < "$stats/rx_bytes" && read -r tx < "$stats/tx_bytes" || return 1
printf '%s %s\n' $(( (rx - prev_rx) * 8 / 1000000 )) $(( (tx - prev_tx) * 8 / 1000000 ))
prev_rx=$rx prev_tx=$tx
done
}
# Median of the samples reaching at least half of the peak sample, which is the plateau of a test
# phase. Ignores ramp-up, idle time and the ACK traffic of the opposite direction.
# Prints 0 if there is no usable plateau.
# 1 - samples file, 2 - column (1 = rx, 2 = tx)
plateau_rate() {
[ -s "$1" ] || { echo "0"; return 0; }
awk -v col="$2" '
{ v[NR] = $col + 0; if (v[NR] > peak) peak = v[NR] }
END {
if (peak <= 0) { print 0; exit }
for (i = 1; i <= NR; i++) if (v[i] >= peak / 2) s[++n] = v[i]
if (n < 3) { print 0; exit }
for (i = 2; i <= n; i++) { x = s[i]; for (j = i - 1; j > 0 && s[j] > x; j--) s[j + 1] = s[j]; s[j + 1] = x }
print (n % 2) ? s[(n + 1) / 2] : int((s[n / 2] + s[n / 2 + 1]) / 2)
}' "$1" 2>/dev/null
}
# 1, 2 - rates to compare
lower_of() {
awk -v a="$1" -v b="$2" 'BEGIN{print (a < b) ? a : b}'
}
# Warns when the speed test result is implausibly higher than what crossed the interface.
# 1 - direction label, 2 - reported rate, 3 - measured rate
warn_if_overreported() {
awk -v r="$2" -v m="$3" 'BEGIN{exit !(m > 0 && r > m * 1.25)}' &&
print_msg "Warning: the speed test reported $2 Mbit/s $1, but only $3 Mbit/s crossed the WAN interface. Using the measured value."
:
}
auto_setup() {
try_auto_setup "$@" && return 0
error_out "auto-setup failed."
@@ -2380,15 +2425,49 @@ try_auto_setup() {
fi
print_msg "Running speed test... This may take a few minutes."
SPEED_RESULT=$($SPEEDTEST_CMD)
DOWNLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Download) &&
UPLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Upload) ||
return 1
local samples_file=/tmp/qosmate-speedtest-samples sampler_pid speedtest_rv retried=''
local reported_down reported_up measured_down measured_up
while :; do
sample_wan_rates "$FINAL_INTERFACE" > "$samples_file" &
sampler_pid=$!
SPEED_RESULT=$($SPEEDTEST_CMD 2>&1)
speedtest_rv=$?
kill "$sampler_pid" 2>/dev/null
[ "$speedtest_rv" = 0 ] &&
DOWNLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Download) &&
UPLOAD_SPEED=$(printf %s "$SPEED_RESULT" | parse_speedtest_output "$SPEEDTEST_CMD" Upload) &&
break
[ -z "$retried" ] || {
# include the tool output, otherwise the failure is only reported generically
error_out "Speed test failed (exit code $speedtest_rv). Speed test output:" "$SPEED_RESULT"
return 1
}
retried=1
log_msg -warn "Speed test failed (exit code $speedtest_rv), retrying once."
done
# A speed test measures at the socket, so it can report several times the rate that actually
# crossed the link (speedtest-go does this on upload). Cross-check with the WAN counters.
reported_down="$DOWNLOAD_SPEED" reported_up="$UPLOAD_SPEED"
measured_down=$(plateau_rate "$samples_file" 1)
measured_up=$(plateau_rate "$samples_file" 2)
rm -f "$samples_file"
# Use the lower value: the test may over-report, the counters may include foreign traffic
[ "$measured_down" != 0 ] && DOWNLOAD_SPEED=$(lower_of "$reported_down" "$measured_down")
[ "$measured_up" != 0 ] && UPLOAD_SPEED=$(lower_of "$reported_up" "$measured_up")
warn_if_overreported download "$reported_down" "$measured_down"
warn_if_overreported upload "$reported_up" "$measured_up"
print_msg "Speed test results:" \
"Download speed: $DOWNLOAD_SPEED Mbit/s" \
"Upload speed: $UPLOAD_SPEED Mbit/s"
"Upload speed: $UPLOAD_SPEED Mbit/s" \
"WAN counters during the test: $measured_down Mbit/s down, $measured_up Mbit/s up (speed test: $reported_down / $reported_up)"
fi
# Convert speeds to kbps and apply 90% rule
@@ -2419,43 +2498,29 @@ try_auto_setup() {
fi
fi
if [ -n "$gaming_ip" ]; then
# Validate IP address format
if [[ $gaming_ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
# Check if rules for this IP already exist
local ip_configured=
config_foreach is_ip_configured rule "$gaming_ip"
# $ip_configured is set in is_ip_configured()
if [ -n "$ip_configured" ]; then
print_msg "Rules for IP $gaming_ip already exist. Skipping addition of new rules."
gaming_ip=
fi
# Exactly one outcome is reported, so the UI can always tell the user what happened
local ip_configured=
if [ -z "$gaming_ip" ]; then
print_msg "No gaming device IP given - no rule added."
elif [[ ! "$gaming_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
print_msg "Invalid IP address format '$gaming_ip' - no gaming device rule added."
else
config_foreach is_ip_configured rule "$gaming_ip" # sets $ip_configured
if [ -n "$ip_configured" ]; then
print_msg "A rule for IP $gaming_ip already exists - keeping it."
else
print_msg "Invalid IP address format. No gaming device rules added."
gaming_ip=
fi
fi
if [ -n "$gaming_ip" ]; then
local direction ip_dir port_dir
for direction in Inbound Outbound; do
case "$direction" in
Inbound) ip_dir=dest port_dir=src ;;
Outbound) ip_dir=src port_dir=dest ;;
esac
# Only an egress rule is needed: the DSCP is stored in conntrack and restored on ingress via tc-ctinfo
uci add qosmate rule 1>/dev/null &&
uci set "qosmate.@rule[-1].name=Game_Console_${direction}" &&
uci set "qosmate.@rule[-1].name=Game_Console" &&
uci set "qosmate.@rule[-1].proto=udp" &&
uci set "qosmate.@rule[-1].${ip_dir}_ip=${gaming_ip}" &&
uci add_list "qosmate.@rule[-1].${port_dir}_port=!=80" &&
uci add_list "qosmate.@rule[-1].${port_dir}_port=!=443" &&
uci set "qosmate.@rule[-1].src_ip=${gaming_ip}" &&
uci add_list "qosmate.@rule[-1].dest_port=!=80" &&
uci add_list "qosmate.@rule[-1].dest_port=!=443" &&
uci set "qosmate.@rule[-1].class=cs5" &&
uci set "qosmate.@rule[-1].counter=1" ||
return 1
done
print_msg "Gaming device rules added for IP: $gaming_ip"
else
print_msg "No gaming device IP added."
print_msg "Gaming device rule added for IP: $gaming_ip"
fi
fi
uci commit qosmate || return 1
@@ -2470,16 +2535,41 @@ try_auto_setup() {
}
auto_setup_noninteractive() {
local output_file="/tmp/qosmate_auto_setup_output.txt" auto_setup_rv
local auto_setup_rv
# Truncate up front and append afterwards. Truncating while a writer already holds an offset
# leaves a gap that the kernel fills with NUL bytes, and ubus file.read stops at the first NUL,
# so a polling caller would only ever see the beginning of the file.
: > "$QOSMATE_AUTO_SETUP_OUTPUT"
{
echo "Starting qosmate non-interactive auto-setup..."
auto_setup -n "$1" 2>&1
} > "$output_file"
auto_setup_rv=$?
echo "$output_file"
auto_setup_rv=$?
# lets callers polling this file tell a finished run from a partial or stale one
echo "AUTO_SETUP_DONE rv=$auto_setup_rv"
} >> "$QOSMATE_AUTO_SETUP_OUTPUT"
echo "$QOSMATE_AUTO_SETUP_OUTPUT"
return $auto_setup_rv
}
# Starts the non-interactive auto-setup detached and prints the output file path right away.
# A speed test regularly takes longer than uhttpd's script_timeout (60s by default), which kills
# the CGI and leaves the caller with a half-written file, so the caller polls the file instead.
# 1 - optional gaming device IP
auto_setup_detached() {
printf '%s\n' "Starting qosmate auto-setup..." > "$QOSMATE_AUTO_SETUP_OUTPUT" || return 1
# A new session is required, not just a background job: ash waits for children of the same
# session in a command substitution, which is how callers read this function's output.
if check_util setsid; then
# stdin is closed as well, otherwise the run keeps the caller's pipe open - for a request
# from the web UI that blocks the connection the UI then polls on
setsid "$QOSMATE_SERVICE_PATH" auto_setup_noninteractive "$1" >/dev/null 2>&1 <&- &
else
log_msg -warn "setsid is not available, running the auto-setup in the foreground."
auto_setup_noninteractive "$1" >/dev/null 2>&1
fi
echo "$QOSMATE_AUTO_SETUP_OUTPUT"
}
health_check() {
# Check if QoSmate is properly configured and running
local status="" errors=0 service_enabled=0 config_status=''
+13 -8
View File
@@ -25,15 +25,21 @@ error_out() { log_msg -err "${@}"; }
# prints each argument to a separate line
print_msg() {
local _arg msgs_dest="/dev/stdout" msgs_prefix=''
# Write to the inherited descriptors instead of redirecting to /dev/stdout: that re-opens the
# target, and when stdout is a regular file '>' truncates it.
local _arg to_stderr='' msgs_prefix=''
for _arg in "$@"
do
case "${_arg}" in
-err) msgs_dest="/dev/stderr" msgs_prefix="Error: " ;;
-warn) msgs_dest="/dev/stderr" msgs_prefix="Warning: " ;;
-err) to_stderr=1 msgs_prefix="Error: " ;;
-warn) to_stderr=1 msgs_prefix="Warning: " ;;
'') printf '\n' ;; # print out empty lines
*)
printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest"
if [ -n "$to_stderr" ]; then
printf '%s\n' "${msgs_prefix}${_arg}" >&2
else
printf '%s\n' "${msgs_prefix}${_arg}"
fi
msgs_prefix=''
esac
done
@@ -43,7 +49,7 @@ print_msg() {
# logs each argument separately and prints to a separate line
# optional arguments: '-err', '-warn' to set logged error level
log_msg() {
local msgs_prefix='' _arg err_l=info msgs_dest
local msgs_prefix='' _arg err_l=info
local IFS="$DEFAULT_IFS"
for _arg in "$@"
@@ -54,10 +60,9 @@ log_msg() {
'') printf '\n' ;; # print out empty lines
*)
case "$err_l" in
err|warn) msgs_dest="/dev/stderr" ;;
*) msgs_dest="/dev/stdout"
err|warn) printf '%s\n' "${msgs_prefix}${_arg}" >&2 ;;
*) printf '%s\n' "${msgs_prefix}${_arg}"
esac
printf '%s\n' "${msgs_prefix}${_arg}" > "$msgs_dest"
logger -t qosmate -p user."$err_l" "${msgs_prefix}${_arg}"
msgs_prefix=''
esac