🌴 Sync 2026-07-07 04:15:13

This commit is contained in:
github-actions[bot] 2026-07-07 04:15:13 +08:00
parent 2a528e3190
commit 5c17703277
33 changed files with 464 additions and 419 deletions

View File

@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dnsproxy PKG_NAME:=dnsproxy
PKG_VERSION:=0.82.1 PKG_VERSION:=0.83.0
PKG_RELEASE:=12 PKG_RELEASE:=13
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)?

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede PKG_NAME:=luci-app-daede
PKG_VERSION:=1.14.7 PKG_VERSION:=1.14.7
PKG_RELEASE:=28 PKG_RELEASE:=29
PKG_MAINTAINER:=kenzok8 PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)

View File

@ -677,9 +677,24 @@ return view.extend({
}; };
const importDirectNodes = function() { const importDirectNodes = function() {
const existing = {}; const existing = {};
const usedTags = {};
(((before || {}).nodes || {}).edges || []).forEach(function(node) { (((before || {}).nodes || {}).edges || []).forEach(function(node) {
existing[clashConverter.normalizeLink(node.link)] = node; existing[clashConverter.normalizeLink(node.link)] = node;
if (node.tag)
usedTags[String(node.tag)] = true;
}); });
const readableNodeTag = function(item, index) {
const base = String(item.name || '').replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim()
|| _('Node %d').format(index + 1);
let tag = base.slice(0, 96);
let suffix = 2;
while (usedTags[tag]) {
const tail = ' ' + suffix++;
tag = base.slice(0, 96 - tail.length) + tail;
}
usedTags[tag] = true;
return tag;
};
const fresh = items.filter(function(item) { const fresh = items.filter(function(item) {
const duplicate = existing[clashConverter.normalizeLink(item.link)]; const duplicate = existing[clashConverter.normalizeLink(item.link)];
if (duplicate) if (duplicate)
@ -689,7 +704,7 @@ return view.extend({
const imported = fresh.length const imported = fresh.length
? graphQL(endpoint, ? graphQL(endpoint,
'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}', 'mutation ImportNodes($args:[ImportArgument!]!){importNodes(rollbackError:false,args:$args){link error node{id}}}',
{ args: fresh.map(function(item, index) { return { link: item.link, tag: airportSync.backendId(airportId) + Date.now().toString(36) + (index + 1) }; }) }, token) { args: fresh.map(function(item, index) { return { link: item.link, tag: readableNodeTag(item, index) }; }) }, token)
: Promise.resolve({ importNodes: [] }); : Promise.resolve({ importNodes: [] });
return imported.then(function(result) { return imported.then(function(result) {

View File

@ -6,7 +6,7 @@
'require tools.widgets as widgets'; 'require tools.widgets as widgets';
/* /*
* Copyright (C) 2024-2025 Ycarus (Yannick Chabanois) <contact@openmptcprouter.com> for OpenMPTCProuter * Copyright (C) 2024-2026 Ycarus (Yannick Chabanois) <contact@openmptcprouter.com> for OpenMPTCProuter
* This is free software, licensed under the GNU General Public License v3. * This is free software, licensed under the GNU General Public License v3.
* See /LICENSE for more information * See /LICENSE for more information
*/ */
@ -276,30 +276,11 @@ return L.view.extend({
//o.value("handover", _("handover")); //o.value("handover", _("handover"));
o.default = "off"; o.default = "off";
o = s.option(form.Value, "multipath_weight", _("Weight"), _("Only for *weight schedulers/path managers (if any available)") + '<br />' + _("A weight >100 make it more attractive, a weight <100 make it less attractive. Max 256")); o = s.option(form.Value, "multipath_weight", _("Weight"), _("Only used by *weight schedulers/path managers. Ignored if no weight scheduler is selected.") + '<br />' + _("A weight >100 make it more attractive, a weight <100 make it less attractive. Max 256"));
o.datatype = "uinteger"; o.datatype = "uinteger";
o.rmempty = false; o.rmempty = false;
o.default = 100; o.default = 100;
return m.render().then(function(mapEl) { return m.render();
function updateWeightVisibility() {
var schedulerEl = document.getElementById('widget.cbid.network.globals.mptcp_scheduler');
var val = schedulerEl ? schedulerEl.value : (uci.get('network', 'globals', 'mptcp_scheduler') || '');
var show = val.toLowerCase().indexOf('weight') > -1;
mapEl.querySelectorAll('[data-name="multipath_weight"]').forEach(function(el) {
var row = el.closest('.cbi-value') || el;
row.style.display = show ? '' : 'none';
});
}
var schedulerEl = document.getElementById('widget.cbid.network.globals.mptcp_scheduler');
if (schedulerEl) {
schedulerEl.addEventListener('change', updateWeightVisibility);
}
updateWeightVisibility();
return mapEl;
});
} }
}); });

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.7.1 PKG_VERSION:=26.7.1
PKG_RELEASE:=173 PKG_RELEASE:=174
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \

View File

@ -179,9 +179,9 @@ insert_nftset() {
printf "%s\n" "$@" printf "%s\n" "$@"
else else
cat cat
fi | tr -s ' \t' '\n' | awk -v s="$suffix" -v n="$nftset_name" -v t="$NFTABLE_NAME" ' fi | awk -v s="$suffix" -v n="$nftset_name" -v t="$NFTABLE_NAME" '
{ BEGIN {
gsub(/^[ \t\r]+|[ \t\r]+$/, ""); RS = "[ \t\n\r]+"
} }
$0 != "" { $0 != "" {
if (first == 0) { if (first == 0) {

View File

@ -4,7 +4,7 @@
"read": { "read": {
"file": { "file": {
"/etc/samba/smb.conf.template": [ "read" ], "/etc/samba/smb.conf.template": [ "read" ],
"/usr/sbin/smbd": [ "exec" ] "/usr/sbin/smbd -V": [ "exec" ]
}, },
"uci": [ "samba4" ] "uci": [ "samba4" ]
}, },

View File

@ -69,7 +69,7 @@ return baseclass.extend({
rule.extport, rule.extport,
rule.proto, rule.proto,
expires_str, expires_str,
rule.descr, '%h'.format(rule.descr),
E('button', { E('button', {
'class': 'btn cbi-button-remove', 'class': 'btn cbi-button-remove',
'click': L.bind(handleDelRule, this, rule.num), 'click': L.bind(handleDelRule, this, rule.num),

View File

@ -66,7 +66,7 @@ return view.extend({
rule.extport, rule.extport,
rule.proto, rule.proto,
expires_str, expires_str,
rule.descr, '%h'.format(rule.descr),
E('button', { E('button', {
'class': 'btn cbi-button-remove', 'class': 'btn cbi-button-remove',
'click': L.bind(handleDelRule, this, rule.num), 'click': L.bind(handleDelRule, this, rule.num),

View File

@ -1,17 +1,12 @@
# OpenWrt LuCI web UI address # Optional — run `pnpm setup` once and you usually don't need a .env at all.
VITE_OPENWRT_HOST=http://192.168.1.1
# SSH config for syncing .ut template files to the device on change # OpenWrt router address — bare IP/hostname; the web proxy target and the
# Leave SSH_HOST empty to disable ut sync. # .ut-sync ssh target both derive from it (ssh key selection etc. belongs
VITE_OPENWRT_SSH_HOST=root@192.168.1.1 # in ~/.ssh/config)
# # VITE_OPENWRT_HOST=192.168.1.1
# Path to SSH private key (optional, falls back to ssh-agent or ~/.ssh/config)
# VITE_OPENWRT_SSH_KEY=~/.ssh/id_rsa
# Development server configuration # Dev server bind / port
VITE_DEV_PORT=5173 VITE_DEV_PORT=5173
# Specify which IP addresses the server should listen on # 0.0.0.0 = LAN; 127.0.0.1 = local only
# Set to 0.0.0.0 to listen on all addresses, including LAN and public addresses VITE_DEV_HOST=0.0.0.0
# Set to 127.0.0.1 to listen only on local machine
VITE_DEV_HOST=0.0.0.0

View File

@ -28,18 +28,16 @@ pnpm install
### 2. Configure Environment ### 2. Configure Environment
```bash ```bash
# Copy environment template # One-shot wizard: asks for the router IP, generates/installs an SSH key
cp .env.example .env # (one router-password prompt), and writes .env
pnpm setup
# Edit .env and set your OpenWrt device address
# VITE_OPENWRT_HOST=http://192.168.1.1
``` ```
**Environment Variables:** If the router is at the default `192.168.1.1` and passwordless SSH already works, no `.env` is needed at all — every value below has a working default.
- `VITE_OPENWRT_HOST` - Your OpenWrt LuCI web interface URL (required) **Environment Variables** (all optional):
- `VITE_OPENWRT_SSH_HOST` - SSH target for `.ut` template sync, e.g. `root@192.168.1.1` (optional)
- `VITE_OPENWRT_SSH_KEY` - Path to SSH private key (optional, falls back to ssh-agent or `~/.ssh/config`) - `VITE_OPENWRT_HOST` - bare router address, e.g. `192.168.1.1` (default; `host:port` and full-URL forms also accepted). The web proxy target and the `.ut`-sync SSH target (`root@<hostname>`) both derive from it; anything fancier (a dedicated key, a jump host, a non-standard ssh port) belongs in a `Host` block in `~/.ssh/config`, which ssh picks up automatically.
- `VITE_DEV_HOST` - Development server host (code default: `127.0.0.1`, `.env.example` sets `0.0.0.0` for LAN access) - `VITE_DEV_HOST` - Development server host (code default: `127.0.0.1`, `.env.example` sets `0.0.0.0` for LAN access)
- `VITE_DEV_PORT` - Development server port (default: `5173`) - `VITE_DEV_PORT` - Development server port (default: `5173`)
@ -94,7 +92,7 @@ Third-party compatibility patches are **not** bundled into `main.css` — they a
**Adding new styles:** **Adding new styles:**
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — no `@layer` wrappers needed (any that remain are stripped by PostCSS). - New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — don't add `@layer` wrappers: theme partials stay unlayered, so they outrank Tailwind's layered base/utilities regardless of specificity.
- Compatibility fix for a third-party LuCI app/page → add a new file under `media/patches/` (see below). - Compatibility fix for a third-party LuCI app/page → add a new file under `media/patches/` (see below).
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties. All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties.
@ -184,46 +182,23 @@ For LuCI-specific JavaScript development, refer to the official API documentatio
- **CSS changes**: Trigger full page reload via custom HMR handler - **CSS changes**: Trigger full page reload via custom HMR handler
- **JS changes**: Trigger full page reload via custom HMR handler - **JS changes**: Trigger full page reload via custom HMR handler
- **Template changes** (`.ut` files): Auto-synced to router via SCP and trigger full page reload (requires SSH setup, see below) - **Template changes** (`.ut` files): Auto-synced to router over SSH and trigger full page reload (one-time `pnpm setup` required, see below)
### Template (`.ut`) Live Sync ### Template (`.ut`) Live Sync
The `.ut` template files are rendered server-side on the OpenWrt device. To see template changes during development, the dev server can automatically sync modified `.ut` files to the router via SCP. The `.ut` template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run `pnpm setup` once to configure passwordless SSH; after that it's fully automatic:
**1. Set up SSH key authentication to your router:** - **On startup**, the whole template directory is pushed (as one tarball over ssh stdin — Dropbear has no SFTP server for scp), so edits made while the dev server was down never leave the router stale.
- **On save**, changes are debounced and the directory is pushed again, then the browser reloads.
- **On page load**, requests to `/cgi-bin` wait for any in-flight push, so a proxied render never uses a stale template.
```bash **Troubleshooting** — sync errors are printed with the fix:
# Generate a key if you don't have one
ssh-keygen -t ed25519
# Copy your public key to the router (OpenWrt uses Dropbear, not OpenSSH)
cat ~/.ssh/id_ed25519.pub | ssh root@192.168.1.1 "cat >> /etc/dropbear/authorized_keys"
# Verify passwordless login works
ssh root@192.168.1.1 "echo ok"
```
**2. Add SSH config to `.env`:**
```bash
# SSH target for .ut file sync (user@host)
VITE_OPENWRT_SSH_HOST=root@192.168.1.1
# Optional: path to SSH private key (falls back to ssh-agent or ~/.ssh/config)
# VITE_OPENWRT_SSH_KEY=~/.ssh/id_ed25519
```
**3. Start `pnpm dev` and edit any `.ut` file** — the dev server will automatically sync it to the router and reload the browser.
**Troubleshooting:**
The dev server checks SSH connectivity on startup and prints actionable errors:
- **Host key mismatch** (device was reflashed): Run `ssh-keygen -R <device-ip>`, then restart the dev server - **Host key mismatch** (device was reflashed): Run `ssh-keygen -R <device-ip>`, then restart the dev server
- **Authentication failed** (public key not on device): Copy your key with the command above - **Authentication failed** (public key not on device): Run `pnpm setup`
- **Connection refused/timed out**: Check that the device is online and SSH is enabled - **Connection refused/timed out**: Check that the device is online and SSH is enabled
If `VITE_OPENWRT_SSH_HOST` is not set, template sync is simply disabled and other dev features work normally. A failed sync is retried on the next `.ut` change; CSS/JS dev features work normally without SSH.
## Building for Production ## Building for Production
@ -252,10 +227,9 @@ htdocs/luci-static/
**Build Process:** **Build Process:**
1. `pnpm gen:tokens` regenerates `src/media/_tokens.css` from `tokens/` (see [Design Tokens](#design-tokens)) 1. `pnpm gen:tokens` regenerates `src/media/_tokens.css` from `tokens/` (see [Design Tokens](#design-tokens))
2. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`) 2. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`), keeping Tailwind's native `@layer` structure
3. Custom PostCSS plugin removes `@layer` at-rules for OpenWrt compatibility 3. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
4. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser 4. Static assets copied from `.dev/public/aurora/`
5. Static assets copied from `.dev/public/aurora/`
## Package Compilation ## Package Compilation

View File

@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"setup": "node scripts/setup.js",
"build": "npm run clean && npm run gen:tokens && vite build", "build": "npm run clean && npm run gen:tokens && vite build",
"clean": "node scripts/clean.js", "clean": "node scripts/clean.js",
"gen:tokens": "node scripts/gen-tokens.js", "gen:tokens": "node scripts/gen-tokens.js",

View File

@ -0,0 +1,93 @@
/**
* One-shot dev environment setup: writes .env and installs an SSH key on the
* router so ut-sync works without any manual ssh configuration.
*
* Usage: pnpm setup (asks for the router IP, then one router password prompt)
*/
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
const SSH_OPTS = [
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
];
function canAuth(host) {
return (
spawnSync(
"ssh",
[...SSH_OPTS, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host, "echo ok"],
{ stdio: "ignore" },
).status === 0
);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ip =
(await rl.question("Router IP [192.168.1.1]: ")).trim() || "192.168.1.1";
rl.close();
const host = `root@${ip}`;
const sshDir = join(homedir(), ".ssh");
let pubKeyPath = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]
.map((f) => join(sshDir, f))
.find((f) => existsSync(f));
if (!pubKeyPath) {
console.log("No SSH key found — generating ~/.ssh/id_ed25519 ...");
const keygen = spawnSync(
"ssh-keygen",
["-t", "ed25519", "-N", "", "-f", join(sshDir, "id_ed25519")],
{ stdio: "inherit" },
);
if (keygen.status !== 0) {
console.error("✗ ssh-keygen failed.");
process.exit(1);
}
pubKeyPath = join(sshDir, "id_ed25519.pub");
}
if (canAuth(host)) {
console.log(`✓ Passwordless SSH to ${host} already works.`);
} else {
console.log(
`Installing ${pubKeyPath} on the router (enter the router password once):`,
);
const pubKey = readFileSync(pubKeyPath, "utf-8").trim();
const install = spawnSync(
"ssh",
[
...SSH_OPTS,
host,
`mkdir -p /etc/dropbear && echo '${pubKey}' >> /etc/dropbear/authorized_keys && chmod 600 /etc/dropbear/authorized_keys`,
],
{ stdio: "inherit" },
);
if (install.status !== 0 || !canAuth(host)) {
console.error(
`✗ Key install failed. Check that the device at ${ip} is reachable and the password is correct, then re-run pnpm setup.`,
);
process.exit(1);
}
console.log("✓ Passwordless SSH configured.");
}
const envPath = resolve(process.cwd(), ".env");
const kept = existsSync(envPath)
? readFileSync(envPath, "utf-8")
.split("\n")
.filter((l) => l && !l.startsWith("VITE_OPENWRT_HOST="))
: [];
writeFileSync(
envPath,
[`VITE_OPENWRT_HOST=${ip}`, ...kept].join("\n") + "\n",
);
console.log(`✓ Wrote .env (VITE_OPENWRT_HOST=${ip})`);
console.log("Done — run `pnpm dev`.");

View File

@ -18,118 +18,116 @@
} }
} }
@layer login { .login-screen {
.login-screen { @apply relative flex min-h-dvh flex-col items-center justify-center px-5 pb-14;
@apply relative flex min-h-dvh flex-col items-center justify-center px-5 pb-14; }
}
.login-shell { .login-shell {
@apply w-full max-w-md; @apply w-full max-w-md;
} }
/* Fixed sibling layer (not form ancestor) on its own GPU surface so input /* Fixed sibling layer (not form ancestor) on its own GPU surface so input
paints don't re-sample the bg image. DOM order keeps the form on top. */ paints don't re-sample the bg image. DOM order keeps the form on top. */
/* LQIP shown immediately; falls back to full image when no LQIP stored */ /* LQIP shown immediately; falls back to full image when no LQIP stored */
.login-bg { .login-bg {
@apply pointer-events-none fixed inset-0 transform-[translateZ(0)] bg-cover bg-center bg-no-repeat [background-image:var(--login-bg-lqip,var(--login-bg))]; @apply pointer-events-none fixed inset-0 transform-[translateZ(0)] bg-cover bg-center bg-no-repeat [background-image:var(--login-bg-lqip,var(--login-bg))];
} }
/* Full image fades in on top once loaded (compositor-only opacity change) */ /* Full image fades in on top once loaded (compositor-only opacity change) */
.login-bg::after { .login-bg::after {
@apply pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 [will-change:opacity] content-[''] [transition:opacity_0.5s_ease] [background-image:var(--login-bg)]; @apply pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 [will-change:opacity] content-[''] [transition:opacity_0.5s_ease] [background-image:var(--login-bg)];
} }
.login-bg.full-loaded::after { .login-bg.full-loaded::after {
@apply opacity-100; @apply opacity-100;
} }
.login-card { .login-card {
@apply bg-surface flex flex-col gap-7 rounded-4xl p-14 shadow-lg max-md:rounded-3xl max-md:p-9; @apply bg-surface flex flex-col gap-7 rounded-4xl p-14 shadow-lg max-md:rounded-3xl max-md:p-9;
} }
.login-brand { .login-brand {
@apply flex items-center gap-3.5; @apply flex items-center gap-3.5;
} }
.login-divider { .login-divider {
@apply via-hairline h-px w-full animate-[divider-in_0.7s_cubic-bezier(0.16,1,0.3,1)_0.1s_both] border-0 bg-linear-to-r from-transparent to-transparent; @apply via-hairline h-px w-full animate-[divider-in_0.7s_cubic-bezier(0.16,1,0.3,1)_0.1s_both] border-0 bg-linear-to-r from-transparent to-transparent;
} }
.login-logo { .login-logo {
@apply bg-brand-subtle ring-brand-subtle flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ring-1; @apply bg-brand-subtle ring-brand-subtle flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ring-1;
& img { & img {
@apply h-6 w-6; @apply h-6 w-6;
} }
} }
.login-title { .login-title {
@apply flex flex-col gap-1; @apply flex flex-col gap-1;
} }
.login-hostname { .login-hostname {
@apply text-text text-xl font-semibold tracking-tight; @apply text-text text-xl font-semibold tracking-tight;
} }
.login-subtitle { .login-subtitle {
@apply text-text text-sm; @apply text-text text-sm;
} }
.login-form { .login-form {
@apply flex flex-col gap-5; @apply flex flex-col gap-5;
} }
.login-fields { .login-fields {
@apply flex flex-col gap-4; @apply flex flex-col gap-4;
} }
.login-field { .login-field {
@apply flex flex-col gap-2; @apply flex flex-col gap-2;
} }
.login-label { .login-label {
@apply text-text text-xs font-semibold tracking-widest uppercase; @apply text-text text-xs font-semibold tracking-widest uppercase;
} }
.input-wrap { .input-wrap {
@apply border-hairline bg-surface-overlay dark:bg-surface-sunken relative rounded-2xl border [contain:layout_style]; @apply border-hairline bg-surface-overlay dark:bg-surface-sunken relative rounded-2xl border [contain:layout_style];
} }
/* Focus ring: opacity-only transition runs on compositor thread, not main thread /* Focus ring: opacity-only transition runs on compositor thread, not main thread
border-color changes require a repaint (~400 ms INP); opacity changes do not. */ border-color changes require a repaint (~400 ms INP); opacity changes do not. */
.input-wrap::after { .input-wrap::after {
@apply border-brand pointer-events-none absolute -inset-px rounded-[calc(var(--radius-2xl)+1px)] border-2 opacity-0 [will-change:opacity] content-[''] [transition:opacity_150ms_ease]; @apply border-brand pointer-events-none absolute -inset-px rounded-[calc(var(--radius-2xl)+1px)] border-2 opacity-0 [will-change:opacity] content-[''] [transition:opacity_150ms_ease];
} }
.input-wrap:focus-within::after { .input-wrap:focus-within::after {
@apply opacity-100; @apply opacity-100;
} }
.login-input { .login-input {
@apply text-text placeholder:text-text-subtle w-full appearance-none rounded-2xl border-0 bg-transparent px-3 py-2.5 text-base outline-none; @apply text-text placeholder:text-text-subtle w-full appearance-none rounded-2xl border-0 bg-transparent px-3 py-2.5 text-base outline-none;
} }
.login-error { .login-error {
@apply bg-danger-surface text-danger border-hairline rounded-2xl border px-3 py-2 text-center text-sm; @apply bg-danger-surface text-danger border-hairline rounded-2xl border px-3 py-2 text-center text-sm;
} }
.login-actions { .login-actions {
@apply pt-4; @apply pt-4;
} }
.login-submit { .login-submit {
@apply bg-brand text-on-brand w-full cursor-pointer rounded-2xl border-0 py-2.5 text-base font-medium hover:opacity-90 active:opacity-80; @apply bg-brand text-on-brand w-full cursor-pointer rounded-2xl border-0 py-2.5 text-base font-medium hover:opacity-90 active:opacity-80;
} }
.login-footer { .login-footer {
@apply text-text-muted absolute right-0 bottom-5 left-0 px-5 text-center text-xs; @apply text-text-muted absolute right-0 bottom-5 left-0 px-5 text-center text-xs;
& p { & p {
@apply whitespace-normal; @apply whitespace-normal;
} }
& a { & a {
@apply text-brand hover:underline; @apply text-brand hover:underline;
}
} }
} }

View File

@ -6,7 +6,7 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import browserslist from "browserslist"; import browserslist from "browserslist";
import { exec } from "child_process"; import { exec } from "child_process";
import { watch as fsWatch, readdirSync } from "fs"; import { existsSync, readdirSync } from "fs";
import { mkdir, readdir, readFile, writeFile } from "fs/promises"; import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import { browserslistToTargets } from "lightningcss"; import { browserslistToTargets } from "lightningcss";
import { basename, dirname, join, relative, resolve } from "path"; import { basename, dirname, join, relative, resolve } from "path";
@ -59,35 +59,36 @@ function createLuciJsCompressPlugin(): Plugin {
}, },
async generateBundle() { async generateBundle() {
for (const filePath of jsFiles) { await Promise.all(
try { jsFiles.map(async (filePath) => {
const sourceCode = await readFile(filePath, "utf-8"); try {
const compressed = await terserMinify(sourceCode, { const sourceCode = await readFile(filePath, "utf-8");
parse: { bare_returns: true }, const compressed = await terserMinify(sourceCode, {
compress: false, parse: { bare_returns: true },
mangle: false, compress: false,
format: { comments: false, beautify: false }, mangle: false,
}); format: { comments: false, beautify: false },
});
const relativePath = relative( const relativePath = relative(
resolve(CURRENT_DIR, "src/resource"), resolve(CURRENT_DIR, "src/resource"),
filePath, filePath,
).replace(/\\/g, "/"); ).replace(/\\/g, "/");
const outputPath = join(outDir, "resources", relativePath); const outputPath = join(outDir, "resources", relativePath);
await mkdir(dirname(outputPath), { recursive: true }); await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, compressed.code || sourceCode, "utf-8"); await writeFile(outputPath, compressed.code || sourceCode, "utf-8");
} catch (error: any) { } catch (error: any) {
console.error(`JS compress failed: ${filePath}`, error?.message); console.error(`JS compress failed: ${filePath}`, error?.message);
} }
} }),
);
}, },
}; };
} }
interface RouteConfig { interface RouteConfig {
routes: Record<string, string>; routes: Record<string, string>;
shouldRewrite: boolean;
hmrMessage: string; hmrMessage: string;
} }
@ -96,18 +97,13 @@ interface ResourceConfig {
js: RouteConfig; js: RouteConfig;
} }
// On-demand third-party patches: serve each src/media/patches/<page>.css at // On-demand third-party patches: serve src/media/patches/<page>.css at
// /luci-static/aurora/patches/<page>.css in dev. Without this, header.ut's patch // /luci-static/aurora/patches/<page>.css in dev. Without this, header.ut's patch
// <link> falls through to the OpenWrt proxy (404 / stale router asset) and patch // <link> falls through to the OpenWrt proxy (404 / stale router asset) and patch
// edits don't trigger HMR. Mirrors the build entries derived from the same dir. // edits don't trigger HMR. Matched per request so new patch files work without
function patchCssRoutes(): Record<string, string> { // a dev-server restart.
const dir = resolve(CURRENT_DIR, "src/media/patches"); const PATCH_PUBLIC_PREFIX = "/luci-static/aurora/patches/";
return Object.fromEntries( const PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/media/patches");
readdirSync(dir)
.filter((f) => f.endsWith(".css"))
.map((f) => [`/luci-static/aurora/patches/${f}`, `/src/media/patches/${f}`]),
);
}
function createLocalServePlugin(): Plugin { function createLocalServePlugin(): Plugin {
const resourceConfig: ResourceConfig = { const resourceConfig: ResourceConfig = {
@ -115,9 +111,7 @@ function createLocalServePlugin(): Plugin {
routes: { routes: {
"/luci-static/aurora/main.css": "/src/media/main.css", "/luci-static/aurora/main.css": "/src/media/main.css",
"/luci-static/aurora/login.css": "/src/media/login.css", "/luci-static/aurora/login.css": "/src/media/login.css",
...patchCssRoutes(),
}, },
shouldRewrite: true,
hmrMessage: "CSS file changed", hmrMessage: "CSS file changed",
}, },
js: { js: {
@ -126,7 +120,6 @@ function createLocalServePlugin(): Plugin {
"src/resource/view/aurora/sysauth.js", "src/resource/view/aurora/sysauth.js",
"/luci-static/resources/menu-aurora.js": "src/resource/menu-aurora.js", "/luci-static/resources/menu-aurora.js": "src/resource/menu-aurora.js",
}, },
shouldRewrite: false,
hmrMessage: "JS file changed", hmrMessage: "JS file changed",
}, },
}; };
@ -162,6 +155,14 @@ function createLocalServePlugin(): Plugin {
return next(); return next();
} }
if (pathname.startsWith(PATCH_PUBLIC_PREFIX) && pathname.endsWith(".css")) {
const file = basename(pathname);
if (existsSync(join(PATCH_SRC_DIR, file))) {
req.url = `/src/media/patches/${file}` + (search ? `?${search}` : "");
return next();
}
}
const jsPath = resourceConfig.js.routes[pathname]; const jsPath = resourceConfig.js.routes[pathname];
if (jsPath) { if (jsPath) {
try { try {
@ -184,6 +185,15 @@ function createLocalServePlugin(): Plugin {
handleHotUpdate({ file, server }) { handleHotUpdate({ file, server }) {
const normalizedFile = file.replace(/\\/g, "/"); const normalizedFile = file.replace(/\\/g, "/");
if (
normalizedFile.startsWith(PATCH_SRC_DIR.replace(/\\/g, "/") + "/") &&
normalizedFile.endsWith(".css")
) {
console.log(`[HMR] Patch CSS changed: ${basename(normalizedFile)}`);
server.ws.send({ type: "full-reload", path: "*" });
return [];
}
const resources = [ const resources = [
{ map: cssHmrMap, config: resourceConfig.css }, { map: cssHmrMap, config: resourceConfig.css },
{ map: jsHmrMap, config: resourceConfig.js }, { map: jsHmrMap, config: resourceConfig.js },
@ -204,113 +214,116 @@ function createLocalServePlugin(): Plugin {
const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora"); const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora");
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora"; const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
interface ScpConfig { // Key selection is ssh's own job: ssh-agent or a Host block in ~/.ssh/config.
host: string; const SSH_ARGS = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
key?: string;
}
function buildSshArgs(cfg: ScpConfig): string {
const args = ["-o StrictHostKeyChecking=no", "-o UserKnownHostsFile=/dev/null"];
if (cfg.key) args.push(`-i "${cfg.key}"`);
return args.join(" ");
}
// Stream the file over ssh stdin instead of scp: OpenSSH 9+ scp defaults to
// the SFTP protocol, which Dropbear on OpenWrt does not ship a server for.
function buildScpCommand(localPath: string, remotePath: string, cfg: ScpConfig): string {
return `ssh ${buildSshArgs(cfg)} "${cfg.host}" "cat > '${remotePath}'" < "${localPath}"`;
}
function parseHost(sshHost: string): string { function parseHost(sshHost: string): string {
const atIndex = sshHost.lastIndexOf("@"); const atIndex = sshHost.lastIndexOf("@");
return atIndex !== -1 ? sshHost.slice(atIndex + 1) : sshHost; return atIndex !== -1 ? sshHost.slice(atIndex + 1) : sshHost;
} }
async function checkSshConnection(cfg: ScpConfig): Promise<boolean> { function reportSshError(err: any, sshHost: string): void {
const host = parseHost(cfg.host); const host = parseHost(sshHost);
const stderr = err?.stderr || err?.message || "";
if (stderr.includes("Host key verification failed") || stderr.includes("REMOTE HOST IDENTIFICATION HAS CHANGED")) {
console.error(`\n[UT Sync] SSH host key mismatch for ${host}.`);
console.error(`[UT Sync] The device may have been reflashed. Run this to fix:\n`);
console.error(` ssh-keygen -R ${host}\n`);
} else if (stderr.includes("Permission denied") || stderr.includes("Authentication failed")) {
console.error(`\n[UT Sync] SSH authentication failed for ${sshHost}.`);
console.error(`[UT Sync] Run \`pnpm setup\` to configure passwordless login.\n`);
} else if (stderr.includes("Connection refused") || stderr.includes("Connection timed out") || stderr.includes("No route to host")) {
console.error(`\n[UT Sync] Cannot reach ${host}. Check that the device is online and SSH is enabled.\n`);
} else {
console.error(`\n[UT Sync] SSH connection failed: ${stderr}\n`);
}
}
async function checkSshConnection(sshHost: string): Promise<boolean> {
try { try {
await execAsync(`ssh ${buildSshArgs(cfg)} -o ConnectTimeout=5 "${cfg.host}" echo ok`); await execAsync(`ssh ${SSH_ARGS} -o ConnectTimeout=5 "${sshHost}" echo ok`);
console.log(`[UT Sync] SSH connection verified.`); console.log(`[UT Sync] SSH connection verified.`);
return true; return true;
} catch (err: any) { } catch (err: any) {
const stderr = err?.stderr || err?.message || ""; reportSshError(err, sshHost);
if (stderr.includes("Host key verification failed") || stderr.includes("REMOTE HOST IDENTIFICATION HAS CHANGED")) {
console.error(`\n[UT Sync] SSH host key mismatch for ${host}.`);
console.error(`[UT Sync] The device may have been reflashed. Run this to fix:\n`);
console.error(` ssh-keygen -R ${host}\n`);
console.error(`[UT Sync] Then restart the dev server.\n`);
} else if (stderr.includes("Permission denied") || stderr.includes("Authentication failed")) {
console.error(`\n[UT Sync] SSH authentication failed for ${cfg.host}.`);
console.error(`[UT Sync] Copy your public key to the device:\n`);
console.error(` cat ~/.ssh/id_ed25519.pub | ssh ${cfg.host} "cat >> /etc/dropbear/authorized_keys"\n`);
} else if (stderr.includes("Connection refused") || stderr.includes("Connection timed out") || stderr.includes("No route to host")) {
console.error(`\n[UT Sync] Cannot reach ${host}. Check that the device is online and SSH is enabled.\n`);
} else {
console.error(`\n[UT Sync] SSH connection failed: ${stderr}\n`);
}
return false; return false;
} }
} }
function createUtSyncPlugin(cfg: ScpConfig): Plugin { function createUtSyncPlugin(sshHost: string): Plugin {
let syncing = false; let dirty = false;
let pendingFile: string | null = null; let flushing: Promise<void> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function syncFile(filename: string, server: any) { // The templates are tiny (3 files), so every sync just pushes the whole
if (syncing) { // directory in one tarball streamed over ssh stdin — OpenSSH 9+ scp defaults
pendingFile = filename; // to the SFTP protocol, which Dropbear on OpenWrt does not ship a server for.
return; const pushAll = () =>
execAsync(
`tar -C "${UT_TEMPLATE_DIR}" -cf - . | ssh ${SSH_ARGS} "${sshHost}" "mkdir -p '${UT_REMOTE_DIR}' && tar -xf - -C '${UT_REMOTE_DIR}'"`,
);
const flush = (server: any): Promise<void> => {
if (!flushing) {
flushing = (async () => {
while (dirty) {
dirty = false;
try {
await pushAll();
console.log(`[UT Sync] Templates synced to ${sshHost}.`);
server.ws.send({ type: "full-reload", path: "*" });
} catch (err: any) {
reportSshError(err, sshHost);
break;
}
}
flushing = null;
})();
} }
return flushing;
};
syncing = true; const markDirty = (server: any) => {
pendingFile = null; dirty = true;
if (timer) clearTimeout(timer);
const filePath = join(UT_TEMPLATE_DIR, filename); timer = setTimeout(() => flush(server), 150);
const remotePath = `${UT_REMOTE_DIR}/${filename}`; };
const cmd = buildScpCommand(filePath, remotePath, cfg);
console.log(`[UT Sync] Syncing ${filename}${cfg.host}:${remotePath}`);
execAsync(cmd)
.then(() => {
console.log(`[UT Sync] Done. Reloading browser.`);
server.ws.send({ type: "full-reload", path: "*" });
})
.catch((err: any) => {
console.error(`[UT Sync] Failed to sync ${filename}:`, err?.message);
})
.finally(() => {
syncing = false;
if (pendingFile) syncFile(pendingFile, server);
});
}
return { return {
name: "ut-sync-plugin", name: "ut-sync-plugin",
apply: "serve", apply: "serve",
configureServer(server) { configureServer(server) {
if (!cfg.host) {
console.log("[UT Sync] Disabled: VITE_OPENWRT_SSH_HOST not set in .env");
return;
}
const authInfo = cfg.key ? `key (${cfg.key})` : "ssh-agent/config";
console.log(`[UT Sync] Watching ${UT_TEMPLATE_DIR}`); console.log(`[UT Sync] Watching ${UT_TEMPLATE_DIR}`);
console.log(`[UT Sync] Target: ${cfg.host}:${UT_REMOTE_DIR} (auth: ${authInfo})`); console.log(`[UT Sync] Target: ${sshHost}:${UT_REMOTE_DIR}`);
checkSshConnection(cfg).then((ok) => { // Full push on startup so edits made while the server was down (or a
if (!ok) return; // freshly flashed device) can't leave the router stale.
checkSshConnection(sshHost).then((ok) => {
if (ok) markDirty(server);
});
// Accept both "change" and "rename" — VS Code atomic saves emit "rename" on macOS server.watcher.add(UT_TEMPLATE_DIR);
const watcher = fsWatch(UT_TEMPLATE_DIR, (eventType, filename) => { const onTemplateEvent = (file: string) => {
if (!filename?.endsWith(".ut")) return; if (file.startsWith(UT_TEMPLATE_DIR) && file.endsWith(".ut")) {
syncFile(filename, server); markDirty(server);
}); }
};
server.watcher.on("add", onTemplateEvent);
server.watcher.on("change", onTemplateEvent);
server.httpServer?.on("close", () => watcher.close()); // Hold page loads until pending template pushes land, so a proxied
// render never uses a stale template.
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith("/cgi-bin") || (!dirty && !flushing)) {
return next();
}
if (timer) clearTimeout(timer);
flush(server).then(
() => next(),
() => next(),
);
}); });
}, },
}; };
@ -336,23 +349,36 @@ function createRedirectPlugin(): Plugin {
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, CURRENT_DIR, ""); const env = loadEnv(mode, CURRENT_DIR, "");
const OPENWRT_HOST = env.VITE_OPENWRT_HOST || "http://192.168.1.1:80"; // VITE_OPENWRT_HOST is just the router address — a bare IP/hostname like
const OPENWRT_SSH_HOST = env.VITE_OPENWRT_SSH_HOST || ""; // 192.168.1.1 (host:port and http:// URL forms also work). The web proxy
const OPENWRT_SSH_KEY = env.VITE_OPENWRT_SSH_KEY || ""; // target and the .ut-sync ssh target are both derived from it; ssh key
// selection etc. belongs in ~/.ssh/config, not here.
const OPENWRT_RAW = env.VITE_OPENWRT_HOST || "192.168.1.1";
const OPENWRT = new URL(
/^https?:\/\//.test(OPENWRT_RAW) ? OPENWRT_RAW : `http://${OPENWRT_RAW}`,
);
const OPENWRT_URL = OPENWRT.origin;
const OPENWRT_SSH_HOST = `root@${OPENWRT.hostname}`;
const DEV_HOST = env.VITE_DEV_HOST || "127.0.0.1"; const DEV_HOST = env.VITE_DEV_HOST || "127.0.0.1";
const DEV_PORT = Number(env.VITE_DEV_PORT) || 5173; const DEV_PORT = Number(env.VITE_DEV_PORT) || 5173;
const proxyConfig = { const proxyConfig = {
"/luci-static": { "/luci-static": {
target: OPENWRT_HOST, target: OPENWRT_URL,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
"/cgi-bin": { "/cgi-bin": {
target: OPENWRT_HOST, target: OPENWRT_URL,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
configure: (proxy: any) => { configure: (proxy: any) => {
// Force an uncompressed upstream response: the HTML injection below
// treats the body as UTF-8 text and would corrupt a gzipped payload.
proxy.on("proxyReq", (proxyReq: any) => {
proxyReq.removeHeader("accept-encoding");
});
proxy.on("proxyRes", (proxyRes: any, req: any, res: any) => { proxy.on("proxyRes", (proxyRes: any, req: any, res: any) => {
const contentType = proxyRes.headers["content-type"] || ""; const contentType = proxyRes.headers["content-type"] || "";
@ -395,7 +421,7 @@ export default defineConfig(({ mode }) => {
tailwindcss(), tailwindcss(),
createRedirectPlugin(), createRedirectPlugin(),
createLocalServePlugin(), createLocalServePlugin(),
createUtSyncPlugin({ host: OPENWRT_SSH_HOST, key: OPENWRT_SSH_KEY }), createUtSyncPlugin(OPENWRT_SSH_HOST),
createLuciJsCompressPlugin(), createLuciJsCompressPlugin(),
], ],
@ -405,22 +431,6 @@ export default defineConfig(({ mode }) => {
browserslist("last 4 versions, Firefox ESR, not dead"), browserslist("last 4 versions, Firefox ESR, not dead"),
), ),
}, },
postcss: {
plugins: [
{
postcssPlugin: "remove-layers",
Once(root) {
function removeLayers(node: any) {
node.walkAtRules("layer", (rule: any) => {
removeLayers(rule);
rule.replaceWith(rule.nodes);
});
}
removeLayers(root);
},
},
],
},
}, },
build: { build: {
@ -435,11 +445,11 @@ export default defineConfig(({ mode }) => {
// aurora/patches/<page>.css (the `patches/` key prefix lands them there // aurora/patches/<page>.css (the `patches/` key prefix lands them there
// via assetFileNames below). header.ut links the matching one per page. // via assetFileNames below). header.ut links the matching one per page.
...Object.fromEntries( ...Object.fromEntries(
readdirSync(resolve(CURRENT_DIR, "src/media/patches")) readdirSync(PATCH_SRC_DIR)
.filter((f) => f.endsWith(".css")) .filter((f) => f.endsWith(".css"))
.map((f) => [ .map((f) => [
`patches/${f.slice(0, -4)}`, `patches/${f.slice(0, -4)}`,
resolve(CURRENT_DIR, "src/media/patches", f), join(PATCH_SRC_DIR, f),
]), ]),
), ),
}, },

View File

@ -8,6 +8,7 @@ All dev commands run from `.dev/`:
```bash ```bash
cd .dev/ cd .dev/
pnpm setup # One-shot dev setup: router IP → .env, installs SSH key on device
pnpm dev # Start Vite dev server (proxies to OpenWrt device) pnpm dev # Start Vite dev server (proxies to OpenWrt device)
pnpm build # Clean + regenerate tokens + build production assets to htdocs/luci-static/ pnpm build # Clean + regenerate tokens + build production assets to htdocs/luci-static/
pnpm gen:tokens # Regenerate src/media/_tokens.css from tokens/ (also runs as part of build) pnpm gen:tokens # Regenerate src/media/_tokens.css from tokens/ (also runs as part of build)
@ -26,7 +27,7 @@ No linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.j
- `.dev/public/aurora/``htdocs/luci-static/aurora/` (copied as-is) - `.dev/public/aurora/``htdocs/luci-static/aurora/` (copied as-is)
- `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite - `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/``main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_elements.css`, `_layout.css`, `components/*.css`, `_utilities.css`) and `login.css` (standalone login page; imports `_base.css`). Third-party compatibility patches are not bundled into `main.css`; each lives in `media/patches/<data-page>.css`, builds to its own `htdocs/luci-static/aurora/patches/<data-page>.css`, and is linked on demand by `header.ut`, which discovers installed patches at render time via `fs.lsdir()` and matches them on path-segment-boundary prefixes (dynamic subpages inherit their prefix's patch; third-party packages may drop their own patch into the directory) — see "On-Demand Third-Party Patches" in `.dev/docs/DEVELOPMENT.md`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout. **CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/``main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_elements.css`, `_layout.css`, `components/*.css`, `_utilities.css`) and `login.css` (standalone login page; imports `_base.css`). Third-party compatibility patches are not bundled into `main.css`; each lives in `media/patches/<data-page>.css`, builds to its own `htdocs/luci-static/aurora/patches/<data-page>.css`, and is linked on demand by `header.ut`, which discovers installed patches at render time via `fs.lsdir()` and matches them on path-segment-boundary prefixes (dynamic subpages inherit their prefix's patch; third-party packages may drop their own patch into the directory) — see "On-Demand Third-Party Patches" in `.dev/docs/DEVELOPMENT.md`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. Built CSS keeps Tailwind's native `@layer` structure (theme partials are unlayered, so they outrank layered utilities); the OKLCH requirement already gates browsers to ones with `@layer` support. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**Design tokens**: `_tokens.css` is GENERATED — do not hand-edit (see its header comment). Source of truth is `.dev/tokens/`: `defaults.js` holds the 10 editable input colors per mode (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`); `spec.js` declares how every other token derives from those via `mix`/`shade`/`set`/`alpha`/`const` operators plus `FIXED` literals (shadows); `engine.js`/`resolve.js` do the OKLCH math and flatten everything to plain `oklch(...)` values. After changing inputs or derivations, run `pnpm gen:tokens` to rewrite `_tokens.css`, and `pnpm test` to check the color-math and token invariants. **Design tokens**: `_tokens.css` is GENERATED — do not hand-edit (see its header comment). Source of truth is `.dev/tokens/`: `defaults.js` holds the 10 editable input colors per mode (`bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger`); `spec.js` declares how every other token derives from those via `mix`/`shade`/`set`/`alpha`/`const` operators plus `FIXED` literals (shadows); `engine.js`/`resolve.js` do the OKLCH math and flatten everything to plain `oklch(...)` values. After changing inputs or derivations, run `pnpm gen:tokens` to rewrite `_tokens.css`, and `pnpm test` to check the color-math and token invariants.
@ -39,5 +40,5 @@ No linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.j
## Key References ## Key References
- **Development guide**: `.dev/docs/DEVELOPMENT.md` — dev server setup, env config, proxy details, CI workflows, directory structure - **Development guide**: `.dev/docs/DEVELOPMENT.md` — dev server setup, env config, proxy details, CI workflows, directory structure
- **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, redirect, ut-sync, remove-layers) - **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, redirect, ut-sync)
- **Version**: `PKG_VERSION` and `PKG_RELEASE` in `Makefile` - **Version**: `PKG_VERSION` and `PKG_RELEASE` in `Makefile`

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_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.0.7 PKG_VERSION:=1.0.8
PKG_RELEASE:=42 PKG_RELEASE:=43
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:= LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}[data-page=admin-statistics-graphs] [data-plugin] img:where([data-darkmode=true],[data-darkmode=true] *){--tw-hue-rotate:hue-rotate(150deg);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,);--tw-invert:invert(100%)}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} @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-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}[data-page=admin-statistics-graphs] [data-plugin] img:where([data-darkmode=true],[data-darkmode=true] *){--tw-hue-rotate:hue-rotate(150deg);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,);--tw-invert:invert(100%)}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}

View File

@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@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}}[data-page=admin-system-filemanager] #file-manager-container{overflow:auto}[data-page=admin-system-filemanager] #file-manager-container #status-bar{border-style:var(--tw-border-style);background-color:var(--surface);border-width:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid} @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}}}[data-page=admin-system-filemanager] #file-manager-container{overflow:auto}[data-page=admin-system-filemanager] #file-manager-container #status-bar{border-style:var(--tw-border-style);background-color:var(--surface);border-width:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}

View File

@ -2,20 +2,28 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mwan3-ledhelper PKG_NAME:=mwan3-ledhelper
PKG_VERSION:=0.0.2 PKG_VERSION:=0.0.2
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru> PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
PKGARCH:=all define Package/$(PKG_NAME)/Default
SECTION:=net
CATEGORY:=Network
SUBMENU:=WAN
TITLE:=MWAN3 Led Helper TITLE:=MWAN3 Led Helper
PKGARCH:=all
endef
define Package/$(PKG_NAME)
$(call Package/$(PKG_NAME)/Default)
DEPENDS:=+mwan3 DEPENDS:=+mwan3
endef endef
define Package/$(PKG_NAME)-nft define Package/$(PKG_NAME)-nft
PKGARCH:=all $(call Package/$(PKG_NAME)/Default)
TITLE:=MWAN3 Led Helper (nft variant) TITLE+= (nft variant)
DEPENDS:=+mwan3-nft DEPENDS:=+mwan3-nft
endef endef
@ -23,6 +31,10 @@ define Package/$(PKG_NAME)/description
Hotplug script to indicate led for mwan3 link states Hotplug script to indicate led for mwan3 link states
endef endef
define Package/$(PKG_NAME)-nft/description
Hotplug script to indicate led for mwan3 link states (nftables/fw4 variant).
endef
define Build/Prepare define Build/Prepare
endef endef
@ -41,6 +53,10 @@ define Package/$(PKG_NAME)-nft/install
$(CP) ./root/* $(1)/ $(CP) ./root/* $(1)/
endef endef
$(eval $(call BuildPackage,$(PKG_NAME))) ifneq ($(BUILD_NFT_ONLY),1)
$(eval $(call BuildPackage,$(PKG_NAME)-nft)) $(eval $(call BuildPackage,$(PKG_NAME)))
endif
ifneq ($(BUILD_IPT_ONLY),1)
$(eval $(call BuildPackage,$(PKG_NAME)-nft))
endif

View File

@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow PKG_NAME:=natflow
PKG_VERSION:=20260531 PKG_VERSION:=20260531
PKG_RELEASE:=36 PKG_RELEASE:=37
PKG_SOURCE:=$(PKG_VERSION).tar.xz PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=756fea6d1e541e3d693dea4f82969585ad9262cc PKG_SOURCE_VERSION:=5430b3312cd783ba2179a24297979252c0463bb9
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com> PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0 PKG_LICENSE:=GPL-2.0

View File

@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=xmm-modem PKG_NAME:=xmm-modem
PKG_VERSION:=0.1.6 PKG_VERSION:=0.1.7
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru> PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk

View File

@ -1,16 +0,0 @@
opengt
set com 115200n81
set comecho off
set senddelay 0.02
waitquiet 0.2 0.2
send "AT+CGAUTH="
send $env("CID")
send ","
send $env("AUTH")
send ",\""
send $env("USER")
send "\",\""
send $env("PASS")
send "\"^m"

View File

@ -1,17 +0,0 @@
opengt
set com 115200n81
set comecho off
set senddelay 0.02
waitquiet 0.2 0.2
send "AT+CGPADDR="
send $env("CID")
send "^m"
get 1 "" $s
print $s
send "AT+GTDNS="
send $env("CID")
send "^m"
get 1 "" $s
print $s

View File

@ -1,28 +0,0 @@
opengt
set com 115200n81
set comecho off
set senddelay 0.02
waitquiet 0.2 0.2
send "AT+CMEE=2^m"
send "AT+COPS=0^m"
send "AT+CGPIAF=1,0,0,0^m"
send "AT+CGDCONT="
send $env("CID")
send "^m"
send "AT+CGDCONT="
send $env("CID")
send ",\""
send $env("PDP")
send "\",\""
send $env("APN")
send "\"^m"
send "AT+CGACT=1,"
send $env("CID")
send "^m"
send "AT+CGPADDR="
send $env("CID")
send "^m"
get 1 "" $s
print $s

View File

@ -4,7 +4,9 @@ opengt
set senddelay 0.02 set senddelay 0.02
waitquiet 0.2 0.2 waitquiet 0.2 0.2
send "AT+XGAUTH=" send "AT+"
send $env("AUTHCMD")
send "="
send $env("CID") send $env("CID")
send "," send ","
send $env("AUTH") send $env("AUTH")

View File

@ -10,6 +10,8 @@ opengt
get 1 "" $s get 1 "" $s
print $s print $s
send "AT+XDNS?^m" send "AT+"
send $env("DNSQUERY")
send "^m"
get 1 "" $s get 1 "" $s
print $s print $s

View File

@ -7,9 +7,16 @@ opengt
send "AT+CMEE=2^m" send "AT+CMEE=2^m"
send "AT+COPS=0^m" send "AT+COPS=0^m"
send "AT+CGPIAF=1,0,0,0^m" send "AT+CGPIAF=1,0,0,0^m"
if $env("PREFIX") = "xmm" goto sendxdns
goto skipxdns
:sendxdns
send "AT+XDNS=" send "AT+XDNS="
send $env("CID") send $env("CID")
send ",1^m" send ",1^m"
:skipxdns
send "AT+CGDCONT=" send "AT+CGDCONT="
send $env("CID") send $env("CID")
send "^m" send "^m"
@ -27,11 +34,17 @@ opengt
send $env("CID") send $env("CID")
send "^m" send "^m"
if $env("PREFIX") = "xmm" goto senddatachannel
goto skipdatachannel
:senddatachannel
send "AT+XDATACHANNEL=1,1,\"/USBCDC/0\",\"/USBHS/NCM/0\",2," send "AT+XDATACHANNEL=1,1,\"/USBCDC/0\",\"/USBHS/NCM/0\",2,"
send $env("CID") send $env("CID")
send "^m" send "^m"
send "AT+CGDATA=\"M-RAW_IP\"," send "AT+CGDATA=\"M-RAW_IP\","
send $env("CID") send $env("CID")
send "^m" send "^m"
:skipdatachannel
get 1 "" $s get 1 "" $s
print $s print $s

View File

@ -7,4 +7,5 @@ opengt
send "AT+CGACT=0," send "AT+CGACT=0,"
send $env("CID") send $env("CID")
send "^m" send "^m"
send "AT+XDATACHANNEL=0^m" send "AT+XDATACHANNEL=0^m"

View File

@ -61,11 +61,15 @@ proto_xmm_setup() {
PREFIX="xmm" PREFIX="xmm"
hwaddr="$(ls -1 $devpath/../*/net/*/*address*)" hwaddr="$(ls -1 $devpath/../*/net/*/*address*)"
XMMDNS="XDNS" XMMDNS="XDNS"
AUTHCMD="XGAUTH"
DNSQUERY="XDNS?"
;; ;;
0e8d7126|0e8d7127) 0e8d7126|0e8d7127)
PREFIX="fm350" PREFIX="fm350"
hwaddr="$(ls -1 $devpath/../../*/net/*/*address*)" hwaddr="$(ls -1 $devpath/../../*/net/*/*address*)"
XMMDNS="GTDNS" XMMDNS="GTDNS"
AUTHCMD="CGAUTH"
DNSQUERY="GTDNS=$profile"
;; ;;
*) *)
echo "Modem not supported!" echo "Modem not supported!"
@ -148,14 +152,14 @@ proto_xmm_setup() {
chap) AUTH=2 ;; chap) AUTH=2 ;;
*) AUTH=0 ;; *) AUTH=0 ;;
esac esac
CID=$profile AUTH=$AUTH USER="$username" PASS="$password" gcom -d "$device" -s /etc/gcom/${PREFIX}-auth.gcom >/dev/null 2>&1 CID=$profile AUTH=$AUTH AUTHCMD=$AUTHCMD USER="$username" PASS="$password" gcom -d "$device" -s /etc/gcom/xmm-auth.gcom >/dev/null 2>&1
} }
CID=$profile APN=$apn PDP=$pdp gcom -d $device -s /etc/gcom/${PREFIX}-connect.gcom >/dev/null 2>&1 CID=$profile APN=$apn PDP=$pdp PREFIX=$PREFIX gcom -d $device -s /etc/gcom/xmm-connect.gcom >/dev/null 2>&1
proto_init_update "$ifname" 1 proto_init_update "$ifname" 1
proto_add_data proto_add_data
proto_close_data proto_close_data
DATA=$(CID=$profile gcom -d $device -s /etc/gcom/${PREFIX}-config.gcom) DATA=$(CID=$profile DNSQUERY=$DNSQUERY gcom -d $device -s /etc/gcom/xmm-config.gcom)
ip4addr=$(echo "$DATA" | awk -F [,] '/^\+CGPADDR/{gsub("\r|\"", ""); print $2}') >/dev/null 2>&1 ip4addr=$(echo "$DATA" | awk -F [,] '/^\+CGPADDR/{gsub("\r|\"", ""); print $2}') >/dev/null 2>&1
lladdr=$(echo "$DATA" | awk -F [,] '/^\+CGPADDR/{gsub("\r|\"", ""); print $3}') >/dev/null 2>&1 lladdr=$(echo "$DATA" | awk -F [,] '/^\+CGPADDR/{gsub("\r|\"", ""); print $3}') >/dev/null 2>&1
ns=$(echo "$DATA" | awk -F [,] '/^\+'$XMMDNS': /{gsub("\r|\"",""); gsub("0.0.0.0",""); print $2" "$3}' | sed 's/^[[:space:]]//g' | uniq) ns=$(echo "$DATA" | awk -F [,] '/^\+'$XMMDNS': /{gsub("\r|\"",""); gsub("0.0.0.0",""); print $2" "$3}' | sed 's/^[[:space:]]//g' | uniq)