🎁 Sync 2026-08-02 20:24:47

This commit is contained in:
github-actions[bot] 2026-08-02 20:24:47 +08:00
parent 540866391e
commit cfc7814ebd
20 changed files with 687 additions and 447 deletions

View File

@ -6,11 +6,11 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=cloudreve
PKG_VERSION:=4.18.0
PKG_RELEASE:=14
PKG_RELEASE:=15
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/cloudreve/Cloudreve.git
PKG_SOURCE_VERSION:=0bb0ab833571d380153edd3529e01a7957b8b4ce
PKG_SOURCE_VERSION:=20c95ad73f3a8bcb72887fea91ff31ab24fa1011
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-only

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.07.31
PKG_RELEASE:=37
PKG_RELEASE:=38
PKG_SOURCE:=daed-src-2026.07.31-178adbdeb296.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src

View File

@ -23,8 +23,7 @@ start_service() {
config_get log_maxsize "config" "log_maxsize" "5"
procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray"
procd_set_param env TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param command "$PROG" run
procd_append_param command --config "/etc/daed/"
procd_append_param command --listen "$listen_addr"

View File

@ -196,14 +196,15 @@ Two rules of thumb that follow from prefix matching:
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.
- **The mock bar:** every HTML page this dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bar in the bottom-left corner. It lives in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it, and the theme's own floating toolbar keeps the bottom-right corner. On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['aurora.mockbar.collapsed']`. The snapshot list is injected inline next to the script; both tags carry `data-aurora-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__auroraMockCapture()` in the console. It 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.
- **Navigate between snapshots in place:** inside a mock the bar also takes over clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`), resolving them against the captured snapshots by `data-page` (exact match) and jumping 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. The bar lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its / buttons) and links back to the index. `↩` leaves for the real page: its target is the `requestpath` from LuCI's own inline bootstrap, but only when that agrees with the snapshot's `data-page` (a hand-assembled mock can carry the segments of the page it was built from); otherwise it falls back to splitting `data-page`, which is lossy whenever a path segment contains a dash of its own (`admin-status-disks-info`), then to the last device page visited in this tab.
- **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.
@ -325,8 +326,7 @@ luci-theme-aurora/
│ ├── 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
│ │ ├── mock-bar.client.js # Injected into device pages and /mocks/ — snapshot bar, capture, link takeover
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons

View File

@ -0,0 +1,499 @@
/**
* Aurora dev helper one floating bar, injected by vite.config.ts into both
* contexts it is useful in. Never ships to the router.
*
* proxied device page (/cgi-bin/luci/)
* · lists every snapshot in .dev/mocks/ so the workflow is reachable
* without typing /mocks/ by hand
* · jumps straight to this page's own snapshot when one was captured
* (matched on <body data-page>)
* · captures the open page the button does what Alt/Option+Shift+S does
* served snapshot (/mocks/)
* · names the open snapshot, cycles with / or the [ and ] keys
* · takes over the snapshot's own LuCI links: they resolve against the
* captured snapshots instead of falling through to the proxied router
*
* Shadow DOM throughout, so theme/patch CSS can't restyle the bar and its
* styles can't leak into the page under review. The theme's own floating
* toolbar owns the bottom-right corner, so this one sits bottom-left.
*
* Data contract (injected inline before this script):
* window.__AURORA_MOCKS__ = { current: "<file>"|null, mocks: [{ file, page }] }
*/
(() => {
"use strict";
if (window.__auroraMockBar) return;
window.__auroraMockBar = true;
const data = window.__AURORA_MOCKS__ || {};
const mocks = Array.isArray(data.mocks) ? data.mocks.slice() : [];
// A snapshot is static DOM: capturing one would only re-save the theme's own
// output, and there is no live page to re-capture.
const inMock = location.pathname.startsWith("/mocks/");
const COLLAPSE_KEY = "aurora.mockbar.collapsed";
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
const displayName = (file) => file.replace(/\.html$/, "");
const escapeHtml = (s) =>
s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
// The page's own identity, used to find its snapshot from a device page.
const currentPage = document.body?.dataset?.page || null;
const snapshotOfThisPage = () =>
currentPage ? mocks.find((m) => m.page === currentPage) : undefined;
const LAST_DEVICE_KEY = "aurora.mockbar.device";
const readStore = (store, key) => {
try {
return window[store].getItem(key);
} catch {
return null; // private mode
}
};
const writeStore = (store, key, value) => {
try {
if (value === null) window[store].removeItem(key);
else window[store].setItem(key, value);
} catch {
/* private mode — the bar just forgets between reloads */
}
};
// Where "back to the device" goes from inside a snapshot. data-page is the
// request path joined with "-", so splitting it back apart is wrong whenever
// a segment contains a dash of its own (admin-status-disks-info). LuCI's own
// inline bootstrap carries the segments verbatim, so read those first and
// keep the lossy split as the fallback.
const devicePath = () => {
const page = mocks.find((m) => m.file === data.current)?.page;
const bootstrap = document.documentElement.innerHTML.match(
/"requestpath"\s*:\s*\[([^\]]*)\]/,
);
const segs = bootstrap?.[1]
.match(/"((?:[^"\\]|\\.)*)"/g)
?.map((s) => JSON.parse(s));
// Only trust the bootstrap when it agrees with the snapshot's identity —
// a hand-assembled mock can carry the segments of the page it was built
// from.
if (segs && (!page || segs.join("-") === page))
return "/cgi-bin/luci/" + segs.join("/");
if (page) return "/cgi-bin/luci/" + page.split("-").join("/");
return readStore("sessionStorage", LAST_DEVICE_KEY) || "/cgi-bin/luci/";
};
// ---- shell ---------------------------------------------------------------
const host = document.createElement("aurora-mock-bar");
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);
}
.pill[hidden],
.dot[hidden],
.step[hidden],
.here[hidden],
.back[hidden],
.capture[hidden] {
display: none;
}
.step,
.here,
.back,
.capture,
.close {
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 14px;
opacity: 0.75;
}
.step:hover,
.here:hover,
.back:hover,
.capture:hover,
.close:hover {
background: rgba(255, 255, 255, 0.14);
opacity: 1;
}
.here {
color: #4ade80;
opacity: 1;
}
.close {
font-size: 12px;
opacity: 0.5;
}
.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;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(18, 18, 24, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.dot:hover {
background: rgba(18, 18, 24, 0.85);
}
.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="here" title="打开本页快照"></button>
<button class="name" title="全部快照"></button>
<button class="step next" title="下一个快照 ]"></button>
<button class="back" title="回到真机页"></button>
<button class="capture" title="捕获此页Alt/Option+Shift+S"></button>
<button class="close" title="收起"></button>
</div>
<button class="dot" title="Aurora 快照" hidden></button>
`;
const panel = root.querySelector(".panel");
const toastEl = root.querySelector(".toast");
const pill = root.querySelector(".pill");
const dot = root.querySelector(".dot");
const nameBtn = root.querySelector(".name");
const hereBtn = root.querySelector(".here");
const backBtn = root.querySelector(".back");
const captureBtn = root.querySelector(".capture");
const prevBtn = root.querySelector(".prev");
const nextBtn = root.querySelector(".next");
let toastTimer;
const toast = (text, ok) => {
toastEl.textContent = text;
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2800);
};
// ---- rendering -----------------------------------------------------------
const currentIndex = () =>
Math.max(
mocks.findIndex((m) => m.file === data.current),
0,
);
const render = () => {
const here = snapshotOfThisPage();
const count = mocks.length;
prevBtn.hidden = nextBtn.hidden = !inMock || count < 2;
captureBtn.hidden = inMock;
hereBtn.hidden = inMock || !here;
backBtn.hidden = !inMock;
if (inMock) backBtn.title = `回到真机页 ${devicePath()}`;
if (inMock) {
nameBtn.innerHTML =
escapeHtml(displayName(data.current || "")) +
`<span class="count">${currentIndex() + 1}/${count}</span>`;
} else if (!count) {
nameBtn.textContent = "捕获此页";
} else {
nameBtn.innerHTML =
(here ? "本页有快照" : "快照") + `<span class="count">${count}</span>`;
}
panel.innerHTML =
mocks
.map((m) => {
const isCurrent = inMock
? m.file === data.current
: !!currentPage && m.page === currentPage;
return (
`<a href="${mockUrl(m.file)}"` +
(isCurrent ? ' class="current"' : "") +
` title="${escapeHtml(m.page || m.file)}">` +
`${escapeHtml(displayName(m.file))}</a>`
);
})
.join("") +
'<div class="foot"><a href="/mocks/">全部快照 ↗</a>' +
`<span>${inMock ? "[ / ] 切换" : "Alt/⇧+S 捕获"}</span></div>`;
};
const setCollapsed = (collapsed) => {
pill.hidden = collapsed;
dot.hidden = !collapsed;
if (collapsed) panel.hidden = true;
writeStore("localStorage", COLLAPSE_KEY, collapsed ? "1" : null);
};
// ---- capture -------------------------------------------------------------
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}`);
// Fold the fresh snapshot into the list the page was served with, so the
// bar reflects it without a reload.
const entry = { file: info.file, page: info.page ?? null };
const at = mocks.findIndex((m) => m.file === entry.file);
if (at === -1) mocks.push(entry);
else mocks[at] = entry;
mocks.sort((a, b) => a.file.localeCompare(b.file));
render();
toast(`已捕获 ${info.file}`, true);
} catch (err) {
toast(`捕获失败:${(err && err.message) || err}`, false);
}
};
// ---- wiring --------------------------------------------------------------
const cycle = (step) => {
if (!inMock || mocks.length < 2) return;
const next = mocks[(currentIndex() + step + mocks.length) % mocks.length];
location.href = mockUrl(next.file);
};
prevBtn.addEventListener("click", () => cycle(-1));
nextBtn.addEventListener("click", () => cycle(1));
hereBtn.addEventListener("click", () => {
const here = snapshotOfThisPage();
if (here) location.href = mockUrl(here.file);
});
backBtn.addEventListener("click", () => {
location.href = devicePath();
});
captureBtn.addEventListener("click", capture);
nameBtn.addEventListener("click", () => {
if (!inMock && !mocks.length) return capture();
panel.hidden = !panel.hidden;
});
root
.querySelector(".close")
.addEventListener("click", () => setCollapsed(true));
dot.addEventListener("click", () => setCollapsed(false));
document.addEventListener("click", (event) => {
if (!panel.hidden && !event.composedPath().includes(host)) {
panel.hidden = true;
}
});
render();
setCollapsed(readStore("localStorage", COLLAPSE_KEY) === "1");
document.documentElement.appendChild(host);
// Remembered as the last resort for "back to the device" from a snapshot
// that carries neither a usable bootstrap nor a data-page.
if (!inMock)
writeStore(
"sessionStorage",
LAST_DEVICE_KEY,
location.pathname + location.search,
);
// ---- navigation takeover (snapshots only) --------------------------------
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;
}
};
if (inMock) {
const byPage = new Map(
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
);
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,
);
}
// ---- keyboard ------------------------------------------------------------
const isEditable = (el) =>
el instanceof Element &&
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
addEventListener("keydown", (event) => {
if (isEditable(event.target)) return;
if (
!inMock &&
event.altKey &&
event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
event.code === "KeyS"
) {
event.preventDefault();
capture();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === "[") cycle(-1);
else if (event.key === "]") cycle(1);
});
if (!inMock) {
window.__auroraMockCapture = capture;
console.info(
"[aurora] mock bar ready — 左下角可捕获/打开快照Alt/Option+Shift+S或 __auroraMockCapture())保存当前页到 .dev/mocks/",
);
}
})();

View File

@ -1,75 +0,0 @@
/**
* 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/",
);
})();

View File

@ -1,289 +0,0 @@
/**
* 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);
});
})();

View File

@ -498,11 +498,21 @@ body[data-nav-type="sidebar"] {
}
& .sidebar-list .navigation-direct {
@apply truncate text-lg;
/* flex, matching .nav-category: icon + label on one row (the label
span owns the truncation). */
@apply flex items-center gap-2 text-lg;
}
& .sidebar-list .nav-icon {
@apply size-4.5;
}
& .sidebar-submenu {
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-4;
/* pl: the parent row's px-3 (0.75) + icon (1.125) + gap (0.5) put the
label at 2.375rem; sublinks bring their own px-3, so the list
supplies the remaining 1.625rem and sublink text hangs off the
parent label, not the row edge. */
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-6.5;
}
& .sidebar-submenu .navigation-sublink {

View File

@ -173,10 +173,13 @@
@apply flex h-full flex-1 flex-col items-center justify-around gap-2;
& > div {
@apply flex flex-wrap;
@apply flex flex-wrap gap-2 max-md:gap-1.5;
}
/* `flex-1` alone lets the badges shrink to min-content, so six SSIDs stay
crammed on one line instead of wrapping. The floor mirrors
luci-theme-bootstrap's `min-width: 220px` on the same badges. */
& .ifacebadge {
@apply flex-1;
@apply min-w-55 flex-1;
}
}

View File

@ -55,6 +55,31 @@
@apply text-brand;
}
/* Row icons
A leading glyph inline with the label, on sidebar and mobile-drawer
rows alike (the mega-menu title draws its own ::before). bg-current +
mask lets the glyph track the row's text colour through hover, expanded
and active states; the size is set per surface sidebar rows in
_layout.css, drawer rows in _overlay.css. */
.nav-icon {
@apply shrink-0 bg-current opacity-55 transition-opacity duration-150 [mask:var(--menu-icon,var(--icon-category))_center/contain_no-repeat];
}
/* Direct-row labels; group rows keep .nav-category-label. flex-1 also
holds the label against the drawer's justify-between rows. */
.nav-label {
@apply min-w-0 flex-1 truncate;
}
/* Full-strength glyph wherever the row itself is highlighted. */
.navigation-direct:hover .nav-icon,
.navigation-group-toggle:hover .nav-icon,
.navigation-direct.is-active-page .nav-icon,
.navigation-group.is-expanded > .navigation-group-toggle .nav-icon,
.navigation-group.is-active-group > .navigation-group-toggle .nav-icon {
@apply opacity-100;
}
.navigation-group-region {
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-[250ms] ease-out;
}
@ -85,45 +110,49 @@
}
/* First-level section icons
The name icon map, consumed by the mega-menu title (_layout.css).
Keyed on the LuCI node name (data-section, set by menu-aurora.js)
stable and language-independent. Scoped to .desktop-nav-title so the
selector can't accidentally match unrelated data-section usage elsewhere.
The only maintenance point: add new first-level sections here; anything
unmapped falls back to the default icon at the consumer. */
.desktop-nav-title[data-section="status"] {
The name icon map, shared by every navigation surface: the mega-menu
title (::before consumer in _layout.css) and the sidebar/mobile-drawer
row glyphs (.nav-icon below). Keyed on the LuCI node name (data-section,
set by menu-aurora.js) stable and language-independent. Scoped to the
three row elements that carry the attribute so the selector can't
accidentally match unrelated data-section usage elsewhere; :is() keeps
every key at (0,2,0) the mega-menu consumer's fallback chain would
outrank anything higher (see _layout.css). The only maintenance point:
add new first-level sections here; anything unmapped falls back to the
default icon at the consumer. */
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="status"] {
@apply [--menu-icon:var(--icon-activity)];
}
.desktop-nav-title[data-section="statistics"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="statistics"] {
@apply [--menu-icon:var(--icon-chart-bar)];
}
.desktop-nav-title[data-section="nlbw"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nlbw"] {
@apply [--menu-icon:var(--icon-gauge)];
}
.desktop-nav-title[data-section="system"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="system"] {
@apply [--menu-icon:var(--icon-settings)];
}
.desktop-nav-title[data-section="services"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="services"] {
@apply [--menu-icon:var(--icon-apps)];
}
.desktop-nav-title[data-section="nas"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nas"] {
@apply [--menu-icon:var(--icon-server)];
}
.desktop-nav-title[data-section="control"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="control"] {
@apply [--menu-icon:var(--icon-adjustments)];
}
.desktop-nav-title[data-section="network"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="network"] {
@apply [--menu-icon:var(--icon-world)];
}
.desktop-nav-title[data-section="vpn"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="vpn"] {
@apply [--menu-icon:var(--icon-shield)];
}
.desktop-nav-title[data-section="docker"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="docker"] {
@apply [--menu-icon:var(--icon-box)];
}
/* "asterisk" covers VoIP/PBX apps (e.g. luci-app-asterisk forks) no
official OpenWrt package registers a verified menu.d node under this
name, so this is a best-effort key. */
.desktop-nav-title[data-section="asterisk"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="asterisk"] {
@apply [--menu-icon:var(--icon-phone)];
}

View File

@ -28,12 +28,22 @@
@apply max-md:shrink-0;
& .mobile-nav-link {
@apply max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none;
/* gap-3 (not 4): tight enough that the leading .nav-icon reads
attached to its label; justify-between still pins the group
arrow to the right edge. */
@apply max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-3 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none;
& .nav-icon {
/* Row icons at drawer scale, against the text-2xl labels. */
@apply max-md:size-6;
}
}
& .mobile-nav-submenu {
& .mobile-nav-submenu-list {
@apply max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:py-1 max-md:pr-0 max-md:pl-4;
/* pl: rows are px-0, so icon (1.5) + gap (0.75) put the label
at 2.25rem; sublinks bring px-3, the list supplies the rest. */
@apply max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:py-1 max-md:pr-0 max-md:pl-6;
}
& .mobile-nav-subitem {

View File

@ -0,0 +1,35 @@
/*
PATCH: admin-system-diskman (luci-app-diskman)
Loaded on-demand by header.ut only when data-page matches this filename
the prefix match also covers .../partition/<dev> and .../btrfs/<uuid>.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
[data-page^="admin-system-diskman"] {
/* disk_info.htm injects the partition bar as a full-span row whose cell carries
an inline white-space:nowrap. Once .tr turns into a flex row on mobile, that
cell's automatic minimum size is its min-content the whole unwrapped bar
so it refuses to shrink and drags the page ~300px wide. Zero the minimum and
give the bar its own row. */
td[colspan]:has(> div[title]) {
flex: 0 0 100%;
min-width: 0;
overflow: hidden;
border-radius: var(--radius-base);
/* The segments are inline-blocks sized in percent: any whitespace between
them adds a word space on top of the 100% they already sum to. */
font-size: 0;
}
/* Each segment is exactly as wide as its share of the disk, but its label does
not wrap and spills onto the neighbouring segments. Clip it the full text
stays available through the title attribute. */
td[colspan] > div[title],
.tr > div[style*="nowrap"] > div[title] {
overflow: hidden;
font-size: 0.75rem;
line-height: 1.5rem;
text-overflow: ellipsis;
}
}

View File

@ -345,7 +345,15 @@ return baseclass.extend({
href: item.href,
};
if (item.isActivePage) attributes["aria-current"] = "page";
return E("li", { class: itemClass }, [E("a", attributes, [item.title])]);
// data-section keys the shared name → icon map (_nav.css); the icon
// span renders it, unmapped names fall back at the consumer.
attributes["data-section"] = item.name;
return E("li", { class: itemClass }, [
E("a", attributes, [
E("span", { class: "nav-icon", "aria-hidden": "true" }),
E("span", { class: "nav-label" }, [item.title]),
]),
]);
}
const submenuId = this.navigationSubmenuId(mode, item.name);
@ -364,6 +372,8 @@ return baseclass.extend({
"aria-controls": submenuId,
};
if (item.isActiveGroup) toggleAttributes["aria-current"] = "location";
// See the direct-row note: keys the shared icon map.
toggleAttributes["data-section"] = item.name;
const list = E("ul", {
class: mobile
@ -400,6 +410,7 @@ return baseclass.extend({
return E("li", { class: groupClasses.join(" ") }, [
E("button", toggleAttributes, [
E("span", { class: "nav-icon", "aria-hidden": "true" }),
E("span", { class: "nav-category-label" }, [item.title]),
]),
E("div", regionAttributes, [list]),

View File

@ -125,11 +125,16 @@ test("shared navigation styles own accordion animation without a guide rail", ()
test("desktop sidebar styles only provide desktop navigation density", () => {
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
const direct = getBlock(sidebar, "& .sidebar-list .navigation-direct");
const icon = getBlock(sidebar, "& .sidebar-list .nav-icon");
const submenu = getBlock(sidebar, "& .sidebar-submenu");
const sublink = getBlock(sidebar, "& .sidebar-submenu .navigation-sublink");
assertIncludesUtilities(direct, ["truncate", "text-lg"]);
assertIncludesUtilities(submenu, ["pl-4"]);
// Direct rows are icon + label flex rows; the label span truncates.
assertIncludesUtilities(direct, ["flex", "items-center", "gap-2", "text-lg"]);
assertIncludesUtilities(icon, ["size-4.5"]);
// pl-6.5 hangs sublink text off the parent label (px-3 + icon + gap
// the sublink's own px-3), not the row edge.
assertIncludesUtilities(submenu, ["pl-6.5"]);
assertIncludesUtilities(sublink, ["px-3", "py-1.5", "text-sm"]);
assert.doesNotMatch(
sidebar,
@ -140,10 +145,15 @@ test("desktop sidebar styles only provide desktop navigation density", () => {
test("mobile drawer styles only provide mobile navigation density", () => {
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
const icon = getBlock(drawer, "& .nav-icon");
const submenu = getBlock(drawer, "& .mobile-nav-submenu-list");
const sublink = getBlock(drawer, "& .mobile-nav-sublink");
assertIncludesUtilities(submenu, ["max-md:pl-4"]);
// Row icons at drawer scale, against the text-2xl labels.
assertIncludesUtilities(icon, ["max-md:size-6"]);
// pl-6 hangs sublink text off the parent label (rows are px-0: icon +
// gap the sublink's own px-3).
assertIncludesUtilities(submenu, ["max-md:pl-6"]);
assertIncludesUtilities(sublink, [
"max-md:min-h-10",
"max-md:px-3",

View File

@ -383,19 +383,15 @@ function createLocalServePlugin(): Plugin {
// 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.
// URL and is served as-is (no HMR). Both served snapshots and proxied device
// pages get scripts/mock-bar.client.js — a floating bar that lists the
// snapshots, opens the one matching the open page, captures the open device
// page (POST /mocks/__save), and inside a snapshot takes over its LuCI links.
// 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",
);
const MOCK_BAR_CLIENT = resolve(CURRENT_DIR, "scripts/mock-bar.client.js");
// A captured LuCI page is tens of KB — anything past this is not a page.
const MOCK_SAVE_LIMIT = 20 * 1024 * 1024;
@ -513,17 +509,19 @@ function listMocks(): MockEntry[] {
.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 {
// Handing the snapshot list to the page is what makes the bar work in both
// contexts: inside a snapshot it also drives the link takeover, on a device
// page it tells the bar which snapshots exist (`current` is null there — a
// live page is not one of them). data-aurora-mock marks every tag this server
// injects so a capture can strip them back out.
function injectMockBar(html: string, current: string | null): 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 `;
`<script data-aurora-mock>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
` <script data-aurora-mock defer src="${MOCK_ROUTE}/__bar.js"></script>\n `;
return html.includes("</head>")
? html.replace("</head>", `${tags}</head>`)
: html + tags;
@ -623,23 +621,17 @@ function createMockPlugin(): Plugin {
// 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;
// Dev-helper client script (kept as a real file for editability).
if (pathname === `${MOCK_ROUTE}/__bar.js`) {
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"));
res.end(await readFile(MOCK_BAR_CLIENT, "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
// Capture endpoint, written to by mock-bar.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`) {
@ -649,9 +641,15 @@ function createMockPlugin(): Plugin {
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.
// a snapshot must stay a clean capture of the real page. The
// snapshot list in particular must not be baked in — it is
// re-injected, current, on every serve.
.replace(
/<script[^>]*\bsrc="(?:\/@vite\/client|\/mocks\/__capture\.js)"[^>]*>\s*<\/script>\s*/gi,
/<script\b[^>]*\bdata-aurora-mock\b[^>]*>[\s\S]*?<\/script>\s*/gi,
"",
)
.replace(
/<script[^>]*\bsrc="\/@vite\/client"[^>]*>\s*<\/script>\s*/gi,
"",
);
const page =
@ -679,8 +677,8 @@ function createMockPlugin(): Plugin {
return;
}
// A single snapshot: LuCI runtime neutralised, HMR client + in-mock
// navigation injected, doctype restored.
// A single snapshot: LuCI runtime neutralised, HMR client + mock bar
// injected, doctype restored.
if (inMocks && pathname.endsWith(".html")) {
const name = basename(decodeURIComponent(pathname));
const file = resolve(MOCKS_DIR, name);
@ -702,7 +700,7 @@ function createMockPlugin(): Plugin {
const snapshot = await readFile(file, "utf-8");
res.end(
ensureDoctype(
injectMockNav(
injectMockBar(
injectHmrClient(neutralizeLuciRuntime(snapshot)),
name,
),
@ -1060,15 +1058,14 @@ 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>`);
// Real device pages also get the mock bar, so the snapshot
// workflow is one click away instead of a URL to remember:
// it lists what .dev/mocks/ holds, opens this page's own
// snapshot when there is one, and captures the open page
// (same as Alt/Option+Shift+S). `current` is null — a live
// page is not itself a snapshot.
if (html.includes("</head>") && !html.includes("/__bar.js")) {
html = injectMockBar(html, null);
}
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
res.writeHead(status, {

View File

@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.10
PKG_RELEASE:=61
PKG_VERSION:=1.1.13
PKG_RELEASE:=62
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
[data-page^=admin-system-diskman] td[colspan]:has(>div[title]){border-radius:var(--radius-base);flex:0 0 100%;min-width:0;font-size:0;overflow:hidden}[data-page^=admin-system-diskman] td[colspan]>div[title],[data-page^=admin-system-diskman] .tr>div[style*=nowrap]>div[title]{text-overflow:ellipsis;font-size:.75rem;line-height:1.5rem;overflow:hidden}

File diff suppressed because one or more lines are too long

View File

@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow
PKG_VERSION:=20260531
PKG_RELEASE:=57
PKG_RELEASE:=58
PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=6161bacd30128d2ea0bae5c05290bd46e6d3606d
PKG_SOURCE_VERSION:=6ffd9078fe2c2946112a05a80a9c8a496250d98c
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0