update 2026-10-25 16:53:21

This commit is contained in:
action committed 2026-10-25 16:53:21 +08:00
1 parent 91f217be10
commit 7fca8e9a3a
74 files changed
+1429 -918

No files matched your search

+4 -4
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=clashoo PKG_NAME:=clashoo
PKG_SHORT_SHA:=91e85ee PKG_SHORT_SHA:=6780eee
PKG_COMMIT_DATE:=2026.10.24 PKG_COMMIT_DATE:=2026.10.25
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA) PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
PKG_RELEASE:=1 PKG_RELEASE:=1
@@ -11,8 +11,8 @@ PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=91e85eebf0b654c73424f0a215b3c56f6b39614f PKG_SOURCE_VERSION:=6780eeeb27c7eda5e79a6310a4f7f91ba7cb725d
PKG_HASH:=efa0d9d66a589f06f402f50e0dea349ab9923d36d181303769f0575c0779e562 PKG_HASH:=affb9e916e750c6bd4385c6718660a02cc213936063141fd1c2422011e408b47
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA) PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
PKG_LICENSE:=GPL3.0+ PKG_LICENSE:=GPL3.0+
+25 -2
View File
@@ -1049,7 +1049,7 @@ _user_dns_fields() {
command -v yq >/dev/null 2>&1 || return 0 command -v yq >/dev/null 2>&1 || return 0
[ -s "$CONFIG_YAML" ] || return 0 [ -s "$CONFIG_YAML" ] || return 0
for field in nameserver proxy-server-nameserver fallback direct-nameserver \ for field in nameserver proxy-server-nameserver fallback direct-nameserver \
default-nameserver nameserver-policy respect-rules; do default-nameserver nameserver-policy proxy-server-nameserver-policy respect-rules; do
case ",$force," in *,"$field",*) continue ;; esac case ",$force," in *,"$field",*) continue ;; esac
[ "$(yq -M e "(.dns // {}) | has(\"${field}\")" "$CONFIG_YAML" 2>/dev/null)" = "true" ] && [ "$(yq -M e "(.dns // {}) | has(\"${field}\")" "$CONFIG_YAML" 2>/dev/null)" = "true" ] &&
out="${out}${out:+,}${field}" out="${out}${out:+,}${field}"
@@ -1091,7 +1091,7 @@ _clashoo_dns_force_fields() {
command -v yq >/dev/null 2>&1 || return 0 command -v yq >/dev/null 2>&1 || return 0
[ -s "$CONFIG_YAML" ] || return 0 [ -s "$CONFIG_YAML" ] || return 0
local managed legacy fields local managed legacy fields
fields="nameserver,proxy-server-nameserver,direct-nameserver,default-nameserver,nameserver-policy,respect-rules" fields="nameserver,proxy-server-nameserver,direct-nameserver,default-nameserver,nameserver-policy,proxy-server-nameserver-policy,respect-rules"
managed="$(yq -M e '(.dns."x-clashoo-managed-dns" // false) == true' "$CONFIG_YAML" 2>/dev/null)" managed="$(yq -M e '(.dns."x-clashoo-managed-dns" // false) == true' "$CONFIG_YAML" 2>/dev/null)"
if [ "$managed" = "true" ]; then if [ "$managed" = "true" ]; then
printf '%s' "$fields" printf '%s' "$fields"
@@ -2156,6 +2156,17 @@ _core_only_ui_args() {
printf ' -ext-ui %s' "$(shell_quote /etc/clashoo/dashboard)" printf ' -ext-ui %s' "$(shell_quote /etc/clashoo/dashboard)"
} }
_mihomo_proxy_dns_valid() {
local config="$1" proxy_count policy_count respect_rules
command -v yq >/dev/null 2>&1 || return 0
[ -s "$config" ] || return 0
proxy_count="$(yq -M e -r '(.dns."proxy-server-nameserver" // []) | length' "$config" 2>/dev/null)"
[ "$proxy_count" = "0" ] || return 0
policy_count="$(yq -M e -r '(.dns."proxy-server-nameserver-policy" // {}) | length' "$config" 2>/dev/null)"
respect_rules="$(yq -M e -r '.dns."respect-rules" // false' "$config" 2>/dev/null)"
[ "$policy_count" = "0" ] && [ "$respect_rules" != "true" ]
}
_start_launch_core() { _start_launch_core() {
local _cmd _core_only local _cmd _core_only
disable_quic_gso=$(uci get clashoo.config.disable_quic_gso 2>/dev/null) disable_quic_gso=$(uci get clashoo.config.disable_quic_gso 2>/dev/null)
@@ -2169,6 +2180,13 @@ _start_launch_core() {
# in core-only the user is explicitly trusting their own config. # in core-only the user is explicitly trusting their own config.
[ "$_core_only" = "1" ] && export SAFE_PATHS="/" [ "$_core_only" = "1" ] && export SAFE_PATHS="/"
if ! _mihomo_proxy_dns_valid "${CLASH_CONFIG}/config.yaml"; then
log_msg "Proxy node DNS is required by node DNS policy or respect-rules; set a Proxy Node Domain Resolver in LuCI" "节点域名 DNS 策略或 DNS 遵循规则需要代理节点域名解析,请在 LuCI 中配置"
runtime_state_record_health "fail" "preflight:proxy_dns_missing"
echo "Clashoo" > "$REAL_LOG"
return 1
fi
# preflight dry-run with watchdog: bail on bad config without touching # preflight dry-run with watchdog: bail on bad config without touching
# firewall/dnsmasq, and never let a geo auto-download (mihomo blocks ~90s on # firewall/dnsmasq, and never let a geo auto-download (mihomo blocks ~90s on
# a missing GeoIP/GeoSite when GitHub is unreachable) freeze startup/UI. # a missing GeoIP/GeoSite when GitHub is unreachable) freeze startup/UI.
@@ -2557,6 +2575,11 @@ reload_service()
rm -f "$_candidate" "$_previous" "$_response" "$_dns_backup" rm -f "$_candidate" "$_previous" "$_response" "$_dns_backup"
return 1 return 1
fi fi
if ! _mihomo_proxy_dns_valid "$_candidate"; then
_log_runtime_error "Mihomo reload failed: node DNS policy or respect-rules requires a Proxy Node Domain Resolver; current config preserved"
rm -f "$_candidate" "$_previous" "$_response" "$_dns_backup"
return 1
fi
if [ "$(uci -q get clashoo.config.core_only 2>/dev/null)" = "1" ]; then if [ "$(uci -q get clashoo.config.core_only 2>/dev/null)" = "1" ]; then
SAFE_PATHS=/ _run_with_timeout 15 "$CLASH" -d "$CLASH_CONFIG" -f "$_candidate" -t >> "$CORE_LOG_PATH" 2>&1 SAFE_PATHS=/ _run_with_timeout 15 "$CLASH" -d "$CLASH_CONFIG" -f "$_candidate" -t >> "$CORE_LOG_PATH" 2>&1
else else
@@ -145,7 +145,7 @@ if (type(inherited_nameserver) != 'array') {
inherited_nameserver = []; inherited_nameserver = [];
} }
let dns_role_fields = ['nameserver', 'proxy-server-nameserver', 'direct-nameserver', let dns_role_fields = ['nameserver', 'proxy-server-nameserver', 'direct-nameserver',
'default-nameserver', 'nameserver-policy', 'respect-rules']; 'default-nameserver', 'nameserver-policy', 'proxy-server-nameserver-policy', 'respect-rules'];
let had_user_dns_roles = false; let had_user_dns_roles = false;
for (let f in dns_role_fields) for (let f in dns_role_fields)
if (dns_present[f]) had_user_dns_roles = true; if (dns_present[f]) had_user_dns_roles = true;
@@ -200,18 +200,23 @@ if (length(bootstrap) && (!dns_present['default-nameserver'] || dns_force['defau
cfg['dns']['default-nameserver'] = bootstrap; cfg['dns']['default-nameserver'] = bootstrap;
let policies = {}; let policies = {};
let proxy_policies = {};
uci.foreach('clashoo', 'dns_policy', function(sec) { uci.foreach('clashoo', 'dns_policy', function(sec) {
if (b(sec.enabled) == false) return; if (b(sec.enabled) == false) return;
if (s(sec.policy_type, 'nameserver-policy') != 'nameserver-policy') return; let role = s(sec.policy_type, 'nameserver-policy');
if (role != 'nameserver-policy' && role != 'proxy-server-nameserver-policy') return;
let matcher = trim(sec.matcher || ''); let matcher = trim(sec.matcher || '');
let servers = sec.nameserver; let servers = sec.nameserver;
if (type(servers) != 'array') servers = servers != null ? [servers] : []; if (type(servers) != 'array') servers = servers != null ? [servers] : [];
if (!length(matcher) || !length(servers)) return; if (!length(matcher) || !length(servers)) return;
if (matcher == 'geosite:cn') { matcher = 'rule-set:cn_domain'; need_cn_rs = true; } if (matcher == 'geosite:cn') { matcher = 'rule-set:cn_domain'; need_cn_rs = true; }
policies[matcher] = servers; if (role == 'nameserver-policy') policies[matcher] = servers;
else proxy_policies[matcher] = servers;
}); });
if (length(keys(policies)) && (!dns_present['nameserver-policy'] || dns_force['nameserver-policy'])) if (length(keys(policies)) && (!dns_present['nameserver-policy'] || dns_force['nameserver-policy']))
cfg['dns']['nameserver-policy'] = policies; cfg['dns']['nameserver-policy'] = policies;
if (length(keys(proxy_policies)) && (!dns_present['proxy-server-nameserver-policy'] || dns_force['proxy-server-nameserver-policy']))
cfg['dns']['proxy-server-nameserver-policy'] = proxy_policies;
let respect_rules = a('dns_respect_rules') == null ? true : ab('dns_respect_rules'); let respect_rules = a('dns_respect_rules') == null ? true : ab('dns_respect_rules');
if (dns_force['respect-rules']) if (dns_force['respect-rules'])
+2 -2
View File
@@ -35,8 +35,8 @@ valid_clash_api() {
case "$clash_api_port" in ''|*[!0-9]*) return 1 ;; esac case "$clash_api_port" in ''|*[!0-9]*) return 1 ;; esac
[ "$clash_api_port" -ge 1024 ] && [ "$clash_api_port" -le 65535 ] || return 1 [ "$clash_api_port" -ge 1024 ] && [ "$clash_api_port" -le 65535 ] || return 1
[ "$clash_api_port" != "$listen_port" ] || return 1 [ "$clash_api_port" != "$listen_port" ] || return 1
case "$clash_api_secret" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac case "$clash_api_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#clash_api_secret}" -ge 16 ] && [ "${#clash_api_secret}" -le 128 ] [ "${#clash_api_secret}" -ge 6 ] && [ "${#clash_api_secret}" -le 128 ]
} }
valid_network_name() { valid_network_name() {
+2 -2
View File
@@ -452,8 +452,8 @@ load_uci_values() {
[ "$u_clash_port" -ge 1024 ] && [ "$u_clash_port" -le 65535 ] || return 1 [ "$u_clash_port" -ge 1024 ] && [ "$u_clash_port" -le 65535 ] || return 1
if [ "$u_clash_enabled" = 1 ]; then if [ "$u_clash_enabled" = 1 ]; then
[ "$u_initialized" = 1 ] && [ "$u_clash_port" != "$u_port" ] || return 1 [ "$u_initialized" = 1 ] && [ "$u_clash_port" != "$u_port" ] || return 1
case "$u_clash_secret" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac case "$u_clash_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac
[ "${#u_clash_secret}" -ge 16 ] && [ "${#u_clash_secret}" -le 128 ] || return 1 [ "${#u_clash_secret}" -ge 6 ] && [ "${#u_clash_secret}" -le 128 ] || return 1
fi fi
} }
+83 -33
View File
@@ -5,18 +5,23 @@ on:
tags: tags:
- 'v*' - 'v*'
- 'dev-*' - 'dev-*'
workflow_dispatch:
permissions: permissions:
contents: write contents: write
env: env:
TARGET: x86 TARGET: x86
SUBTARGET: 64 SUBTARGET: '64'
PKG_DIR_NAME: luci-app-adguardhome PKG_DIR_NAME: luci-app-adguardhome
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90
defaults:
run:
shell: bash
steps: steps:
- name: 检出代码 - name: 检出代码
@@ -28,47 +33,79 @@ jobs:
run: | run: |
sudo apt update sudo apt update
sudo apt install -y build-essential clang flex bison g++ gawk \ sudo apt install -y build-essential clang flex bison g++ gawk \
gcc-multilib g++-multilib gettext git libncurses5-dev libssl-dev \ gcc-multilib g++-multilib gettext git libncurses-dev libssl-dev \
python3-setuptools rsync swig unzip zlib1g-dev file wget zstd jq python3-setuptools rsync swig unzip zlib1g-dev file curl zstd jq
- name: 下载 SDK - name: 确定 SDK 版本
id: sdk
env:
GH_TOKEN: ${{ github.token }}
run: | run: |
set -euo pipefail set -euo pipefail
RELEASE=$(
echo "🔍 获取 OpenWrt 25.12 最新版本..." curl --fail --silent --show-error --location --retry 3 \
LATEST_TAG=$( -H "Authorization: Bearer ${GH_TOKEN}" \
curl -fsSL https://api.github.com/repos/openwrt/openwrt/releases?per_page=50 \ 'https://api.github.com/repos/openwrt/openwrt/releases?per_page=100' \
| jq -r '.[].tag_name' \ | jq -r '.[] | select(.draft == false and .prerelease == false) | .tag_name
| grep -E '^v25\.12\.[0-9]+(-rc[0-9]+)?$' \ | select(test("^v25\\.12\\.[0-9]+$")) | ltrimstr("v")' \
| sort -V \ | sort -V | tail -n 1
| tail -n 1
) )
[[ -n "$RELEASE" ]] || { echo '::error::未找到 OpenWrt 25.12 正式版'; exit 1; }
BASE="https://downloads.openwrt.org/releases/${RELEASE}/targets/${TARGET}/${SUBTARGET}"
SDK_CACHE="${RUNNER_TEMP}/adguardhome-sdk-download"
mkdir -p "$SDK_CACHE"
curl -fSL --retry 3 "$BASE/sha256sums" -o "$RUNNER_TEMP/adguardhome-sdk-sha256sums"
SDK_TARBALL=$(awk '{ name=$2; sub(/^\*/, "", name); if (name ~ /^openwrt-sdk-.*\.tar\.zst$/) print name }' "$RUNNER_TEMP/adguardhome-sdk-sha256sums")
[[ -n "$SDK_TARBALL" && "$SDK_TARBALL" != *$'\n'* && "$SDK_TARBALL" != */* ]] || {
echo '::error::无法唯一确定 SDK 文件'; exit 1;
}
SDK_SHA=$(awk -v name="$SDK_TARBALL" '{ file=$2; sub(/^\*/, "", file); if (file == name) print $1 }' "$RUNNER_TEMP/adguardhome-sdk-sha256sums")
[[ "$SDK_SHA" =~ ^[0-9a-f]{64}$ ]]
echo "sha256=$SDK_SHA" >> "$GITHUB_OUTPUT"
echo "SDK_URL=$BASE/$SDK_TARBALL" >> "$GITHUB_ENV"
echo "SDK_TARBALL=$SDK_TARBALL" >> "$GITHUB_ENV"
echo "SDK_SHA=$SDK_SHA" >> "$GITHUB_ENV"
echo "OpenWrt SDK: ${RELEASE}" >> "$GITHUB_STEP_SUMMARY"
if [[ -z "${LATEST_TAG}" || "${LATEST_TAG}" == "null" ]]; then - name: 缓存 SDK 压缩包
echo "❌ 获取 OpenWrt 25.12.x tag 失败" uses: actions/cache@v5
exit 1 with:
path: ${{ runner.temp }}/adguardhome-sdk-download
key: adguardhome-sdk-v1-${{ runner.os }}-${{ env.TARGET }}-${{ env.SUBTARGET }}-${{ steps.sdk.outputs.sha256 }}
- name: 校验并解压 SDK
run: |
set -euo pipefail
SDK_CACHE="${RUNNER_TEMP}/adguardhome-sdk-download"
SDK_ROOT="${RUNNER_TEMP}/adguardhome-sdk"
mkdir -p "$SDK_CACHE" "$SDK_ROOT"
cd "$SDK_CACHE"
# 缓存命中也校验官方摘要;缺失或损坏时重新下载。
if ! printf '%s %s\n' "$SDK_SHA" "$SDK_TARBALL" | sha256sum -c -; then
curl -fSL --retry 3 "$SDK_URL" -o "$SDK_TARBALL"
fi fi
printf '%s %s\n' "$SDK_SHA" "$SDK_TARBALL" | sha256sum -c -
tar --zstd -xf "$SDK_TARBALL" -C "$SDK_ROOT"
SDK_DIR=$(find "$SDK_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'openwrt-sdk-*' -print)
[[ -n "$SDK_DIR" && "$SDK_DIR" != *$'\n'* ]]
echo "SDK_DIR=$SDK_DIR" >> "$GITHUB_ENV"
RELEASE="${LATEST_TAG#v}" - name: 缓存依赖源码下载
echo "检测到 25.12 系列最新版本: ${RELEASE}" uses: actions/cache@v5
with:
echo "📥 下载 SDK..." path: ${{ env.SDK_DIR }}/dl
BASE="https://downloads.openwrt.org/releases/${RELEASE}/targets/${TARGET}/${SUBTARGET}/" key: adguardhome-dl-v1-${{ runner.os }}-${{ steps.sdk.outputs.sha256 }}-${{ hashFiles('Makefile', '.github/workflows/build-apk.yaml') }}
SDK_TARBALL=$(curl -fsSL "$BASE" | grep -o 'openwrt-sdk-[^"]*\.tar\.zst' | head -n1) restore-keys: |
mkdir -p _sdk adguardhome-dl-v1-${{ runner.os }}-${{ steps.sdk.outputs.sha256 }}-
wget -q "${BASE}${SDK_TARBALL}" -O _sdk/sdk.tar.zst
tar --zstd -xf _sdk/sdk.tar.zst -C _sdk
SDK_DIR="$(find "$PWD/_sdk" -maxdepth 1 -type d -name 'openwrt-sdk-*' -print -quit)"
echo "SDK_DIR=${SDK_DIR}" >> "$GITHUB_ENV"
echo "✅ SDK 已解压到: ${SDK_DIR}"
- name: 构建包 - name: 构建包
run: | run: |
set -euo pipefail
cd "$SDK_DIR" cd "$SDK_DIR"
mkdir -p package/${PKG_DIR_NAME} mkdir -p package/${PKG_DIR_NAME}
rsync -a --delete "$GITHUB_WORKSPACE/" "package/${PKG_DIR_NAME}/" \ rsync -a --delete "$GITHUB_WORKSPACE/" "package/${PKG_DIR_NAME}/" \
--exclude '.git*' --exclude '_sdk' --exclude '.git*' --exclude '.local' --exclude '_sdk' --exclude 'dist'
./scripts/feeds update -a ./scripts/feeds update -a
./scripts/feeds install luci-base tar ./scripts/feeds install luci-base tar
@@ -91,17 +128,30 @@ jobs:
make package/compile -j"$(nproc)" V=sc make package/compile -j"$(nproc)" V=sc
- name: 上传产物 - name: 整理 APK 产物
uses: actions/upload-artifact@v4 run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/dist"
mapfile -t packages < <(find "$SDK_DIR/bin/packages" -type f -name "${PKG_DIR_NAME}-*.apk")
if (( ${#packages[@]} == 0 )); then
echo '::error::未找到 AdGuardHome APK'
exit 1
fi
cp "${packages[@]}" "$GITHUB_WORKSPACE/dist/"
- name: 上传独立 APK
uses: actions/upload-artifact@v7
with: with:
name: pkg-${{ github.ref_name }} path: dist/*.apk
path: _sdk/**/bin/packages/**/${{ env.PKG_DIR_NAME }}* archive: false
if-no-files-found: error if-no-files-found: error
- name: 发布到 Releases - name: 发布到 Releases
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
prerelease: ${{ startsWith(github.ref_name, 'dev-') }} prerelease: ${{ startsWith(github.ref_name, 'dev-') }}
make_latest: ${{ startsWith(github.ref_name, 'dev-') && 'false' || 'true' }} make_latest: ${{ startsWith(github.ref_name, 'dev-') && 'false' || 'true' }}
files: _sdk/**/bin/packages/**/${{ env.PKG_DIR_NAME }}* fail_on_unmatched_files: true
files: dist/*.apk
+2 -1
View File
@@ -1 +1,2 @@
.DS_Store .DS_Store
.local
+6 -4
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-adguardhome PKG_NAME:=luci-app-adguardhome
PKG_VERSION:=2.4.1 PKG_VERSION:=2.5.0
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_LICENSE:=MIT PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE PKG_LICENSE_FILES:=LICENSE
@@ -24,7 +24,7 @@ define Package/luci-app-adguardhome
TITLE:=LuCI app for AdGuardHome TITLE:=LuCI app for AdGuardHome
PKG_MAINTAINER:=https://github.com/w9315273/luci-app-adguardhome PKG_MAINTAINER:=https://github.com/w9315273/luci-app-adguardhome
PKGARCH:=all PKGARCH:=all
DEPENDS:=+luci-base +rpcd-mod-ucode +uclient-fetch +tar DEPENDS:=+luci-base +rpcd-mod-ucode +uclient-fetch +jsonfilter +flock +ca-bundle +!(PACKAGE_libustream-openssl||PACKAGE_libustream-wolfssl):libustream-mbedtls +tar
endef endef
define Package/luci-app-adguardhome/description define Package/luci-app-adguardhome/description
@@ -61,7 +61,9 @@ if [ "$$enable" = "1" ]; then
/etc/init.d/AdGuardHome reload /etc/init.d/AdGuardHome reload
fi fi
rm -f /tmp/luci-indexcache # Remove the former menu definition when upgrading.
rm -f /usr/share/luci/menu.d/luci-app-adguardhome-js.json
rm -f /tmp/luci-indexcache /tmp/luci-indexcache.*
rm -f /tmp/luci-modulecache/* rm -f /tmp/luci-modulecache/*
/etc/init.d/rpcd reload /etc/init.d/rpcd reload
-39
View File
@@ -1,39 +0,0 @@
# luci-app-adguardhome
基于原版 `luci-app-adguardhome` 修改,主要针对现代 OpenWrt 环境进行了 `nftables` 适配,并从 **v2.4.0** 开始完成 LuCI 前端 JavaScript 化。
当前 `main` 分支为 JavaScript 版本,旧版 Lua 实现保留在 [`lua`](https://github.com/w9315273/luci-app-adguardhome/tree/lua) 分支。
## 主要变化
### v2.4.0 — LuCI JavaScript 迁移
从 **v2.4.0** 开始,LuCI 页面由传统 Lua CBI / HTM 架构迁移至 JavaScript。
- LuCI 页面由 Lua CBI / HTM 迁移到 JavaScript
- 后端接口迁移到 `rpcd + ucode`
- 使用 LuCI RPC ACL 控制前后端权限
- 删除旧的 `luasrc/controller`、`luasrc/model/cbi` 和 HTM 页面
- 保留原有 AdGuardHome 配置方式与主要功能
- 优化 YAML 配置编辑与配置检查
### nftables DNS 重定向
将原版的 DNS 重定向实现从 `iptables` 迁移到 `nftables`,核心使用方式保持不变。
### WAN 设备排除
从 **v2.2.0** 开始,可直接在 LuCI 界面的 **WAN 接口** 选项中选择需要排除的网卡,无需手动编辑 `adguardhome.nft.tpl`。
该选项仅在 **「重定向」模式** 下生效,用于避免将来自公网接口的 DNS 请求重定向到本机,从而防止 AdGuardHome 被暴露为公共 DNS 解析器。
请仅选择直接面向公网的接口,例如:
- `eth0`
- `pppoe-wan`
## 界面
<img width="677" height="695" src="https://github.com/user-attachments/assets/4b436eec-8e05-4bf6-82d9-a972164e9659" />
<img width="536" height="714" src="https://github.com/user-attachments/assets/575c2d6c-e289-425d-91b9-faf01b5a7262" />
+35
View File
@@ -0,0 +1,35 @@
# AdGuard Home for LuCI
Manage AdGuard Home in OpenWrt's LuCI interface. LuCI pages are rewritten in JavaScript, with an rpcd + ucode backend and nftables-based DNS forwarding.
[简体中文](README.md) · [English](README.en.md)
## Features
- Service status and core updates
- DNS handling through firewall forwarding, dnsmasq upstream, or port 53 replacement
- YAML configuration editing, validation, and web login password changes
- Runtime logs with reverse order, local timestamps, download, and a verbose logging toggle
> [!TIP]
> **Quick installation**
>
> Run in the terminal of an OpenWrt router using the APK package manager:
>
> ```sh
> uclient-fetch -O /tmp/install-adguardhome.sh https://raw.githubusercontent.com/w9315273/luci-app-adguardhome/main/install.sh && sh /tmp/install-adguardhome.sh
> ```
## Screenshots
### Basic settings
![Basic settings](screenshots/base-en.png)
### Manual configuration
![Manual configuration](screenshots/manual-en.png)
### Logs
![Runtime logs](screenshots/log-en.png)
+35
View File
@@ -0,0 +1,35 @@
# AdGuard Home for LuCI
在 OpenWrt 的 LuCI 界面中管理 AdGuard Home。使用 JavaScript 重写 LuCI 页面,后端采用 rpcd + ucode,并适配 nftables DNS 转发。
[简体中文](README.md) · [English](README.en.md)
## 功能
- 服务状态查看与核心更新
- 防火墙转发、dnsmasq 上游、替代 dnsmasq 使用 53 端口等 DNS 接入方式
- YAML 配置编辑与校验、网页登录密码修改
- 运行日志查看,支持逆序、本地时间、下载和详细日志开关
> [!TIP]
> **一键安装**
>
> 在使用 APK 包管理器的 OpenWrt 路由器终端执行:
>
> ```sh
> uclient-fetch -O /tmp/install-adguardhome.sh https://raw.githubusercontent.com/w9315273/luci-app-adguardhome/main/install.sh && sh /tmp/install-adguardhome.sh
> ```
## 截图
### 基础设置
![基础设置](screenshots/base-zh-cn.png)
### 手动设置
![手动设置](screenshots/manual-zh-cn.png)
### 日志
![运行日志](screenshots/log-zh-cn.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+43 -22
View File
@@ -17,48 +17,36 @@ msgstr "启用"
msgid "Browser management port" msgid "Browser management port"
msgstr "网页管理端口" msgstr "网页管理端口"
msgid "Redirect mode" msgid "DNS handling mode"
msgstr "重定向模式" msgstr "DNS 接入方式"
msgid "AdGuardHome redirect mode" msgid "Do not handle DNS"
msgstr "AdGuardHome 重定向模式" msgstr "不接管 DNS"
msgid "None"
msgstr "无"
msgid "Run as dnsmasq upstream server" msgid "Run as dnsmasq upstream server"
msgstr "作为 dnsmasq 的上游服务器" msgstr "作为 dnsmasq 的上游服务器"
msgid "Redirect port 53 to AdGuardHome" msgid "Forward port 53 requests through the firewall"
msgstr "将 53 端口重定向到 AdGuardHome" msgstr "通过防火墙转发 53 端口请求"
msgid "Use port 53 instead of dnsmasq" msgid "Use port 53 instead of dnsmasq"
msgstr "使用 53 端口替代 dnsmasq" msgstr "使用 53 端口替代 dnsmasq"
msgid "WAN Interface" msgid "Interfaces excluded from forwarding"
msgstr "WAN 接口" msgstr "不参与转发的接口"
msgid "Only effective in redirect mode, bypass specified interfaces to avoid becoming a public DNS resolver" msgid "DNS requests arriving on these interfaces are not forwarded to AdGuardHome"
msgstr "仅在重定向模式下生效,绕过指定接口以避免成为公共 DNS 解析器" msgstr "从这些接口进入的 DNS 请求不转发到 AdGuardHome"
msgid "Bin Path" msgid "Bin Path"
msgstr "执行文件路径" msgstr "执行文件路径"
msgid "AdGuardHome binary path"
msgstr "AdGuardHome 执行文件路径"
msgid "Config Path" msgid "Config Path"
msgstr "配置文件路径" msgstr "配置文件路径"
msgid "AdGuardHome config path"
msgstr "AdGuardHome 配置文件路径"
msgid "Work dir" msgid "Work dir"
msgstr "工作目录" msgstr "工作目录"
msgid "AdGuardHome work dir including rules, audit log and database"
msgstr "AdGuardHome 工作目录,包含规则、审计日志和数据库"
msgid "Runtime log file" msgid "Runtime log file"
msgstr "运行日志文件" msgstr "运行日志文件"
@@ -208,3 +196,36 @@ msgstr "恢复失败,备份文件位于"
msgid "Config changed on disk; reload before saving." msgid "Config changed on disk; reload before saving."
msgstr "磁盘上的配置已变化,请重新读取后再保存。" msgstr "磁盘上的配置已变化,请重新读取后再保存。"
msgid "Unable to read runtime log"
msgstr "无法读取运行日志"
msgid "Switching"
msgstr "正在切换"
msgid "Checking service…"
msgstr "正在确认服务状态…"
msgid "Switch not completed; check the log"
msgstr "切换尚未完成,请检查日志"
msgid "Disabled"
msgstr "已停用"
msgid "Unable to read switch status"
msgstr "无法读取切换状态"
msgid "Forward port 53 requests through the firewall (recommended)"
msgstr "通过防火墙转发 53 端口请求(推荐)"
msgid "Log settings"
msgstr "日志设置"
msgid "Applying verbose logging…"
msgstr "正在应用详细日志设置…"
msgid "Saved, but service reload failed; check the log"
msgstr "已保存,但服务重载失败,请检查日志"
msgid "Unable to apply verbose logging"
msgstr "无法应用详细日志设置"
+270 -57
View File
@@ -56,43 +56,195 @@ clear_nft_redirect() {
nft delete table inet "$NFT_TABLE" 2>/dev/null nft delete table inet "$NFT_TABLE" 2>/dev/null
} }
set_forward_dnsmasq() { wait_for_pid_exit() {
local PORT="$1" local pid="$1" attempts=0
addr="127.0.0.1#$PORT" case "$pid" in ''|*[!0-9]*) return 0 ;; esac
OLD_SERVER="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)" while kill -0 "$pid" 2>/dev/null; do
echo "$OLD_SERVER" | grep -q "^$addr" && return 0 [ "$attempts" -lt 15 ] || return 1
sleep 1
uci delete dhcp.@dnsmasq[0].server 2>/dev/null attempts=$((attempts + 1))
uci add_list dhcp.@dnsmasq[0].server="$addr"
for server in $OLD_SERVER; do
[ "$server" = "$addr" ] && continue
uci add_list dhcp.@dnsmasq[0].server="$server"
done done
uci delete dhcp.@dnsmasq[0].resolvfile 2>/dev/null
uci set dhcp.@dnsmasq[0].noresolv=1
uci commit dhcp
/etc/init.d/dnsmasq restart >/dev/null 2>&1
} }
stop_forward_dnsmasq() { wait_for_dns_port_release() {
local OLD_PORT="$1" local port="$1" attempts=0 tables="" table busy
addr="127.0.0.1#$OLD_PORT" for table in /proc/net/tcp /proc/net/tcp6 /proc/net/udp /proc/net/udp6; do
OLD_SERVER="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)" [ -r "$table" ] && tables="$tables $table"
echo "$OLD_SERVER" | grep -q "^$addr" || return 0 done
[ -n "$tables" ] || return 1
port="$(printf '%04X' "$port")" || return 1
while :; do
busy="$(awk -v port="$port" '
FNR > 1 {
split($2, address, ":")
if (toupper(address[2]) == port &&
((FILENAME ~ /tcp/ && $4 == "0A") ||
(FILENAME ~ /udp/ && $4 == "07"))) found = 1
}
END { print found ? 1 : 0 }
' $tables)" || return 1
[ "$busy" = "0" ] && return 0
[ "$attempts" -lt 15 ] || return 1
sleep 1
attempts=$((attempts + 1))
done
}
uci del_list dhcp.@dnsmasq[0].server="$addr" 2>/dev/null restart_dnsmasq() {
addrlist="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)" local released_port="$1" service pids pid
if [ -z "$addrlist" ]; then service="$(ubus call service list '{"name":"dnsmasq"}' 2>/dev/null)" || return 1
resolvfile="/tmp/resolv.conf.d/resolv.conf.auto" pids="$(printf '%s' "$service" | jsonfilter -e '@.dnsmasq.instances.*.pid')"
[ ! -f "$resolvfile" ] && resolvfile="/tmp/resolv.conf.auto" /etc/init.d/dnsmasq stop 8>&- 1000>&- || return 1
uci set dhcp.@dnsmasq[0].resolvfile="$resolvfile" 2>/dev/null for pid in $pids; do
uci delete dhcp.@dnsmasq[0].noresolv 2>/dev/null if ! wait_for_pid_exit "$pid"; then
logger -t AdGuardHome 'dnsmasq did not stop; refusing to reuse its old jail.'
return 1
fi
done
if [ -n "$released_port" ] && ! wait_for_dns_port_release "$released_port"; then
logger -t AdGuardHome "DNS port $released_port is still occupied after stopping dnsmasq."
/etc/init.d/dnsmasq start 8>&- 1000>&-
return 1
fi
/etc/init.d/dnsmasq start 8>&- 1000>&- || return 1
if [ -n "$released_port" ] && ! wait_for_dns_port_release "$released_port"; then
logger -t AdGuardHome "DNS port $released_port was occupied again after starting dnsmasq."
return 1
fi
return 0
}
save_dnsmasq_upstream_config() {
local backup="AdGuardHome.dnsmasq_backup" option value server
[ "$(uci -q get "$backup.ready")" = "1" ] && return 0
uci -q delete "$backup"
uci set "$backup=dnsmasq_backup" || return 1
value="$(uci -q get dhcp.@dnsmasq[0].server)"
for server in $value; do
uci add_list "$backup.server=$server" || return 1
done
for option in resolvfile noresolv; do
if value="$(uci -q get "dhcp.@dnsmasq[0].$option")"; then
uci set "$backup.$option=$value" || return 1
fi
done
uci set "$backup.ready=1" || return 1
if ! uci commit AdGuardHome; then
uci -q revert "$backup"
return 1
fi
}
apply_dnsmasq_config() {
[ "${AGH_DEFER_DNSMASQ_RESTART:-0}" = "1" ] && return 0
restart_dnsmasq "$@"
}
is_scoped_dnsmasq_server() {
local domains="${1%/*}"
case "$1" in
/*) case "$domains/" in */\#/*) return 1 ;; esac; return 0 ;;
esac
return 1
}
set_forward_dnsmasq() (
set -f
local addr="127.0.0.1#$1" servers desired server
if [ "$(uci -q get AdGuardHome.dnsmasq_backup.ready)" != "1" ] &&
[ "$2" = "dnsmasq-upstream" ] && [ "$3" = "1" ]; then
stop_forward_dnsmasq "$4" || return 1
fi
save_dnsmasq_upstream_config || return 1
servers="$(uci -q get dhcp.@dnsmasq[0].server)"
desired="$addr"
for server in $servers; do
is_scoped_dnsmasq_server "$server" && desired="$desired $server"
done
if [ "$servers" = "$desired" ] && [ "$(uci -q get dhcp.@dnsmasq[0].noresolv)" = "1" ]; then
return 0
fi
uci -q delete dhcp.@dnsmasq[0].server
for server in $desired; do
uci add_list "dhcp.@dnsmasq[0].server=$server" || return 1
done
uci set dhcp.@dnsmasq[0].noresolv=1 || return 1
uci commit dhcp || return 1
apply_dnsmasq_config >/dev/null 2>&1
)
stop_forward_dnsmasq() (
set -f
local backup="AdGuardHome.dnsmasq_backup" option value server
local addr="127.0.0.1#$1" found=0 has_general=0
if [ "$(uci -q get "$backup.ready")" = "1" ]; then
uci -q delete dhcp.@dnsmasq[0].server
value="$(uci -q get "$backup.server")"
for server in $value; do
uci add_list "dhcp.@dnsmasq[0].server=$server" || return 1
done
for option in resolvfile noresolv; do
if value="$(uci -q get "$backup.$option")"; then
uci set "dhcp.@dnsmasq[0].$option=$value" || return 1
else
uci -q delete "dhcp.@dnsmasq[0].$option"
fi
done
uci commit dhcp || return 1
apply_dnsmasq_config >/dev/null 2>&1 || return 1
[ "${AGH_DEFER_DNSMASQ_RESTART:-0}" = "1" ] && return 0
uci -q delete "$backup"
uci commit AdGuardHome
return $?
fi
value="$(uci -q get dhcp.@dnsmasq[0].server)"
for server in $value; do
if [ "$server" = "$addr" ]; then
found=1
elif ! is_scoped_dnsmasq_server "$server"; then
has_general=1
fi
done
[ "$found" = "1" ] || return 0
uci -q del_list "dhcp.@dnsmasq[0].server=$addr" || return 1
if [ "$has_general" = "0" ]; then
if ! uci -q get dhcp.@dnsmasq[0].resolvfile >/dev/null; then
value="/tmp/resolv.conf.d/resolv.conf.auto"
[ -f "$value" ] || value="/tmp/resolv.conf.auto"
uci set "dhcp.@dnsmasq[0].resolvfile=$value" || return 1
fi
uci -q delete dhcp.@dnsmasq[0].noresolv
fi
logger -t AdGuardHome 'No DNS snapshot from previous version; removed old upstream, original resolver settings are unavailable.'
uci commit dhcp || return 1
apply_dnsmasq_config >/dev/null 2>&1
)
stop_core() {
local service instance pid
service="$(ubus call service list '{"name":"AdGuardHome"}' 2>/dev/null)" || return 1
instance="$(printf '%s' "$service" | jsonfilter -e '@.AdGuardHome.instances.instance1')"
[ -n "$instance" ] || return 0
pid="$(printf '%s' "$instance" | jsonfilter -e '@.pid')"
ubus call service delete '{"name":"AdGuardHome","instance":"instance1"}' >/dev/null || return 1
if ! wait_for_pid_exit "$pid"; then
logger -t AdGuardHome 'Core did not stop; DNS port settings were not changed.'
return 1
fi fi
uci commit dhcp
/etc/init.d/dnsmasq restart >/dev/null 2>&1
} }
agh_reload() { agh_reload() {
[ "${AGH_REDIRECT_RELOAD:-1}" = "1" ] || return 0
if [ "${AGH_DEFER_DNSMASQ_RESTART:-0}" = "1" ]; then
AGH_RELOAD_PENDING=1
return 0
fi
ubus call service event '{"type":"config.change","data":{"package":"AdGuardHome"}}' >/dev/null 2>&1 ubus call service event '{"type":"config.change","data":{"package":"AdGuardHome"}}' >/dev/null 2>&1
} }
@@ -100,7 +252,7 @@ rm_port53() {
local configpath local configpath
config_get configpath $CONFIGURATION configpath "/etc/AdGuardHome.yaml" config_get configpath $CONFIGURATION configpath "/etc/AdGuardHome.yaml"
local AdGuardHome_PORT dnsmasq_port binpath local AdGuardHome_PORT dnsmasq_port
AdGuardHome_PORT=$(config_editor "dns.port" "" "$configpath" "1") AdGuardHome_PORT=$(config_editor "dns.port" "" "$configpath" "1")
dnsmasq_port=$(uci get dhcp.@dnsmasq[0].port 2>/dev/null) dnsmasq_port=$(uci get dhcp.@dnsmasq[0].port 2>/dev/null)
[ -z "$dnsmasq_port" ] && dnsmasq_port="53" [ -z "$dnsmasq_port" ] && dnsmasq_port="53"
@@ -111,13 +263,12 @@ rm_port53() {
return return
fi fi
stop_core || return 1
config_editor "dns.port" "$dnsmasq_port" "$configpath" config_editor "dns.port" "$dnsmasq_port" "$configpath"
uci set dhcp.@dnsmasq[0].port="53" [ "$(config_editor "dns.port" "" "$configpath" "1")" = "$dnsmasq_port" ] || return 1
uci commit dhcp uci set dhcp.@dnsmasq[0].port="53" || return 1
uci commit dhcp || return 1
config_get binpath $CONFIGURATION binpath "/usr/bin/AdGuardHome/AdGuardHome" apply_dnsmasq_config "$dnsmasq_port" || return 1
killall -9 "$binpath" 2>/dev/null
/etc/init.d/dnsmasq restart
agh_reload agh_reload
} }
@@ -136,10 +287,12 @@ use_port53() {
return return
fi fi
stop_core || return 1
config_editor "dns.port" "53" "$configpath" config_editor "dns.port" "53" "$configpath"
uci set dhcp.@dnsmasq[0].port="$AdGuardHome_PORT" [ "$(config_editor "dns.port" "" "$configpath" "1")" = "53" ] || return 1
uci commit dhcp uci set dhcp.@dnsmasq[0].port="$AdGuardHome_PORT" || return 1
/etc/init.d/dnsmasq restart uci commit dhcp || return 1
apply_dnsmasq_config "$dnsmasq_port" || return 1
agh_reload agh_reload
} }
@@ -175,12 +328,44 @@ mark_redirect_flag() {
echo -n "$flag" > /var/run/AdGredir echo -n "$flag" > /var/run/AdGredir
} }
_do_redirect() { finish_dnsmasq_config() {
case "$old_redirect:$redirect" in
*dnsmasq-upstream*|*exchange*)
if [ "$dnsmasq_before" != "$(uci -q export dhcp)" ] || [ "$previous_flag" != "1" ] || [ "$enabled" = "0" ]; then
local released_port=""
if [ "$enabled" = "1" ] && [ "$redirect" = "exchange" ]; then
released_port=53
elif [ "$dnsmasq_previous_port" != "$(uci -q get dhcp.@dnsmasq[0].port)" ]; then
released_port="$dnsmasq_previous_port"
fi
restart_dnsmasq "$released_port" || return 1
fi
;;
esac
if { [ "$enabled" = "0" ] || [ "$redirect" != "dnsmasq-upstream" ]; } &&
[ "$(uci -q get AdGuardHome.dnsmasq_backup.ready)" = "1" ]; then
uci -q delete AdGuardHome.dnsmasq_backup
uci commit AdGuardHome || return 1
fi
return 0
}
_do_redirect() (
exec 8>/var/lock/AdGuardHome-redirect.lock || return 1
flock -w 10 8 || return 1
local AGH_DEFER_DNSMASQ_RESTART=1 AGH_RELOAD_PENDING=0
local dnsmasq_before dnsmasq_previous_port previous_flag
dnsmasq_before="$(uci -q export dhcp)" || return 1
dnsmasq_previous_port="$(uci -q get dhcp.@dnsmasq[0].port)"
dnsmasq_previous_port="${dnsmasq_previous_port:-53}"
previous_flag="$(cat /var/run/AdGredir 2>/dev/null)"
echo -n "0" > /var/run/AdGredir
local section="$CONFIGURATION" local section="$CONFIGURATION"
args="" args=""
ipv6_server=1 ipv6_server=1
tcp_server=0 tcp_server=0
enabled="$1" enabled="$1"
AGH_REDIRECT_RELOAD="${2:-$enabled}"
config_get configpath $CONFIGURATION configpath "/etc/AdGuardHome.yaml" config_get configpath $CONFIGURATION configpath "/etc/AdGuardHome.yaml"
AdGuardHome_PORT="$(config_editor "dns.port" "" "$configpath" "1")" AdGuardHome_PORT="$(config_editor "dns.port" "" "$configpath" "1")"
@@ -203,9 +388,18 @@ _do_redirect() {
if [ "$old_redirect" = "redirect" ] && [ "$old_port" != "0" ]; then if [ "$old_redirect" = "redirect" ] && [ "$old_port" != "0" ]; then
clear_nft_redirect clear_nft_redirect
elif [ "$old_redirect" = "dnsmasq-upstream" ]; then elif [ "$old_redirect" = "dnsmasq-upstream" ]; then
stop_forward_dnsmasq "$old_port" if [ "$old_enabled" = "1" ] || [ "$(uci -q get AdGuardHome.dnsmasq_backup.ready)" = "1" ]; then
if ! stop_forward_dnsmasq "$old_port"; then
echo -n "0" > /var/run/AdGredir
return 1
fi
fi
old_enabled=0
elif [ "$old_redirect" = "exchange" ]; then elif [ "$old_redirect" = "exchange" ]; then
rm_port53 if ! rm_port53; then
echo -n "0" > /var/run/AdGredir
return 1
fi
fi fi
fi fi
elif [ "$old_enabled" = "1" ] && [ "$enabled" = "1" ]; then elif [ "$old_enabled" = "1" ] && [ "$enabled" = "1" ]; then
@@ -223,6 +417,7 @@ _do_redirect() {
uci commit AdGuardHome uci commit AdGuardHome
if [ "$enabled" = "0" ] || [ "$AdGuardHome_PORT" = "0" ]; then if [ "$enabled" = "0" ] || [ "$AdGuardHome_PORT" = "0" ]; then
finish_dnsmasq_config || return 1
echo -n "0" > /var/run/AdGredir echo -n "0" > /var/run/AdGredir
return 1 return 1
fi fi
@@ -230,13 +425,23 @@ _do_redirect() {
if [ "$redirect" = "redirect" ]; then if [ "$redirect" = "redirect" ]; then
set_nft_redirect "$AdGuardHome_PORT" set_nft_redirect "$AdGuardHome_PORT"
elif [ "$redirect" = "dnsmasq-upstream" ]; then elif [ "$redirect" = "dnsmasq-upstream" ]; then
set_forward_dnsmasq "$AdGuardHome_PORT" if ! set_forward_dnsmasq "$AdGuardHome_PORT" "$old_redirect" "$old_enabled" "$old_port"; then
echo -n "0" > /var/run/AdGredir
return 1
fi
elif [ "$redirect" = "exchange" ] && [ "$(uci get dhcp.@dnsmasq[0].port 2>/dev/null)" = "53" ]; then elif [ "$redirect" = "exchange" ] && [ "$(uci get dhcp.@dnsmasq[0].port 2>/dev/null)" = "53" ]; then
use_port53 if ! use_port53; then
echo -n "0" > /var/run/AdGredir
return 1
fi
fi fi
mark_redirect_flag "$enabled" "$redirect" "$AdGuardHome_PORT" finish_dnsmasq_config || return 1
} mark_redirect_flag "$enabled" "$redirect" "$AdGuardHome_PORT" || return 1
AGH_DEFER_DNSMASQ_RESTART=0
[ "$AGH_RELOAD_PENDING" = "0" ] || agh_reload
return 0
)
do_redirect() { do_redirect() {
config_load "${CONFIGURATION}" config_load "${CONFIGURATION}"
@@ -326,6 +531,7 @@ boot_service() {
} }
start_service() { start_service() {
stop_core || return 1
rm -f /var/run/AdGserverdis 2>/dev/null rm -f /var/run/AdGserverdis 2>/dev/null
config_load "${CONFIGURATION}" config_load "${CONFIGURATION}"
config_get hashpass $CONFIGURATION hashpass "" config_get hashpass $CONFIGURATION hashpass ""
@@ -379,7 +585,7 @@ start_service() {
if [ ! -f "$binpath" ]; then if [ ! -f "$binpath" ]; then
_do_redirect 0 _do_redirect 0
/usr/share/AdGuardHome/update_core.sh 2>&1 >/tmp/AdGuardHome_update.log & /usr/share/AdGuardHome/update_core.sh >/dev/null 2>&1 &
exit 0 exit 0
fi fi
@@ -388,6 +594,20 @@ start_service() {
ADDITIONAL_ARGS="$ADDITIONAL_ARGS -v" ADDITIONAL_ARGS="$ADDITIONAL_ARGS -v"
fi fi
if [ "$(uci -q get bypass.@global[0].global_server)" ] && [ "$(uci -q get bypass.@global[0].adguardhome)" = "1" ] && [ "$(uci -q get dhcp.@dnsmasq[0].port)" = "53" ]; then
uci -q set AdGuardHome.AdGuardHome.redirect='exchange'
uci -q commit AdGuardHome
config_load "${CONFIGURATION}"
fi
if [ -f "$configpath" ]; then
if ! _do_redirect 1 0; then
[ "$(config_editor "dns.port" "" "$configpath" "1")" = "0" ] || return 1
fi
else
_do_redirect 0 0
fi
procd_open_instance procd_open_instance
get_tz get_tz
if [ -n "$SET_TZ" ]; then if [ -n "$SET_TZ" ]; then
@@ -399,10 +619,7 @@ start_service() {
procd_set_param command "$binpath" $ADDITIONAL_ARGS procd_set_param command "$binpath" $ADDITIONAL_ARGS
procd_set_param file "$configpath" "/etc/hosts" "/etc/config/AdGuardHome" procd_set_param file "$configpath" "/etc/hosts" "/etc/config/AdGuardHome"
procd_close_instance procd_close_instance
if [ -f "$configpath" ]; then if [ ! -f "$configpath" ]; then
(sleep 5 && _do_redirect 1) &
else
_do_redirect 0
config_get "redirect" "AdGuardHome" "redirect" "none" config_get "redirect" "AdGuardHome" "redirect" "none"
if [ "$redirect" != "none" ]; then if [ "$redirect" != "none" ]; then
procd_open_instance "waitconfig" procd_open_instance "waitconfig"
@@ -413,12 +630,7 @@ start_service() {
fi fi
echo "AdGuardHome service enabled" echo "AdGuardHome service enabled"
echo "luci enable switch=$enabled" echo "luci enable switch=$enabled"
(sleep 10 && [ -z "$(pgrep "$(basename "$binpath")")" ] && logger "AdGuardHome" "no process in 10s cancel redirect" && _do_redirect 0 )&
if [ "$(uci -q get bypass.@global[0].global_server)" ] && [ "$(uci -q get bypass.@global[0].adguardhome)" = "1" ] && [ "$(uci -q get dhcp.@dnsmasq[0].port)" = "53" ]; then
uci -q set AdGuardHome.AdGuardHome.redirect='exchange'
uci -q commit AdGuardHome
do_redirect
fi
} }
reload_service() { reload_service() {
@@ -428,6 +640,7 @@ reload_service() {
} }
stop_service() { stop_service() {
stop_core || return 1
config_load "${CONFIGURATION}" config_load "${CONFIGURATION}"
_do_redirect 0 _do_redirect 0
echo "AdGuardHome service disabled" echo "AdGuardHome service disabled"
@@ -7,5 +7,8 @@ uci -q batch <<-EOF >/dev/null 2>&1
commit ucitrack commit ucitrack
EOF EOF
rm -f /tmp/luci-indexcache # Remove the former menu and both Lua and ucode menu caches.
exit 0 rm -f /usr/share/luci/menu.d/luci-app-adguardhome-js.json
rm -f /tmp/luci-indexcache /tmp/luci-indexcache.*
rm -f /tmp/luci-modulecache/*
exit 0
@@ -2,6 +2,28 @@
PATH="/usr/sbin:/usr/bin:/sbin:/bin" PATH="/usr/sbin:/usr/bin:/sbin:/bin"
update_mode=$1 update_mode=$1
# Keep the lock file: unlinking it would allow a second lock on a new inode.
exec 9>/var/lock/AdGuardHome-update.lock || exit 1
# Allow a brief status probe to finish before treating the lock as busy.
if ! flock -w 1 9; then
printf '%s\n' '已经有更新任务正在运行,请等待其结束。'
exit 2
fi
# Only the lock owner may reset the log and update status.
exec >/tmp/AdGuardHome_update.log 2>&1
cleanup() {
local result=$?
trap - EXIT
[ "$result" -ne 0 ] && touch /var/run/update_core_error
rm -f /var/run/update_core
exit "$result"
}
trap cleanup EXIT
trap 'exit 1' TERM INT HUP
rm -f /var/run/update_core_error
touch /var/run/update_core
binpath=$(uci get AdGuardHome.AdGuardHome.binpath 2>/dev/null) binpath=$(uci get AdGuardHome.AdGuardHome.binpath 2>/dev/null)
if [ -z "$binpath" ]; then if [ -z "$binpath" ]; then
uci set AdGuardHome.AdGuardHome.binpath="/tmp/AdGuardHome/AdGuardHome" uci set AdGuardHome.AdGuardHome.binpath="/tmp/AdGuardHome/AdGuardHome"
@@ -11,31 +33,9 @@ fi
enabled=$(uci get AdGuardHome.AdGuardHome.enabled 2>/dev/null) enabled=$(uci get AdGuardHome.AdGuardHome.enabled 2>/dev/null)
core_api_url="https://api.github.com/repos/AdguardTeam/AdGuardHome/releases/latest" core_api_url="https://api.github.com/repos/AdguardTeam/AdGuardHome/releases/latest"
Check_Task(){
running_tasks=$(pgrep -f "update_core.sh" 2>/dev/null | grep -v "^$$\$" | wc -l)
running_tasks=${running_tasks:-0}
case $1 in
force)
printf "%s\n" "执行: 强制更新核心"
if [ "$running_tasks" -gt 0 ]; then
printf "%s\n" "清除 ${running_tasks} 个进程 ..."
pkill -9 -f "update_core.sh" 2>/dev/null
sleep 1
fi
;;
*)
if [ "$running_tasks" -gt 1 ]; then
printf "%s\n" "已经有更新任务正在运行, 请等待其执行结束或点击强制更新!"
EXIT 2
fi
;;
esac
}
Check_Updates(){ Check_Updates(){
downloader_cmd="uclient-fetch" downloader_cmd="uclient-fetch"
downloader_opts="--no-check-certificate -T 5 -q -O" downloader_opts="-T 5 -q -O"
downloader_quiet_opts="-q -O -" downloader_quiet_opts="-q -O -"
printf "%s\n" "开始检查更新, 请耐心等待 ..." printf "%s\n" "开始检查更新, 请耐心等待 ..."
Cloud_Version="$(${downloader_cmd} ${downloader_quiet_opts} "$core_api_url" 2>/dev/null | sed -n 's/.*"tag_name":[ ]*"\(v[^"]*\)".*/\1/p' | head -n 1)" Cloud_Version="$(${downloader_cmd} ${downloader_quiet_opts} "$core_api_url" 2>/dev/null | sed -n 's/.*"tag_name":[ ]*"\(v[^"]*\)".*/\1/p' | head -n 1)"
@@ -67,7 +67,7 @@ Update_Core(){
printf "\n开始下载 AdGuardHome 核心文件 ...\n\n" printf "\n开始下载 AdGuardHome 核心文件 ...\n\n"
${downloader_cmd} ${downloader_opts} "/tmp/AdGuardHome_Update/${link##*/}" "$link" > /dev/null 2>&1 ${downloader_cmd} ${downloader_opts} "/tmp/AdGuardHome_Update/${link##*/}" "$link" > /dev/null 2>&1
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
printf "\nAdGuardHome 核心下载失败 ...\n" printf "\nAdGuardHome 核心下载失败,请检查网络、系统时间及 CA 证书 ...\n"
rm -rf /tmp/AdGuardHome_Update rm -rf /tmp/AdGuardHome_Update
EXIT 1 EXIT 1
fi fi
@@ -87,10 +87,9 @@ Update_Core(){
chmod +x "$downloadbin" chmod +x "$downloadbin"
size_bytes=$(wc -c < "$downloadbin" 2>/dev/null || echo 0) size_bytes=$(wc -c < "$downloadbin" 2>/dev/null || echo 0)
printf "\nAdGuardHome 核心体积: %s\n" "$(awk -v bytes="$size_bytes" 'BEGIN{printf "%.2fMB", bytes/1000000}')" printf "\nAdGuardHome 核心体积: %s\n" "$(awk -v bytes="$size_bytes" 'BEGIN{printf "%.2fMB", bytes/1000000}')"
/etc/init.d/AdGuardHome stop > /dev/null 2>&1 /etc/init.d/AdGuardHome stop 9>&- > /dev/null 2>&1
printf "\n移动 AdGuardHome 核心文件到 %s ...\n" "${binpath%/*}" printf "\n移动 AdGuardHome 核心文件到 %s ...\n" "${binpath%/*}"
mv -f "$downloadbin" "$binpath" > /dev/null 2>&1 if ! mv -f "$downloadbin" "$binpath" > /dev/null 2>&1 || [ ! -s "$binpath" ]; then
if [ ! -s "$binpath" ] && [ $? -ne 0 ]; then
printf "AdGuardHome 核心移动失败!\n可能是设备空间不足导致, 请尝试更改 [执行文件路径] 为 /tmp/AdGuardHome\n" printf "AdGuardHome 核心移动失败!\n可能是设备空间不足导致, 请尝试更改 [执行文件路径] 为 /tmp/AdGuardHome\n"
EXIT 1 EXIT 1
fi fi
@@ -98,7 +97,7 @@ Update_Core(){
chmod +x "$binpath" chmod +x "$binpath"
if [ "$enabled" = 1 ]; then if [ "$enabled" = 1 ]; then
printf "\n正在重启 AdGuardHome 服务...\n" printf "\n正在重启 AdGuardHome 服务...\n"
/etc/init.d/AdGuardHome restart > /dev/null 2>&1 /etc/init.d/AdGuardHome restart 9>&- > /dev/null 2>&1
fi fi
printf "\nAdGuardHome 核心更新成功!\n" printf "\nAdGuardHome 核心更新成功!\n"
} }
@@ -159,18 +158,7 @@ GET_Arch() {
} }
EXIT(){ EXIT(){
rm -rf /var/run/update_core $LOCKU 2>/dev/null exit "$1"
[ "$1" -ne 0 ] && touch /var/run/update_core_error
exit $1
} }
main(){ Check_Updates "$update_mode"
Check_Task "$update_mode"
Check_Updates "$update_mode"
}
trap "EXIT 1" SIGTERM SIGINT
touch /var/run/update_core
rm -rf /var/run/update_core_error 2>/dev/null
main
@@ -1,6 +1,6 @@
{ {
"admin/services/AdGuardHomeJS": { "admin/services/AdGuardHome": {
"title": "AdGuard Home JS", "title": "AdGuard Home",
"order": 12, "order": 12,
"action": { "action": {
"type": "firstchild" "type": "firstchild"
@@ -9,7 +9,7 @@
"acl": [ "luci-app-adguardhome" ] "acl": [ "luci-app-adguardhome" ]
} }
}, },
"admin/services/AdGuardHomeJS/base": { "admin/services/AdGuardHome/base": {
"title": "Base Setting", "title": "Base Setting",
"order": 1, "order": 1,
"action": { "action": {
@@ -20,7 +20,7 @@
"acl": [ "luci-app-adguardhome" ] "acl": [ "luci-app-adguardhome" ]
} }
}, },
"admin/services/AdGuardHomeJS/log": { "admin/services/AdGuardHome/log": {
"title": "Log", "title": "Log",
"order": 3, "order": 3,
"action": { "action": {
@@ -31,7 +31,7 @@
"acl": [ "luci-app-adguardhome" ] "acl": [ "luci-app-adguardhome" ]
} }
}, },
"admin/services/AdGuardHomeJS/manual": { "admin/services/AdGuardHome/manual": {
"title": "Manual Config", "title": "Manual Config",
"order": 2, "order": 2,
"action": { "action": {
@@ -42,4 +42,4 @@
"acl": [ "luci-app-adguardhome" ] "acl": [ "luci-app-adguardhome" ]
} }
} }
} }
@@ -7,13 +7,13 @@
}, },
"ubus": { "ubus": {
"service": [ "list" ], "service": [ "list" ],
"luci.adguardhome": [ "getCoreInfo", "getCoreUpdate", "getInterfaces", "getRuntimeLog", "getManualConfig" ] "luci.adguardhome": [ "getCoreInfo", "getRedirectStatus", "getCoreUpdate", "getInterfaces", "getRuntimeLog", "getManualConfig" ]
}, },
"uci": [ "AdGuardHome" ] "uci": [ "AdGuardHome" ]
}, },
"write": { "write": {
"ubus": { "ubus": {
"luci.adguardhome": [ "startCoreUpdate", "changeWebPassword", "clearRuntimeLog", "setManualConfig" ] "luci.adguardhome": [ "setVerboseLog", "startCoreUpdate", "changeWebPassword", "clearRuntimeLog", "setManualConfig" ]
}, },
"uci": [ "AdGuardHome" ] "uci": [ "AdGuardHome" ]
} }
@@ -1,15 +1,48 @@
#!/usr/bin/ucode #!/usr/bin/ucode
'use strict'; 'use strict';
import { stat, popen, readfile, writefile, unlink, lsdir } from 'fs'; import { stat, open, popen, readfile, writefile, unlink, lsdir } from 'fs';
import { cursor } from 'uci'; import { cursor } from 'uci';
function shellQuote(value) { function shellQuote(value) {
return "'" + replace(value, /'/g, "'\\''") + "'"; return "'" + replace(value, /'/g, "'\\''") + "'";
} }
function coreUpdateRunning() {
// The kernel releases the lock even if the updater is killed.
const result = system(['/usr/bin/flock', '-n', '-E', '2',
'/var/lock/AdGuardHome-update.lock', '/bin/true'], 3000);
if (result != 0 && result != 2)
die('Unable to check core update lock');
return result == 2;
}
return { return {
'luci.adguardhome': { 'luci.adguardhome': {
getRedirectStatus: {
call: function() {
const conf = cursor();
function option(name, fallback) {
const value = conf.get('AdGuardHome', 'AdGuardHome', name);
return (type(value) == 'array' ? value[0] : value) || fallback;
}
const result = {
mode: option('redirect', 'none'),
enabled: option('enabled', '0') == '1',
applied_mode: option('old_redirect', 'none'),
applied_enabled: option('old_enabled', '0') == '1',
binpath: option('binpath', '/usr/bin/AdGuardHome/AdGuardHome'),
redirected: trim(readfile('/var/run/AdGredir') || '') == '1'
};
conf.unload('AdGuardHome');
const lock = system(['/usr/bin/flock', '-n', '-E', '2',
'/var/lock/AdGuardHome-redirect.lock', '/bin/true'], 3000);
if (lock != 0 && lock != 2)
die('Unable to check redirect status');
result.busy = lock == 2;
return result;
}
},
getManualConfig: { getManualConfig: {
call: function() { call: function() {
const conf = cursor(); const conf = cursor();
@@ -98,8 +131,9 @@ return {
}, },
getCoreUpdate: { getCoreUpdate: {
call: function() { call: function() {
const running = stat('/var/run/update_core') != null; const running = coreUpdateRunning();
const failed = stat('/var/run/update_core_error') != null; const failed = stat('/var/run/update_core_error') != null ||
(!running && stat('/var/run/update_core') != null);
const log = readfile('/tmp/AdGuardHome_update.log', 131072) || ''; const log = readfile('/tmp/AdGuardHome_update.log', 131072) || '';
return { return {
@@ -111,15 +145,12 @@ return {
startCoreUpdate: { startCoreUpdate: {
args: { force: false }, args: { force: false },
call: function(request) { call: function(request) {
if (stat('/var/run/update_core') != null) if (coreUpdateRunning())
return { started: false, reason: 'busy' }; return { started: false, reason: 'busy' };
unlink('/var/run/update_core_error');
unlink('/tmp/AdGuardHome_update.log');
const command = request.args.force const command = request.args.force
? '/bin/sh /usr/share/AdGuardHome/update_core.sh force >/tmp/AdGuardHome_update.log 2>&1 </dev/null &' ? '/bin/sh /usr/share/AdGuardHome/update_core.sh force >/dev/null 2>&1 </dev/null &'
: '/bin/sh /usr/share/AdGuardHome/update_core.sh >/tmp/AdGuardHome_update.log 2>&1 </dev/null &'; : '/bin/sh /usr/share/AdGuardHome/update_core.sh >/dev/null 2>&1 </dev/null &';
return { started: system(['/bin/sh', '-c', command], 3000) == 0 }; return { started: system(['/bin/sh', '-c', command], 3000) == 0 };
} }
@@ -150,8 +181,22 @@ return {
return { changed: true }; return { changed: true };
} }
}, },
setVerboseLog: {
args: { enabled: false },
call: function(request) {
const conf = cursor();
if (!conf.set('AdGuardHome', 'AdGuardHome', 'verbose', request.args.enabled ? '1' : '0') ||
!conf.commit('AdGuardHome'))
return { saved: false, applied: false };
conf.unload('AdGuardHome');
return {
saved: true,
applied: system(['/etc/init.d/AdGuardHome', 'reload'], 30000) == 0
};
}
},
getRuntimeLog: { getRuntimeLog: {
args: { offset: 0 }, args: { offset: 0, identity: '' },
call: function(request) { call: function(request) {
const conf = cursor(); const conf = cursor();
const logfile = conf.get('AdGuardHome', 'AdGuardHome', 'logfile') || ''; const logfile = conf.get('AdGuardHome', 'AdGuardHome', 'logfile') || '';
@@ -167,18 +212,40 @@ return {
system(['/bin/sh', '-c', '/bin/sh /usr/share/AdGuardHome/getsyslog.sh >/dev/null 2>&1 &'], 3000); system(['/bin/sh', '-c', '/bin/sh /usr/share/AdGuardHome/getsyslog.sh >/dev/null 2>&1 &'], 3000);
} }
const log = readfile(source) || ''; // Bound memory and start with the most recent 256 KiB on a new view.
const size = length(log); const limit = 262144;
let offset = +request.args.offset || 0; const info = stat(source);
if (offset < 0 || offset > size) const identity = info ? source + ':' + info.dev.major + ':' + info.dev.minor + ':' + info.inode : '';
let offset = int(request.args.offset) || 0;
let reset = request.args.identity != identity || offset < 0 || offset > (info?.size || 0);
if (reset)
offset = 0; offset = 0;
const content = substr(log, offset, 2097152); if (info && info.size - offset > limit) {
offset = info.size - limit;
reset = true;
}
let content = '';
if (info?.type == 'file') {
const file = open(source, 'r');
if (!file)
die('Unable to open runtime log');
const positioned = file.seek(offset);
const chunk = positioned ? file.read(limit) : null;
file.close();
if (chunk == null)
die('Unable to read runtime log');
content = chunk;
}
return { return {
enabled: true, enabled: true,
localtime: logfile != 'syslog', localtime: logfile != 'syslog',
content: content, // ubus strings stop at NUL; cleared files can contain sparse zero-filled gaps.
offset: offset + length(content) content_b64: b64enc(content),
offset: offset + length(content),
identity: identity,
reset: reset
}; };
} }
}, },
@@ -3,6 +3,7 @@
'require form'; 'require form';
'require poll'; 'require poll';
'require rpc'; 'require rpc';
'require ui';
'require view/adguardhome/status as status'; 'require view/adguardhome/status as status';
var callInterfaces = rpc.declare({ var callInterfaces = rpc.declare({
@@ -28,12 +29,27 @@ var callStartCoreUpdate = rpc.declare({
}); });
return view.extend({ return view.extend({
handleSaveApply: function(ev, mode) {
var target = {
mode: this.redirectOption.formvalue('AdGuardHome') || 'none',
enabled: this.enabledOption.formvalue('AdGuardHome') === '1'
};
this.sharedStatus.beginSwitch(target);
return this.handleSave(ev).then(function() {
ui.changes.apply(mode == '0');
}).catch(function(error) {
this.sharedStatus.cancelSwitch();
throw error;
}.bind(this));
},
load: function() { load: function() {
return callInterfaces().catch(function() { return { interfaces: [] }; }); return callInterfaces().catch(function() { return { interfaces: [] }; });
}, },
render: function(data) { render: function(data) {
let m, s, o; let m, s, o;
var self = this;
var interfaces = data.interfaces || []; var interfaces = data.interfaces || [];
m = new form.Map('AdGuardHome'); m = new form.Map('AdGuardHome');
@@ -52,6 +68,7 @@ return view.extend({
'enabled', 'enabled',
_('Enable') _('Enable')
); );
this.enabledOption = o;
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;
@@ -68,13 +85,13 @@ return view.extend({
o = s.option( o = s.option(
form.ListValue, form.ListValue,
'redirect', 'redirect',
_('Redirect mode'), _('DNS handling mode')
_('AdGuardHome redirect mode')
); );
o.value('none', _('None')); this.redirectOption = o;
o.value('none', _('Do not handle DNS'));
o.value('redirect', _('Forward port 53 requests through the firewall (recommended)'));
o.value('dnsmasq-upstream', _('Run as dnsmasq upstream server')); o.value('dnsmasq-upstream', _('Run as dnsmasq upstream server'));
o.value('redirect', _('Redirect port 53 to AdGuardHome'));
o.value('exchange', _('Use port 53 instead of dnsmasq')); o.value('exchange', _('Use port 53 instead of dnsmasq'));
o.default = 'none'; o.default = 'none';
@@ -83,9 +100,11 @@ return view.extend({
o = s.option( o = s.option(
form.DynamicList, form.DynamicList,
'wan_ifname', 'wan_ifname',
_('WAN Interface'), _('Interfaces excluded from forwarding'),
_('Only effective in redirect mode, bypass specified interfaces to avoid becoming a public DNS resolver') _('DNS requests arriving on these interfaces are not forwarded to AdGuardHome')
); );
o.depends('redirect', 'redirect');
o.retain = true;
o.datatype = 'string'; o.datatype = 'string';
o.rmempty = true; o.rmempty = true;
interfaces.filter(function(name) { return name !== 'lo'; }).forEach(function(name) { interfaces.filter(function(name) { return name !== 'lo'; }).forEach(function(name) {
@@ -95,8 +114,7 @@ return view.extend({
o = s.option( o = s.option(
form.Value, form.Value,
'binpath', 'binpath',
_('Bin Path'), _('Bin Path')
_('AdGuardHome binary path')
); );
o.default = '/usr/bin/AdGuardHome/AdGuardHome'; o.default = '/usr/bin/AdGuardHome/AdGuardHome';
o.rmempty = false; o.rmempty = false;
@@ -104,8 +122,7 @@ return view.extend({
o = s.option( o = s.option(
form.Value, form.Value,
'configpath', 'configpath',
_('Config Path'), _('Config Path')
_('AdGuardHome config path')
); );
o.default = '/etc/AdGuardHome.yaml'; o.default = '/etc/AdGuardHome.yaml';
o.rmempty = false; o.rmempty = false;
@@ -113,50 +130,37 @@ return view.extend({
o = s.option( o = s.option(
form.Value, form.Value,
'workdir', 'workdir',
_('Work dir'), _('Work dir')
_('AdGuardHome work dir including rules, audit log and database')
); );
o.default = '/usr/bin/AdGuardHome'; o.default = '/usr/bin/AdGuardHome';
o.rmempty = false; o.rmempty = false;
o = s.option(
form.Value,
'logfile',
_('Runtime log file'),
_('If set to "syslog", write to system log; if empty, disable file logging')
);
o.rmempty = true;
o = s.option(
form.Flag,
'verbose',
_('Verbose log')
);
o.default = '0';
o.rmempty = true;
return m.render().then(function(node) { return m.render().then(function(node) {
var sharedStatus = status.render(); var sharedStatus = status.render();
self.sharedStatus = sharedStatus;
var updateButton = E('button', { var updateButton = E('button', {
'class': 'btn cbi-button cbi-button-apply', 'type': 'button' 'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
}, _('Update core version')); }, _('Update core version'));
var forceButton = E('button', { var forceButton = E('button', {
'class': 'btn cbi-button', 'type': 'button', 'style': 'margin-left:0.6em;' 'class': 'btn cbi-button', 'type': 'button'
}, _('Force update')); }, _('Force update'));
var updateMessage = E('span', { 'style': 'margin-left:0.8em;' }, ''); var updateMessage = E('span', {}, '');
var reverseLog = E('input', { 'type': 'checkbox' }); var reverseLog = E('input', { 'type': 'checkbox' });
var updateLog = E('textarea', { var updateLog = E('textarea', {
'class': 'cbi-input-textarea', 'rows': 8, 'readonly': 'readonly', 'class': 'cbi-input-textarea agh-update-log', 'rows': 8, 'readonly': 'readonly',
'style': 'display:none;width:100%;margin-top:0.6em;box-sizing:border-box;' 'style': 'display:none;'
}); });
var logOptions = E('label', { 'style': 'display:none;margin-top:0.6em;' }, [ var logOptions = E('label', { 'class': 'agh-update-options', 'style': 'display:none;' }, [
reverseLog, ' ' + _('reverse') reverseLog, E('span', {}, _('reverse'))
]); ]);
var updatePanel = E('div', { 'class': 'cbi-section' }, [ var updatePanel = E('div', { 'class': 'cbi-value' }, [
E('h3', {}, _('Upgrade Core')), E('div', { 'class': 'cbi-value-title' }, _('Upgrade Core')),
E('div', { 'style': 'padding:0.6em;' }, [updateButton, forceButton, updateMessage]), E('div', { 'class': 'cbi-value-field' }, E('div', { 'class': 'agh-update-content' }, [
logOptions, updateLog E('div', { 'class': 'agh-toolbar' }, [updateButton, forceButton, updateMessage]),
logOptions, updateLog
]))
]); ]);
var lastLog = ''; var lastLog = '';
var startingAt = 0; var startingAt = 0;
@@ -186,7 +190,8 @@ return view.extend({
updateMessage.textContent = state === 'running' ? _('Check...') : updateMessage.textContent = state === 'running' ? _('Check...') :
state === 'finished' ? _('Updated') : _('Update failed'); state === 'finished' ? _('Updated') : _('Update failed');
lastLog = result.log || ''; lastLog = result.log || '';
logOptions.style.display = updateLog.style.display = lastLog ? 'block' : 'none'; logOptions.style.display = lastLog ? 'inline-flex' : 'none';
updateLog.style.display = lastLog ? 'block' : 'none';
showLog(); showLog();
} else { } else {
updateMessage.textContent = ''; updateMessage.textContent = '';
@@ -227,8 +232,14 @@ return view.extend({
updateMessage.textContent = _('Update status unavailable'); updateMessage.textContent = _('Update status unavailable');
}); });
}, 3); }, 3);
var settingsCard = node.querySelector('.cbi-section');
settingsCard.classList.add('agh-compact-card', 'agh-base-card');
var settingsFields = settingsCard.querySelector('.cbi-section-node') || settingsCard;
settingsFields.appendChild(updatePanel);
node.insertBefore(sharedStatus.node, node.firstChild); node.insertBefore(sharedStatus.node, node.firstChild);
sharedStatus.node.parentNode.insertBefore(updatePanel, sharedStatus.node.nextSibling); node.insertBefore(E('link', {
'rel': 'stylesheet', 'href': L.resource('view/adguardhome/compact.css')
}), node.firstChild);
var portInput = node.querySelector('[data-name="httpport"] input'); var portInput = node.querySelector('[data-name="httpport"] input');
var portField = node.querySelector('[data-name="httpport"] .cbi-value-field'); var portField = node.querySelector('[data-name="httpport"] .cbi-value-field');
@@ -239,7 +250,7 @@ return view.extend({
'target': '_blank', 'target': '_blank',
'value': _('AdGuardHome Web') 'value': _('AdGuardHome Web')
}); });
var linkWrap = E('div', { 'style': 'margin-top:0.6em;' }, webLink); var linkWrap = E('div', { 'style': 'margin-top:0.35rem;' }, webLink);
var host = window.location.hostname; var host = window.location.hostname;
if (host.indexOf(':') >= 0 && host.charAt(0) !== '[') if (host.indexOf(':') >= 0 && host.charAt(0) !== '[')
host = '[' + host + ']'; host = '[' + host + ']';
@@ -262,4 +273,4 @@ return view.extend({
return node; return node;
}); });
} }
}); });
@@ -0,0 +1,153 @@
/* Scoped to the settings and log cards; shared status badges keep their layout. */
.agh-compact-card {
margin-bottom: 0.75rem;
}
.agh-compact-card .cbi-value {
margin-bottom: 0;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.agh-compact-card .cbi-value-description {
margin-top: 0.25rem;
}
.agh-compact-card .agh-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.agh-compact-card .agh-toolbar > * {
margin: 0;
}
.agh-compact-card .agh-update-content {
width: 100%;
max-width: 40rem;
min-width: 0;
}
.agh-compact-card .agh-update-options {
align-items: center;
gap: 0.35rem;
width: auto;
margin: 0.5rem 0 0;
padding: 0;
float: none;
}
.agh-compact-card .agh-update-options input[type="checkbox"] {
position: static;
flex: 0 0 auto;
margin: 0;
}
.agh-compact-card textarea.agh-update-log {
width: 100%;
height: 10rem;
min-height: 6rem;
box-sizing: border-box;
resize: vertical;
margin: 0.35rem 0 0;
}
.agh-log-view {
padding: 0.6em;
}
.agh-log-view textarea {
display: block;
width: 100%;
height: clamp(12rem, 32vh, 20rem);
min-height: 10rem;
box-sizing: border-box;
resize: vertical;
margin: 0 0 0.6em;
}
.agh-manual-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding: 0;
}
.agh-compact-card .agh-password-controls label {
float: none;
width: auto;
padding: 0;
}
.agh-compact-card .agh-password-controls input {
width: 14rem;
max-width: 100%;
min-width: 0;
}
.agh-manual-card {
padding: 0.6em;
}
.agh-editor-wrap {
margin: 0 0 0.6em;
}
.agh-config-actions {
margin-left: auto;
}
/* Keep the base card heading in the theme's original position. */
.agh-base-card .cbi-section-node {
margin: 0;
padding: 0.6em;
}
.agh-log-view .agh-log-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6em 1.2em;
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}
.agh-log-view .agh-log-toggles {
justify-content: flex-start;
flex: 0 1 auto;
}
.agh-log-view .agh-log-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.6em 1.2em;
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
}
.agh-log-view .agh-log-actions > .cbi-value {
display: flex;
align-items: center;
flex-wrap: wrap;
flex: 0 1 auto;
gap: 0.5em;
width: auto;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.agh-log-view .agh-log-actions .cbi-value-title {
float: none;
flex: 0 0 auto;
width: auto;
margin: 0;
padding: 0;
}
.agh-log-view .agh-log-actions .cbi-value-field {
flex: 0 1 auto;
width: auto;
min-width: 0;
margin: 0;
padding: 0;
}
.agh-log-view .agh-log-actions .cbi-value-field input {
width: 22em;
max-width: 100%;
box-sizing: border-box;
}
.agh-log-controls .agh-toolbar > span:empty {
display: none;
}
@@ -1,5 +1,6 @@
'use strict'; 'use strict';
'require view'; 'require view';
'require form';
'require poll'; 'require poll';
'require rpc'; 'require rpc';
'require view/adguardhome/status as status'; 'require view/adguardhome/status as status';
@@ -7,7 +8,7 @@
var callRuntimeLog = rpc.declare({ var callRuntimeLog = rpc.declare({
object: 'luci.adguardhome', object: 'luci.adguardhome',
method: 'getRuntimeLog', method: 'getRuntimeLog',
params: [ 'offset' ], params: [ 'offset', 'identity' ],
expect: { '': {} }, expect: { '': {} },
reject: true reject: true
}); });
@@ -19,6 +20,28 @@ var callClearRuntimeLog = rpc.declare({
reject: true reject: true
}); });
var callSetVerboseLog = rpc.declare({
object: 'luci.adguardhome',
method: 'setVerboseLog',
params: [ 'enabled' ],
expect: { '': {} },
reject: true,
timeout: 45000
});
function decodeLog(result) {
var content = result.content || '';
if (typeof result.content_b64 === 'string') {
var binary = atob(result.content_b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
content = new TextDecoder('utf-8').decode(bytes);
}
// Truncating an open log does not reset a non-append writer's file position.
return content.replace(/\0/g, '');
}
function pad(value) { function pad(value) {
return value < 10 ? '0' + value : value; return value < 10 ? '0' + value : value;
} }
@@ -35,120 +58,185 @@ function localizeTimestamps(content) {
} }
return view.extend({ return view.extend({
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() { load: function() {
return callRuntimeLog(0).catch(function() { return callRuntimeLog(0, '').catch(function() {
return { enabled: false, localtime: false, content: '', offset: 0 }; return { error: true, localtime: false, content: '', offset: 0 };
}); });
}, },
render: function(initialLog) { render: function(initialLog) {
var sharedStatus = status.render(); var m = new form.Map('AdGuardHome');
var content = initialLog.content || ''; var s = m.section(form.TypedSection, 'AdGuardHome');
var offset = initialLog.offset || 0; s.anonymous = true;
var enabled = initialLog.enabled; s.addremove = false;
var localtime = initialLog.localtime; var o;
var reverse = true;
var convertTime = localtime;
var logArea = E('textarea', {
'class': 'cbi-input-textarea',
'rows': 32,
'readonly': 'readonly',
'style': 'width:100%;box-sizing:border-box;'
});
var toggleStyle = 'margin:0 0.35em 0 0;height:auto;flex:none;';
var reverseToggle = E('input', { 'type': 'checkbox', 'checked': true, 'style': toggleStyle });
var timeToggle = E('input', { 'type': 'checkbox', 'checked': true, 'style': toggleStyle });
var reverseLabel = E('label', { 'style': 'display:inline-flex;align-items:center;' }, [
reverseToggle, _('reverse')
]);
var timeLabel = E('label', { 'style': localtime ? 'display:inline-flex;align-items:center;margin-left:0.8em;' : 'display:none;' }, [
timeToggle, _('localtime')
]);
var message = E('span', { 'style': 'margin-left:0.8em;' }, '');
function renderLog() { o = s.option(
var displayed = convertTime ? localizeTimestamps(content) : content; form.Value,
logArea.value = reverse ? displayed.split('\n').reverse().join('\n') : displayed; 'logfile',
} _('Runtime log file')
);
o.rmempty = true;
function downloadLog() { return m.render().then(function(settingsNode) {
var now = new Date(); var sharedStatus = status.render();
var link = document.createElement('a'); var logLimit = 262144;
link.download = 'AdGuardHome' + (now.getMonth() + 1) + '-' + now.getDate() + '-' + var content = decodeLog(initialLog).slice(-logLimit);
pad(now.getHours()) + '_' + pad(now.getMinutes()) + '.log'; var identity = initialLog.identity || '';
link.href = URL.createObjectURL(new Blob([logArea.value], { type: 'text/plain' })); var generation = 0;
link.click(); var offset = initialLog.offset || 0;
URL.revokeObjectURL(link.href); var enabled = initialLog.enabled;
} var localtime = initialLog.localtime;
var reverse = true;
var convertTime = localtime;
var logArea = E('textarea', {
'class': 'cbi-input-textarea',
'rows': 16,
'readonly': 'readonly',
'style': 'width:100%;box-sizing:border-box;'
});
var toggleStyle = 'margin:0 0.35em 0 0;height:auto;flex:none;';
var reverseToggle = E('input', { 'type': 'checkbox', 'checked': true, 'style': toggleStyle });
var timeToggle = E('input', { 'type': 'checkbox', 'checked': true, 'style': toggleStyle });
var reverseLabel = E('label', { 'style': 'display:inline-flex;align-items:center;' }, [
reverseToggle, _('reverse')
]);
var timeLabel = E('label', { 'style': localtime ? 'display:inline-flex;align-items:center;' : 'display:none;' }, [
timeToggle, _('localtime')
]);
var verboseToggle = E('input', {
'type': 'checkbox', 'style': toggleStyle
});
// Boolean HTML attributes are true whenever present, even with a false value.
verboseToggle.checked = m.data.get('AdGuardHome', 'AdGuardHome', 'verbose') === '1';
verboseToggle.disabled = !!m.readonly;
var verboseMessage = E('span', { 'role': 'status' }, '');
var verboseLabel = E('label', { 'style': 'display:inline-flex;align-items:center;' }, [
verboseToggle, _('Verbose log')
]);
verboseToggle.addEventListener('change', function() {
var requested = verboseToggle.checked;
verboseToggle.disabled = true;
verboseMessage.textContent = _('Applying verbose logging…');
callSetVerboseLog(requested).then(function(result) {
if (!result.saved)
throw new Error('save failed');
verboseMessage.textContent = result.applied ? '' : _('Saved, but service reload failed; check the log');
}).catch(function() {
verboseToggle.checked = !requested;
verboseMessage.textContent = _('Unable to apply verbose logging');
}).then(function() {
verboseToggle.disabled = false;
});
});
var message = E('span', {}, '');
var clearButton = E('button', { function renderLog() {
'class': 'btn cbi-button cbi-button-apply', 'type': 'button' var displayed = convertTime ? localizeTimestamps(content) : content;
}, _('dellog')); logArea.value = reverse ? displayed.split('\n').reverse().join('\n') : displayed;
var downloadButton = E('button', { }
'class': 'btn cbi-button', 'type': 'button', 'style': 'margin-left:0.6em;'
}, _('download log')); function downloadLog() {
reverseToggle.addEventListener('change', function() { var now = new Date();
reverse = reverseToggle.checked; var link = document.createElement('a');
renderLog(); link.download = 'AdGuardHome' + (now.getMonth() + 1) + '-' + now.getDate() + '-' +
}); pad(now.getHours()) + '_' + pad(now.getMinutes()) + '.log';
timeToggle.addEventListener('change', function() { link.href = URL.createObjectURL(new Blob([logArea.value], { type: 'text/plain' }));
convertTime = timeToggle.checked; link.click();
renderLog(); URL.revokeObjectURL(link.href);
}); }
clearButton.addEventListener('click', function() {
clearButton.disabled = true; var clearButton = E('button', {
callClearRuntimeLog().then(function(result) { 'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
if (!result.cleared) }, _('dellog'));
throw new Error(result.reason || 'clear failed'); var downloadButton = E('button', {
content = ''; 'class': 'btn cbi-button', 'type': 'button'
offset = 0; }, _('download log'));
message.textContent = ''; reverseToggle.addEventListener('change', function() {
reverse = reverseToggle.checked;
renderLog(); renderLog();
}).catch(function() {
message.textContent = _('Status unavailable');
}).then(function() {
clearButton.disabled = false;
}); });
}); timeToggle.addEventListener('change', function() {
downloadButton.addEventListener('click', downloadLog); convertTime = timeToggle.checked;
renderLog();
var page = E('div', { 'class': 'cbi-map' }, [ });
sharedStatus.node, clearButton.addEventListener('click', function() {
logArea, clearButton.disabled = true;
E('div', { 'style': 'display:flex;align-items:center;padding:0.6em;' }, [ generation++;
reverseLabel, timeLabel callClearRuntimeLog().then(function(result) {
]), if (!result.cleared)
E('div', { 'style': 'padding:0 0.6em 0.6em;' }, [clearButton, downloadButton, message]) throw new Error(result.reason || 'clear failed');
]);
if (!enabled)
message.textContent = _('Please add log path in config to enable log');
renderLog();
poll.add(function() {
return callRuntimeLog(offset).then(function(result) {
enabled = result.enabled;
if (!enabled) {
message.textContent = _('Please add log path in config to enable log');
return;
}
message.textContent = '';
if (result.offset < offset)
content = ''; content = '';
offset = result.offset; offset = 0;
if (result.content) { identity = '';
content += result.content; generation++;
message.textContent = '';
renderLog(); renderLog();
} }).catch(function() {
}).catch(function() { message.textContent = _('Status unavailable');
message.textContent = _('Status unavailable'); }).then(function() {
clearButton.disabled = false;
});
}); });
}, 3); downloadButton.addEventListener('click', downloadLog);
return page; var settingsCard = settingsNode.querySelector('.cbi-section');
settingsCard.classList.add('agh-compact-card', 'agh-log-view');
var settingsFields = settingsCard.querySelector('.cbi-section-node');
var logFileField = settingsFields.querySelector('.cbi-value');
var logPath = logFileField.querySelector('input[type="text"]');
if (logPath)
logPath.title = _('If set to "syslog", write to system log; if empty, disable file logging');
var logControls = E('div', { 'class': 'agh-log-controls' }, [
E('div', { 'class': 'agh-toolbar agh-log-toggles' }, [
reverseLabel, timeLabel, verboseLabel, verboseMessage, message
]),
E('div', { 'class': 'agh-log-actions' }, [
E('div', { 'class': 'agh-toolbar' }, [clearButton, downloadButton]),
logFileField
])
]);
// Keep the form field inside its map, outside the theme's section layout.
settingsFields.hidden = true;
settingsCard.insertBefore(logControls, settingsFields);
settingsCard.insertBefore(logArea, logControls);
var page = E('div', {}, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('view/adguardhome/compact.css') }),
sharedStatus.node,
settingsNode
]);
if (initialLog.error)
message.textContent = _('Unable to read runtime log');
else if (!enabled)
message.textContent = _('Please add log path in config to enable log');
renderLog();
poll.add(function() {
if (clearButton.disabled)
return Promise.resolve();
var requestGeneration = generation;
return callRuntimeLog(offset, identity).then(function(result) {
if (requestGeneration !== generation)
return;
enabled = result.enabled;
if (!enabled) {
message.textContent = _('Please add log path in config to enable log');
return;
}
message.textContent = '';
if (result.reset || result.offset < offset)
content = '';
offset = result.offset;
identity = result.identity || '';
content = (content + decodeLog(result)).slice(-logLimit);
renderLog();
}).catch(function() {
message.textContent = _('Status unavailable');
});
}, 3);
return page;
});
} }
}); });
@@ -102,11 +102,11 @@ return view.extend({
var message = E('span', { 'style': 'margin-left:0.8em;' }, ''); var message = E('span', { 'style': 'margin-left:0.8em;' }, '');
var configArea = E('textarea', { var configArea = E('textarea', {
'class': 'cbi-input-textarea', 'class': 'cbi-input-textarea',
'rows': 40, 'rows': 20,
'wrap': 'off', 'wrap': 'off',
'style': 'width:100%;box-sizing:border-box;font-family:monospace;' 'style': 'width:100%;box-sizing:border-box;font-family:monospace;'
}); });
var editorWrap = E('div', { 'style': 'padding:0.6em;' }, configArea); var editorWrap = E('div', { 'class': 'agh-editor-wrap' }, configArea);
var templateButton = E('button', { var templateButton = E('button', {
'class': 'btn cbi-button', 'type': 'button' 'class': 'btn cbi-button', 'type': 'button'
}, _('Use template')); }, _('Use template'));
@@ -118,22 +118,15 @@ return view.extend({
}, _('Save & Apply')); }, _('Save & Apply'));
var passwordInput = E('input', { var passwordInput = E('input', {
'class': 'cbi-input-password', 'type': 'password', 'class': 'cbi-input-password', 'type': 'password',
'autocomplete': 'new-password', 'spellcheck': 'false' 'id': 'agh-web-password', 'autocomplete': 'new-password', 'spellcheck': 'false'
}); });
var passwordButton = E('button', { var passwordButton = E('button', {
'class': 'btn cbi-button cbi-button-apply', 'type': 'button' 'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
}, _('Change password')); }, _('Change password'));
var passwordMessage = E('span', { 'style': 'margin-left:0.8em;' }, ''); var passwordMessage = E('span', { 'style': 'margin-left:0.8em;' }, '');
var passwordPanel = E('div', { 'class': 'cbi-section' }, [ var passwordPanel = E('div', { 'class': 'agh-toolbar agh-password-controls' }, [
E('h3', {}, _('Change browser management password')), E('label', { 'for': 'agh-web-password' }, _('Change browser management password')),
E('div', { 'class': 'cbi-value', 'style': 'padding:0.6em 0;' }, [ passwordInput, passwordButton, passwordMessage
E('label', { 'class': 'cbi-value-title' }, _('New password')),
E('div', { 'class': 'cbi-value-field' }, [
passwordInput,
E('span', { 'style': 'margin-left:0.6em;' }, passwordButton),
passwordMessage
])
])
]); ]);
function showConfig() { function showConfig() {
@@ -207,14 +200,15 @@ return view.extend({
showConfig(); showConfig();
var page = E('div', {}, [ var page = E('div', {}, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('view/adguardhome/compact.css') }),
sharedStatus.node, sharedStatus.node,
E('div', { 'class': 'cbi-section' }, [ E('div', { 'class': 'cbi-section agh-compact-card agh-manual-card' }, [
E('div', { 'style': 'padding:0.6em;' }, [ editorWrap,
templateButton, reloadButton, saveButton, message E('div', { 'class': 'agh-manual-toolbar' }, [
]), passwordPanel,
editorWrap E('div', { 'class': 'agh-toolbar agh-config-actions' }, [message, templateButton, reloadButton, saveButton])
]), ])
passwordPanel ])
]); ]);
loadCodeMirror().then(function(CodeMirror) { loadCodeMirror().then(function(CodeMirror) {
editor = CodeMirror.fromTextArea(configArea, { editor = CodeMirror.fromTextArea(configArea, {
@@ -226,6 +220,8 @@ return view.extend({
}); });
editor.getWrapperElement().style.fontSize = '15px'; editor.getWrapperElement().style.fontSize = '15px';
editor.getWrapperElement().style.lineHeight = '1.5'; editor.getWrapperElement().style.lineHeight = '1.5';
// Twenty lines at 1.5em, plus CodeMirror's vertical content padding.
editor.setSize(null, 'calc(30em + 8px)');
editor.refresh(); editor.refresh();
showConfig(); showConfig();
}).catch(function() { }).catch(function() {
@@ -233,4 +229,4 @@ return view.extend({
}); });
return page; return page;
} }
}); });
@@ -2,8 +2,6 @@
'require baseclass'; 'require baseclass';
'require poll'; 'require poll';
'require rpc'; 'require rpc';
'require fs';
'require uci';
var callCoreInfo = rpc.declare({ var callCoreInfo = rpc.declare({
object: 'luci.adguardhome', object: 'luci.adguardhome',
@@ -20,15 +18,20 @@ var callServiceList = rpc.declare({
reject: true reject: true
}); });
var loadConfigPromise; var callRedirectStatus = rpc.declare({
object: 'luci.adguardhome',
method: 'getRedirectStatus',
expect: { '': {} },
reject: true
});
function getConfiguredBinPath() { function modeLabel(mode) {
if (!loadConfigPromise) return {
loadConfigPromise = uci.load('AdGuardHome').catch(function() { return null; }); none: _('Do not handle DNS'),
'dnsmasq-upstream': _('Run as dnsmasq upstream server'),
return loadConfigPromise.then(function() { redirect: _('Forward port 53 requests through the firewall'),
return uci.get('AdGuardHome', 'AdGuardHome', 'binpath') || '/usr/bin/AdGuardHome/AdGuardHome'; exchange: _('Use port 53 instead of dnsmasq')
}); }[mode] || _('Status unavailable');
} }
function coreIsRunning(services, binpath) { function coreIsRunning(services, binpath) {
@@ -57,9 +60,9 @@ return baseclass.extend({
function setBadge(badge, label, state) { function setBadge(badge, label, state) {
badge.textContent = label; badge.textContent = label;
badge.style.backgroundColor = state === true ? '#dcfce7' : state === false ? '#fee2e2' : '#e5e7eb'; badge.style.backgroundColor = state === 'pending' ? '#fef3c7' : state === true ? '#dcfce7' : state === false ? '#fee2e2' : '#e5e7eb';
badge.style.color = state === true ? '#14532d' : state === false ? '#7f1d1d' : '#374151'; badge.style.color = state === 'pending' ? '#92400e' : state === true ? '#14532d' : state === false ? '#7f1d1d' : '#374151';
badge.style.borderColor = state === true ? '#86efac' : state === false ? '#fca5a5' : '#d1d5db'; badge.style.borderColor = state === 'pending' ? '#fcd34d' : state === true ? '#86efac' : state === false ? '#fca5a5' : '#d1d5db';
} }
function refreshCore() { function refreshCore() {
@@ -77,25 +80,125 @@ return baseclass.extend({
}); });
} }
refreshCore(); var storageKey = 'adguardhome.pendingSwitch';
poll.add(function() { var pending = null;
var redirectFlag = fs.read('/var/run/AdGredir').catch(function(error) { var generation = 0;
if (error.name === 'NotFoundError') var stable = 0;
return ''; var nextRefresh = 0;
throw error; var inFlight = null;
}); try {
var saved = JSON.parse(sessionStorage.getItem(storageKey));
if (saved && typeof saved.mode === 'string' && typeof saved.enabled === 'boolean' &&
typeof saved.started === 'number')
pending = saved;
} catch (e) {}
return Promise.all([getConfiguredBinPath(), callServiceList('AdGuardHome', true), redirectFlag]).then(function(results) { function remember() {
var isRunning = coreIsRunning(results[1], results[0]); try {
setBadge(serviceBadge, 'AdGuardHome: ' + (isRunning ? _('RUNNING') : _('NOT RUNNING')), isRunning); if (pending)
setBadge(redirectBadge, _('Redirect') + ': ' + (results[2].trim() === '1' ? _('Redirected') : _('Not redirect')), sessionStorage.setItem(storageKey, JSON.stringify(pending));
results[2].trim() === '1'); else
sessionStorage.removeItem(storageKey);
} catch (e) {}
}
function showPending(running) {
var label = Date.now() - pending.started >= 30000
? _('Switch not completed; check the log') : _('Switching');
if (typeof running === 'boolean')
setBadge(serviceBadge, running ? _('RUNNING') : _('NOT RUNNING'), running);
else
setBadge(serviceBadge, _('Checking service…'), 'pending');
setBadge(redirectBadge, label, 'pending');
}
function beginSwitch(target) {
generation++;
stable = 0;
nextRefresh = 0;
pending = { mode: target.mode, enabled: target.enabled, started: Date.now() };
remember();
showPending();
}
function cancelSwitch() {
generation++;
pending = null;
stable = 0;
nextRefresh = 0;
remember();
return refreshStatus();
}
function matches(info, running, target) {
return !info.busy && info.mode === target.mode && info.enabled === target.enabled &&
info.applied_enabled === target.enabled && running === target.enabled &&
(!target.enabled || info.applied_mode === target.mode) &&
info.redirected === (target.enabled && target.mode !== 'none');
}
function refreshStatus() {
if (inFlight)
return inFlight;
var currentGeneration = generation;
var requestedAt = Date.now();
inFlight = Promise.all([callRedirectStatus(), callServiceList('AdGuardHome', true)]).then(function(results) {
if (currentGeneration !== generation)
return;
var info = results[0];
var running = coreIsRunning(results[1], info.binpath);
// Recover switching feedback after navigation or an apply from another page.
if (!pending && (info.busy || info.mode !== info.applied_mode || info.enabled !== info.applied_enabled))
beginSwitch(info);
if (pending) {
stable = matches(info, running, pending) ? stable + 1 : 0;
// Two consecutive samples avoid declaring a short-lived process healthy.
if (stable < 2) {
showPending(running);
return;
}
pending = null;
remember();
}
setBadge(serviceBadge, running ? _('RUNNING') : _('NOT RUNNING'), running);
var active = info.redirected && running && info.mode === info.applied_mode;
var label = modeLabel(info.mode);
if (!info.enabled)
label += ' (' + _('Disabled') + ')';
else if (info.mode !== 'none' && !active)
label += ' (' + _('Not redirect') + ')';
setBadge(redirectBadge, label,
!info.enabled || info.mode === 'none' ? null : active);
}).catch(function() { }).catch(function() {
setBadge(serviceBadge, _('Status unavailable'), null); if (currentGeneration !== generation)
setBadge(redirectBadge, _('Status unavailable'), null); return;
stable = 0;
if (pending) {
showPending();
setBadge(serviceBadge, _('Status unavailable'), null);
setBadge(redirectBadge, _('Unable to read switch status') + ' (' + modeLabel(pending.mode) + ')', null);
} else {
setBadge(serviceBadge, _('Status unavailable'), null);
setBadge(redirectBadge, _('Status unavailable'), null);
}
}).then(function() {
nextRefresh = requestedAt + (pending ? 1000 : 3000);
inFlight = null;
}); });
}, 3); return inFlight;
}
return { node: node, refresh: refreshCore }; if (pending)
showPending();
refreshCore();
refreshStatus();
poll.add(function() {
if (Date.now() >= nextRefresh)
return refreshStatus();
}, 1);
return { node: node, refresh: function() {
return Promise.all([refreshCore(), refreshStatus()]);
}, beginSwitch: beginSwitch, cancelSwitch: cancelSwitch };
} }
}); });
+1 -1
View File
@@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic
# DD = day (01 = 1st) # DD = day (01 = 1st)
# NN = daily release sequence (01 = first release of the day, 02 = second, ...) # NN = daily release sequence (01 = first release of the day, 02 = second, ...)
# Example below: 26.10.0101 = October 1, 2026, 1st release of the day # Example below: 26.10.0101 = October 1, 2026, 1st release of the day
PKG_VERSION:=26.10.2202 PKG_VERSION:=26.10.2501
# PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump) # PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump)
# 1 = Lua version (lua branch) → tag: 26.10.0101 # 1 = Lua version (lua branch) → tag: 26.10.0101
# 2 = JS version (main branch) → tag: 26.10.0101-js # 2 = JS version (main branch) → tag: 26.10.0101-js
@@ -443,7 +443,7 @@ return view.extend({
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_firmware_version + ' ] ') ? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_firmware_version + ' ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.'))); : E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
dom.content(verPlugin, s && s.current_plugin_version dom.content(verPlugin, s && s.current_plugin_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_plugin_version + ' ] ') ? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_plugin_version + '-js ] ')
: E('span', { class: 'amlogic-status-err' }, _('Invalid value.'))); : E('span', { class: 'amlogic-status-err' }, _('Invalid value.')));
dom.content(verKernel, s && s.current_kernel_version dom.content(verKernel, s && s.current_kernel_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_kernel_version + ' ] ') ? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_kernel_version + ' ] ')
+3 -3
View File
@@ -5,14 +5,14 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-honk PKG_NAME:=luci-app-honk
PKG_VERSION:=2026.10.24 PKG_VERSION:=2026.10.25
PKG_RELEASE:=1 PKG_RELEASE:=1
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
PKG_SOURCE:=doona-369aee8f-f6612106faf8.tar.gz PKG_SOURCE:=doona-4354c99b-e60ed58ef40e.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-honk/releases/download/staging PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-honk/releases/download/staging
PKG_SOURCE_SUBDIR:=doona PKG_SOURCE_SUBDIR:=doona
PKG_HASH:=f6612106faf8897178f91d464e0ba5f99eb46220eba0d984518104dd87fdcf57 PKG_HASH:=e60ed58ef40e849269219de38dddf6974f1dbd2915675390d4b748eb7f78db2b
-include $(CURDIR)/generated-doona-stage.mk -include $(CURDIR)/generated-doona-stage.mk
@@ -5,6 +5,7 @@
'require poll'; 'require poll';
'require view'; 'require view';
'require view.honk.rpc as honk'; 'require view.honk.rpc as honk';
'require uci';
function getNetworkNames(networks) { function getNetworkNames(networks) {
if (!networks) if (!networks)
@@ -81,9 +82,10 @@ return view.extend({
load: function() { load: function() {
return Promise.all([ return Promise.all([
honk.status().then(function(status) { return { status: status }; }).catch(function(error) { return { error: error.message || String(error) }; }), honk.status().then(function(status) { return { status: status }; }).catch(function(error) { return { error: error.message || String(error) }; }),
network.getNetworks().then(getNetworkNames).then(function(networks) { return { networks: networks }; }).catch(function() { return { networks: [] }; }) network.getNetworks().then(getNetworkNames).then(function(networks) { return { networks: networks }; }).catch(function() { return { networks: [] }; }),
uci.load('honk').then(function() { return { clash_secret: uci.get('honk', 'main', 'clash_api_secret') || '' }; }).catch(function() { return { clash_secret: '' }; })
]).then(function(results) { ]).then(function(results) {
return Object.assign({}, results[0], results[1]); return Object.assign({}, results[0], results[1], results[2]);
}); });
}, },
@@ -94,6 +96,7 @@ return view.extend({
const actionMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, ''); const actionMessage = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const setupArea = E('div'); const setupArea = E('div');
let currentStatus = data.status; let currentStatus = data.status;
const clashSecret = data.clash_secret || '';
let busy = false; let busy = false;
let setupBusy = false; let setupBusy = false;
let settingsBusy = false; let settingsBusy = false;
@@ -128,7 +131,7 @@ return view.extend({
'type': webUi ? null : 'button', 'type': webUi ? null : 'button',
'disabled': webUi ? null : true 'disabled': webUi ? null : true
}, _('Open Doona')); }, _('Open Doona'));
const clashUi = honk.localClashUi(status); const clashUi = honk.localClashUi(status, clashSecret);
const openClash = E(clashUi ? 'a' : 'button', { const openClash = E(clashUi ? 'a' : 'button', {
'class': 'cbi-button cbi-button-action', 'class': 'cbi-button cbi-button-action',
'href': clashUi || null, 'href': clashUi || null,
@@ -211,7 +214,8 @@ return view.extend({
const bootEnabledInput = E('input', { 'type': 'checkbox', 'name': 'boot_enabled', 'id': 'honk-boot-enabled' }); const bootEnabledInput = E('input', { 'type': 'checkbox', 'name': 'boot_enabled', 'id': 'honk-boot-enabled' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' }); const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' }); const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password' }); const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, ''); const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save service settings')); const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save service settings'));
@@ -235,7 +239,7 @@ return view.extend({
form.appendChild(field(_('Start Honk at boot'), bootEnabledInput)); form.appendChild(field(_('Start Honk at boot'), bootEnabledInput));
form.appendChild(field(_('Enable Clash API and Zashboard'), clashEnabledInput)); form.appendChild(field(_('Enable Clash API and Zashboard'), clashEnabledInput));
form.appendChild(field(_('Clash API port'), clashPortInput)); form.appendChild(field(_('Clash API port'), clashPortInput));
form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 16–128 letters, numbers, underscores or hyphens.'))); form.appendChild(field(_('Clash API secret'), clashSecretInput, status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it.') : _('Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.'))); form.appendChild(E('p', { 'class': 'honk-note' }, _('Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting.')));
form.appendChild(E('div', { 'class': 'cbi-page-actions' }, save)); form.appendChild(E('div', { 'class': 'cbi-page-actions' }, save));
form.appendChild(message); form.appendChild(message);
@@ -248,10 +252,20 @@ return view.extend({
message.textContent = _('Choose a LAN network and a port from 1024 to 65535.'); message.textContent = _('Choose a LAN network and a port from 1024 to 65535.');
return; return;
} }
if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535 || if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
clashEnabledInput.checked && (clashPort === port || !status.clash_api_secret_set && !clashSecret || message.textContent = _('Clash API port must be between 1024 and 65535.');
!!clashSecret && !/^[A-Za-z0-9_-]{16,128}$/.test(clashSecret))) { return;
message.textContent = _('Choose a different Clash API port and enter a valid secret.'); }
if (clashEnabledInput.checked && clashPort === port) {
message.textContent = _('The Clash API port must differ from the listen port.');
return;
}
if (clashEnabledInput.checked && !status.clash_api_secret_set && !clashSecret) {
message.textContent = _('A secret is required when enabling Clash API.');
return;
}
if (clashEnabledInput.checked && clashSecret && !/^[A-Za-z0-9._-]{6,128}$/.test(clashSecret)) {
message.textContent = _('The secret must be 6-128 letters, numbers, dots, underscores or hyphens.');
return; return;
} }
@@ -122,10 +122,27 @@ function localWebUi(status) {
} }
} }
function localClashUi(status) { function localClashUi(status, secret) {
if (!status || !status.clash_api_enabled || !status.clash_api_ready || !status.clash_ui_ready) if (!status || !status.clash_api_enabled || !status.clash_api_ready || !status.clash_ui_ready)
return null; return null;
return localWebUi(Object.assign({}, status, { api_url: status.clash_api_url, api_ready: true })); const base = localWebUi(Object.assign({}, status, { api_url: status.clash_api_url, api_ready: true }));
if (!base)
return null;
try {
const url = new URL(status.clash_api_url);
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const port = status.clash_api_port || url.port;
const query = [
'host=' + encodeURIComponent(host),
'hostname=' + encodeURIComponent(host),
'port=' + encodeURIComponent(port),
'secret=' + encodeURIComponent(secret || '')
].join('&');
return base + '?' + query + '#/proxies';
}
catch (e) {
return null;
}
} }
function tokenMessage(token) { function tokenMessage(token) {
@@ -205,7 +222,7 @@ function tokenMessage(token) {
lan_network_unavailable: _('The selected LAN network is unavailable.'), lan_network_unavailable: _('The selected LAN network is unavailable.'),
listen_port_occupied: _('The listen port is already in use.'), listen_port_occupied: _('The listen port is already in use.'),
clash_api_port_occupied: _('The Clash API port is already in use.'), clash_api_port_occupied: _('The Clash API port is already in use.'),
invalid_clash_api_settings: _('Enter a different Clash API port and a secret of 16–128 letters, numbers, underscores or hyphens.'), invalid_clash_api_settings: _('Enter a different Clash API port and a secret of 6–128 letters, numbers, dots, underscores or hyphens.'),
invalid_listen_port: _('The listen port is invalid.'), invalid_listen_port: _('The listen port is invalid.'),
initialize_before_enabling_service: _('Complete initial setup before enabling Honk.'), initialize_before_enabling_service: _('Complete initial setup before enabling Honk.'),
system_config_unavailable: _('Honk system configuration is unavailable.'), system_config_unavailable: _('Honk system configuration is unavailable.'),
@@ -51,7 +51,8 @@ return view.extend({
const bootEnabledInput = E('input', { 'type': 'checkbox', 'name': 'boot_enabled', 'id': 'honk-boot-enabled' }); const bootEnabledInput = E('input', { 'type': 'checkbox', 'name': 'boot_enabled', 'id': 'honk-boot-enabled' });
const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' }); const clashEnabledInput = E('input', { 'type': 'checkbox', 'name': 'clash_api_enabled', 'id': 'honk-clash-enabled' });
const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' }); const clashPortInput = E('input', { 'class': 'cbi-input-text', 'type': 'number', 'name': 'clash_api_port', 'id': 'honk-clash-port', 'min': '1024', 'max': '65535', 'step': '1' });
const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password' }); const clashSecretInput = E('input', { 'class': 'cbi-input-password', 'type': 'password', 'name': 'clash_api_secret', 'id': 'honk-clash-secret', 'autocomplete': 'new-password', 'readonly': 'readonly' });
clashSecretInput.addEventListener('focus', function() { clashSecretInput.removeAttribute('readonly'); });
const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, ''); const message = E('p', { 'class': 'honk-status-msg', 'role': 'status' }, '');
const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save settings')); const save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save settings'));
save.disabled = !!data.statusError; save.disabled = !!data.statusError;
@@ -81,7 +82,7 @@ return view.extend({
section.appendChild(field(_('Enable Clash API and Zashboard'), clashEnabledInput)); section.appendChild(field(_('Enable Clash API and Zashboard'), clashEnabledInput));
section.appendChild(field(_('Clash API port'), clashPortInput)); section.appendChild(field(_('Clash API port'), clashPortInput));
section.appendChild(field(_('Clash API secret'), clashSecretInput)); section.appendChild(field(_('Clash API secret'), clashSecretInput));
section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 16–128 letters, numbers, underscores or hyphens.'))); section.appendChild(E('p', { 'class': 'honk-note' }, data.status && data.status.clash_api_secret_set ? _('A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access.') : _('A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens.')));
section.appendChild(E('div', { 'class': 'cbi-page-actions' }, save)); section.appendChild(E('div', { 'class': 'cbi-page-actions' }, save));
section.appendChild(message); section.appendChild(message);
section.addEventListener('submit', function(ev) { section.addEventListener('submit', function(ev) {
@@ -93,10 +94,20 @@ return view.extend({
message.textContent = _('Choose a LAN network and a port from 1024 to 65535.'); message.textContent = _('Choose a LAN network and a port from 1024 to 65535.');
return; return;
} }
if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535 || if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
clashEnabledInput.checked && (clashPort === port || !(data.status && data.status.clash_api_secret_set) && !clashSecret || message.textContent = _('Clash API port must be between 1024 and 65535.');
!!clashSecret && !/^[A-Za-z0-9_-]{16,128}$/.test(clashSecret))) { return;
message.textContent = _('Choose a different Clash API port and enter a valid secret.'); }
if (clashEnabledInput.checked && clashPort === port) {
message.textContent = _('The Clash API port must differ from the listen port.');
return;
}
if (clashEnabledInput.checked && !(data.status && data.status.clash_api_secret_set) && !clashSecret) {
message.textContent = _('A secret is required when enabling Clash API.');
return;
}
if (clashEnabledInput.checked && clashSecret && !/^[A-Za-z0-9._-]{6,128}$/.test(clashSecret)) {
message.textContent = _('The secret must be 6-128 letters, numbers, dots, underscores or hyphens.');
return; return;
} }
+18 -6
View File
@@ -1281,8 +1281,8 @@ msgstr "Clash API 密钥"
msgid "A secret is set. Leave blank to keep it." msgid "A secret is set. Leave blank to keep it."
msgstr "已设置密钥,留空则保持原值。" msgstr "已设置密钥,留空则保持原值。"
msgid "Required when enabling. Use 16–128 letters, numbers, underscores or hyphens." msgid "Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens."
msgstr "启用时必填,使用 16~128 位英文字母、数字、下划线或短横线。" msgstr "启用时必填,使用 6~128 位英文字母、数字、点、下划线或短横线。"
msgid "Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting." msgid "Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting."
msgstr "外网访问请使用现有 HTTPS 反向代理,并在 Zashboard 中填写密钥。" msgstr "外网访问请使用现有 HTTPS 反向代理,并在 Zashboard 中填写密钥。"
@@ -1293,11 +1293,23 @@ msgstr "请选择不同的 Clash API 端口并填写有效密钥。"
msgid "A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access." msgid "A secret is set. Leave blank to keep it. Use your existing HTTPS reverse proxy for external access."
msgstr "已设置密钥,留空则保持原值;外网访问请使用现有 HTTPS 反向代理。" msgstr "已设置密钥,留空则保持原值;外网访问请使用现有 HTTPS 反向代理。"
msgid "A secret is required when enabling Clash API. Use 16–128 letters, numbers, underscores or hyphens." msgid "A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens."
msgstr "启用 Clash API 时必须填写密钥,使用 16~128 位英文字母、数字、下划线或短横线。" msgstr "启用 Clash API 时必须填写密钥,使用 6~128 位英文字母、数字、点、下划线或短横线。"
msgid "The Clash API port is already in use." msgid "The Clash API port is already in use."
msgstr "Clash API 端口已被占用。" msgstr "Clash API 端口已被占用。"
msgid "Enter a different Clash API port and a secret of 16–128 letters, numbers, underscores or hyphens." msgid "Enter a different Clash API port and a secret of 6–128 letters, numbers, dots, underscores or hyphens."
msgstr "请设置不同的 Clash API 端口,以及 16~128 位英文字母、数字、下划线或短横线组成的密钥。" msgstr "请设置不同的 Clash API 端口,以及 6~128 位英文字母、数字、点、下划线或短横线组成的密钥。"
msgid "Clash API port must be between 1024 and 65535."
msgstr "Clash API 端口需要在 1024~65535 之间。"
msgid "The Clash API port must differ from the listen port."
msgstr "Clash API 端口不能和监听端口相同。"
msgid "A secret is required when enabling Clash API."
msgstr "启用 Clash API 时需要设置密钥。"
msgid "The secret must be 6-128 letters, numbers, dots, underscores or hyphens."
msgstr "密钥需要 6~128 位,只能包含字母、数字、点、下划线或短横线。"
@@ -741,6 +741,11 @@ Xray.outbounds = {
else else
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat", local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
dtls = "dtls", wireguard = "wireguard", dns = "dns"} dtls = "dtls", wireguard = "wireguard", dns = "dns"}
local s = { type = "mkcp-legacy" }
if server.seed and server.seed ~= "" then
s.settings = { value = server.seed }
end
udp[#udp+1] = s
if server.kcp_guise and server.kcp_guise ~= "none" then if server.kcp_guise and server.kcp_guise ~= "none" then
local g = { type = "mkcp-legacy" } local g = { type = "mkcp-legacy" }
g.settings = { header = map[server.kcp_guise] } g.settings = { header = map[server.kcp_guise] }
@@ -749,11 +754,6 @@ Xray.outbounds = {
end end
udp[#udp+1] = g udp[#udp+1] = g
end end
local s = { type = "mkcp-legacy" }
if server.seed and server.seed ~= "" then
s.settings = { value = server.seed }
end
udp[#udp+1] = s
end end
finalmask.udp = udp finalmask.udp = udp
elseif PT == "hysteria2" then elseif PT == "hysteria2" then
+5 -8
View File
@@ -30,8 +30,8 @@ LUCI_MINIFY_CSS:=0
# #
# JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the # JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the
# SDK, so LUCI_MINIFY_JS keeps luci.mk's jsmin. jsmin's one-character lookback does not know `n` or # SDK, so LUCI_MINIFY_JS keeps luci.mk's jsmin. jsmin's one-character lookback does not know `n` or
# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299); eslint's # `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299): wrap a
# `wrap-regex` bans the shape and tools/jsmin-verify.mjs proves the output token-identical. # regex literal there in parentheses.
# Apache-2.0, not a free choice: styles/base began as a fork of luci-theme-bootstrap's cascade.css, # Apache-2.0, not a free choice: styles/base began as a fork of luci-theme-bootstrap's cascade.css,
# the ucode templates derive from LuCI's and a few JS helpers are verbatim copies; the notices # the ucode templates derive from LuCI's and a few JS helpers are verbatim copies; the notices
@@ -119,16 +119,14 @@ define Build/Prepare/luci-theme-footstrap
# htdocs/ root/ ucode/; the copy is metadata only, luci.mk never installs it. # htdocs/ root/ ucode/; the copy is metadata only, luci.mk never installs it.
$(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/ $(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/
$(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css $(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css
# Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates and probes strips, which # Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates strip, which takes the
# take the comments it reads. # comments it reads.
$(SHELL) $(CURDIR)/mangle-tokens.sh \ $(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \ $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources $(PKG_BUILD_DIR)/ucode $(PKG_BUILD_DIR)/htdocs/luci-static/resources $(PKG_BUILD_DIR)/ucode
# Strips `{# … #}`, whole-line `/* … */` and the indentation from the templates (-27.2 KB of 47.2). # Strips `{# … #}`, whole-line `/* … */` and the indentation from the templates (-27.2 KB of 47.2).
# dev-sync.sh does not run this, so a router being debugged keeps its comments and indentation. # dev-sync.sh does not run this, so a router being debugged keeps its comments and indentation.
$(SHELL) $(CURDIR)/strip-build.sh templates $(PKG_BUILD_DIR)/ucode $(SHELL) $(CURDIR)/strip-build.sh templates $(PKG_BUILD_DIR)/ucode
# The gate-only `/* fs:probe */` exports: the functions stay, the export line goes.
$(SHELL) $(CURDIR)/strip-build.sh probes $(PKG_BUILD_DIR)/htdocs/luci-static/resources
# The same for the shell under root/ (about half of uci-defaults and the CGI are comments); # The same for the shell under root/ (about half of uci-defaults and the CGI are comments);
# whole-line `#` only. # whole-line `#` only.
$(SHELL) $(CURDIR)/strip-build.sh shell $(PKG_BUILD_DIR)/root $(SHELL) $(CURDIR)/strip-build.sh shell $(PKG_BUILD_DIR)/root
@@ -144,6 +142,5 @@ include $(TOPDIR)/feeds/luci/luci.mk
# Load-bearing, not boilerplate: include/scan.mk finds packages by GREPPING Makefiles for # Load-bearing, not boilerplate: include/scan.mk finds packages by GREPPING Makefiles for
# `call (Build/DefaultTargets|BuildPackage|KernelPackage)`. luci.mk calls BuildPackage, which the # `call (Build/DefaultTargets|BuildPackage|KernelPackage)`. luci.mk calls BuildPackage, which the
# grep cannot see, so without the literal line below the package gets no CONFIG_PACKAGE_* symbol and # grep cannot see, so without the literal line below the package gets no CONFIG_PACKAGE_* symbol and
# `make package/<name>/compile` fails with "No rule to make target". tools/scan-marker.sh re-derives # `make package/<name>/compile` fails with "No rule to make target".
# the grep and fails the build if it goes missing.
# call BuildPackage - OpenWrt buildroot signature # call BuildPackage - OpenWrt buildroot signature
+9 -11
View File
@@ -11,9 +11,7 @@ Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent, by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent,
the three status colours, the four surfaces — are **client** axes on the System → Footstrap page: the three status colours, the four surfaces — are **client** axes on the System → Footstrap page:
`localStorage` plus attributes and inline properties on `:root`, and nothing is written to the `localStorage` plus attributes and inline properties on `:root`, and nothing is written to the
router until "Save as default" is pressed. There are no per-layout or per-mode themes; every legacy router until "Save as default" is pressed. There are no per-layout or per-mode themes.
name (`FootstrapSidebar`, `FootstrapOnTop`, `…-dark`/`…-light`) is deleted by
`root/etc/uci-defaults/30_luci-theme-footstrap`.
There is one menu renderer too, `renderMainMenu` in `fs-chrome.js`. The top bar is its own markup, morphed by CSS There is one menu renderer too, `renderMainMenu` in `fs-chrome.js`. The top bar is its own markup, morphed by CSS
through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink. through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink.
@@ -26,11 +24,11 @@ and only re-arranges the stock overview sections.
``` ```
Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip, Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip,
version, po2lmo) version)
styles/ CSS SOURCE: layers tokens / base / theme / pages styles/ CSS SOURCE: layers tokens / base / theme / pages
build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css
mangle-tokens.sh shorten the private --fs-* names in a built sheet, then join it to one line mangle-tokens.sh shorten the private --fs-* names in a built sheet, then join it to one line
strip-build.sh build-tree strip, one mode each: templates, shell, probes, assets strip-build.sh build-tree strip, one mode each: templates, shell, assets
po/ translation catalogue (Weblate translates this) po/ translation catalogue (Weblate translates this)
ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/ ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/
htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfonts, and no
@@ -38,14 +36,14 @@ htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg — no webfo
is one the admin installs (repo: fonts/set-font.sh) is one the admin installs (repo: fonts/set-font.sh)
htdocs/luci-static/resources/ menu-footstrap.js (footer entry), menu-footstrap-common.js, htdocs/luci-static/resources/ menu-footstrap.js (footer entry), menu-footstrap-common.js,
fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search, fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search,
select,appearance,version,overview}.js select,appearance,assets,axes,version,overview}.js
root/etc/uci-defaults/ registration and legacy-name migration root/etc/uci-defaults/ registration and the upload symlinks
root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background and pattern uploads
``` ```
There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so There is no update checker: the theme is installed from the package feed (`install.sh` adds it), so
`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap `apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The System → Footstrap
tab shows the version it is running and makes no network call to do it. page shows the version it is running and makes no network call to do it.
**Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored. **Do not edit `cascade.css`** — it is generated by `build-css.sh` from `styles/` and is gitignored.
Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`. Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`.
@@ -62,6 +60,6 @@ ssh router 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit lu
ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back ssh router 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back
``` ```
Before pushing, run `npm run check` from the repository root. The rules and the traps are in Before pushing, see [`../docs/verifying.md`](../docs/verifying.md). The rules and the traps are in
[`../docs/conventions.md`](../docs/conventions.md); packaging is in [`../docs/conventions.md`](../docs/conventions.md); packaging is in
[`../docs/package.md`](../docs/package.md). [`../docs/package.md`](../docs/package.md).
@@ -632,8 +632,4 @@ function build() {
return baseclass.extend({ return baseclass.extend({
/* the dispatched page's whole render(), called by view/footstrap/appearance.js */ /* the dispatched page's whole render(), called by view/footstrap/appearance.js */
renderStandalone: () => Promise.resolve().then(build), renderStandalone: () => Promise.resolve().then(build),
/* test-only: the observer's lifetime is a navigation, which a test can fire and cannot reach
* through build() */
watchColours, /* fs:probe */
}); });
@@ -288,7 +288,4 @@ return baseclass.extend({
removePattern: PATTERN.remove, removePattern: PATTERN.remove,
uploadLoginBg: LOGIN_BG.upload, uploadLoginBg: LOGIN_BG.upload,
removeLoginBg: LOGIN_BG.remove, removeLoginBg: LOGIN_BG.remove,
/* test-only: the SVG cleaner and the CSS-escape decoder */
_sanitizeSvg, /* fs:probe */
_cssUnescape, /* fs:probe */
}); });
@@ -7,13 +7,12 @@
/* fs-axes — the Appearance axes and the Save-as-default machinery. /* fs-axes — the Appearance axes and the Save-as-default machinery.
* *
* Split out of fs-prefs.js because only `fs-appearance` (the form) and `fs-assets` (the uploads) * Split out of fs-prefs.js because only `fs-appearance` (the form) and `fs-assets` (the uploads)
* need it, so the router fetches it on the Appearance page and nowhere else: 6.6 KB off what every * need it, so the router fetches it on the Appearance page and nowhere else: 5.9 KB off what every
* page downloads. fs-prefs keeps what the chrome applies live: the localStorage wrappers and sd(), * page downloads. fs-prefs keeps what the chrome applies live: the localStorage wrappers and sd(),
* dark mode (guardDarkStamp defends it on every page), layout, rail and auto-collapse. * dark mode (guardDarkStamp defends it on every page), layout, rail and auto-collapse.
* *
* head.ut's pre-paint has already stamped every axis before the first frame, so nothing here is * head.ut's pre-paint has already stamped every axis before the first frame, so nothing here is
* needed to paint a page correctly, only to change one from the form. The axis list is read from the * needed to paint a page correctly, only to change one from the form. */
* whole resources directory, so an axis may live in a second file. */
/* An axis whose values are a list, with one of them stamped as nothing. /* An axis whose values are a list, with one of them stamped as nothing.
* *
@@ -50,8 +49,8 @@ function listAxis(key, attr, values, dflt, after) {
} }
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape /* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
* with a list of one — kept as its own name because the call is matched by name, and because * with a list of one — kept as its own name because "two-valued" is what most of these
* "two-valued" is what most of these axes are. */ * axes are. */
function enumAxis(key, attr, on, off) { function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off); return listAxis(key, attr, [ on ], off);
} }
@@ -657,13 +657,4 @@ return baseclass.extend({
rail: prefs.currentRail() rail: prefs.currentRail()
}); });
}, },
/* test-only: the arithmetic driven directly covers every combination of layout, rail and width
* without a browser */
columnWidth, /* fs:probe */
/* test-only: the active-leaf rule and the key-repeat guard, and the indicator observer without the
* rest of start() */
renderMainMenu, /* fs:probe */
wireIndicatorCounts, /* fs:probe */
}); });
@@ -6,17 +6,12 @@
* content needs (media = viewport, container = container). Three rules, each a bug that was hit: * content needs (media = viewport, container = container). Three rules, each a bug that was hit:
* rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on * rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on
* a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width * a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width
* (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. The reader's * (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. */
* place is docs/anchoring.md, "The corrections". */
/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming /* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
* without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */ * without it left every data table invisible. */
function armGate() { function armGate() {
if (!fittersEnabled()) return; if (!fittersEnabled()) return;
/* Written as the literal `dataset.fsFit`, never through a helper: the theme's own
* repository checks this file for exactly that spelling to prove the gate rule is still armed,
* and an indirection hides the write from it. The behaviour survives being factored out; the
* contract does not. */
document.documentElement.dataset.fsFit = '1'; document.documentElement.dataset.fsFit = '1';
} }
@@ -30,7 +25,7 @@ function runAll(list, what) {
} }
} }
/* dev switch — docs/anchoring.md, "The dev switches" */ /* dev switch */
function fittersEnabled() { function fittersEnabled() {
try { return localStorage.getItem('fsFit') !== 'off'; } try { return localStorage.getItem('fsFit') !== 'off'; }
catch (e) { return true; } catch (e) { return true; }
@@ -49,7 +44,7 @@ function run(records) {
* where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of * where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
* growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a * growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
* table's body — the third was missing and a poll emptying it took 58px from under the reader on * table's body — the third was missing and a poll emptying it took 58px from under the reader on
* 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */ * 24.10's Overview. */
const SHRINKS = '.cbi-section > div, .table'; const SHRINKS = '.cbi-section > div, .table';
const FLOORED = '[data-fs-floor]'; const FLOORED = '[data-fs-floor]';
@@ -88,14 +83,14 @@ function holdFloor(records) {
dirty.forEach((box) => { box.style.minHeight = ''; }); dirty.forEach((box) => { box.style.minHeight = ''; });
/* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made /* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made
* the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills * the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills
* corrected against 12 of 12. docs/anchoring-log.md, "The floor is written half a pixel too tall". */ * corrected against 12 of 12. */
const hs = dirty.map((box) => box.getBoundingClientRect().height); const hs = dirty.map((box) => box.getBoundingClientRect().height);
dirty.forEach((box, i) => { dirty.forEach((box, i) => {
if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); } if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); }
else box.removeAttribute('data-fs-floor'); else box.removeAttribute('data-fs-floor');
}); });
/* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the /* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the
* document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */ * document lost at its bottom. */
const landed = scrollTop(); const landed = scrollTop();
if (landed >= at) return; if (landed >= at) return;
if (page.scrollHeight >= tall) writeOffset(at); else _clampedTo = landed; if (page.scrollHeight >= tall) writeOffset(at); else _clampedTo = landed;
@@ -104,8 +99,7 @@ function holdFloor(records) {
/* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page /* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page
* long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the * long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the
* page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px — * page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px —
* one pixel of rounding per frame, the floor — at 250ms and above. * one pixel of rounding per frame, the floor — at 250ms and above. */
* docs/anchoring.md, "Is the page moving: `scrolling()`". */
const SCROLL_IDLE = 400; const SCROLL_IDLE = 400;
let _deferred = false; let _deferred = false;
function deferMeasurement() { _deferred = true; } function deferMeasurement() { _deferred = true; }
@@ -120,7 +114,7 @@ function sawOwnWrite(y) {
return true; return true;
} }
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()` /* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
* keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */ * keeps answering for the whole theme. */
let _clampedTo = null; let _clampedTo = null;
function sawClamp() { function sawClamp() {
if (scrollTop() === _clampedTo) return true; if (scrollTop() === _clampedTo) return true;
@@ -134,7 +128,7 @@ function writeOffset(value) {
} }
/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the /* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
* stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */ * stylesheet decides it. */
let _scroller = null, _scrollerAt = -1, _scrollerKey = null; let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
function layoutKey() { function layoutKey() {
const root = document.documentElement; const root = document.documentElement;
@@ -168,16 +162,16 @@ function sampleMotion() {
if (scrolling()) { requestAnimationFrame(sampleMotion); return; } if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false; _sampling = false;
/* The offset's own response to the write below, not a reference element's drift: one re-established /* The offset's own response to the write below, not a reference element's drift: one re-established
* on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */ * on wrong ground shows none. */
const target = _deferredFloor; /* Only a box wholly above the viewport moves the reader when it gives its floor up. */
_deferredFloor = null; const floorsAbove = [..._deferredFloors].filter((b) => b.isConnected && b.getBoundingClientRect().bottom <= 0)
const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0; .map((b) => [ b, parseFloat(b.style.minHeight) || 0 ]);
_deferredFloors.clear();
const offsetBefore = scrollTop(); const offsetBefore = scrollTop();
holdFloor(); holdFloor();
if (target && target.isConnected) { let shrink = 0;
const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0); for (const [ b, h ] of floorsAbove) shrink += h - (parseFloat(b.style.minHeight) || 0);
if (shrink > 1) settleDeferredFloor(offsetBefore, shrink); if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
}
rememberRest(); rememberRest();
if (_deferred) { if (_deferred) {
_deferred = false; _deferred = false;
@@ -202,9 +196,16 @@ function noteUser() {
noteMotion(); noteMotion();
} }
/* Read on the next frame, never in the scroll event: a layout forced while WebKit dispatches one
* skips its anchoring adjustment and leaves the scroller marked as queued, so the engine anchors
* nothing for the rest of the document's life (ScrollAnchoringController::
* adjustScrollPositionForAnchoring returns before it clears the mark). Any sticky or fixed box —
* `.th`, `#modal_overlay`, the bar — leaves layout dirty there, so one `scrollY` read is enough. */
const noteScroll = frame(noteMotion);
(function watchMotion() { (function watchMotion() {
const opts = { passive: true, capture: true }; const opts = { passive: true, capture: true };
window.addEventListener('scroll', noteMotion, opts); window.addEventListener('scroll', noteScroll, opts);
for (const name of [ 'wheel', 'touchmove' ]) for (const name of [ 'wheel', 'touchmove' ])
window.addEventListener(name, noteUser, opts); window.addEventListener(name, noteUser, opts);
for (const name of [ 'mousedown', 'keydown', 'touchstart' ]) for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
@@ -222,7 +223,7 @@ function frame(fn) {
const schedule = frame(run); const schedule = frame(run);
/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only /* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
* steps rewrote 1054 class attributes. docs/anchoring-log.md, "Width only: the URL bar is a resize". */ * steps rewrote 1054 class attributes. */
let _resizeSeq = 0; let _resizeSeq = 0;
const _lastWidth = new WeakMap(); const _lastWidth = new WeakMap();
function onResize(entries) { function onResize(entries) {
@@ -243,38 +244,20 @@ function watch(el) {
} }
/* ---- scroll anchoring, where the engine has none ---- /* ---- scroll anchoring, where the engine has none ----
* An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the * Safari and iOS before 27 have no `overflow-anchor`, and a tick's growth above the reader moves
* reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the * them by all of it. Whether the engine anchors is asked of the platform, never of a browser name;
* offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px). * where it does, the observer leaves the reader to it. Elsewhere the drift is computed from a
* Whether there is one to leave anything behind is asked of the platform, never of a browser name — * reference element, never from the offset. */
* with anchoring suppressed, a 120px growth moves the reader 120px.
* docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
const ENGINE_ANCHORS = (() => { const ENGINE_ANCHORS = (() => {
/* dev switch — docs/anchoring.md, "The dev switches" */
try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
catch (e) { /* no storage, no switch */ }
try { return CSS.supports('overflow-anchor', 'auto'); } try { return CSS.supports('overflow-anchor', 'auto'); }
catch (e) { return true; } catch (e) { return true; }
})(); })();
/* A load-time check cannot see an engine that declines on a given refill: two residuals written
* back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
* name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
const LATE_MISS_LIMIT = 2;
let _lateMisses = 0;
let _engineTrusted = ENGINE_ANCHORS;
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
* residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
const LATE_ROUND_TOLERANCE = 16;
/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
* tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
const TRUST_RECOVERY_LIMIT = 2;
let _lateHits = 0;
let _rest = null; let _rest = null;
let _restAt = null, _restPage = null; let _restAt = null, _restPage = null;
let _deferredFloor = null; /* Every floored box a tick refilled while the reader scrolled: one tick refills several, and keeping
* one lost the lease table's 172px. */
const _deferredFloors = new Set();
function pageStamp() { function pageStamp() {
return (document.body && document.body.getAttribute('data-page')) || ''; return (document.body && document.body.getAttribute('data-page')) || '';
} }
@@ -285,8 +268,7 @@ function forgetRest() {
} }
/* `force`: only for the caller that has just written the offset itself, since the engine's own /* `force`: only for the caller that has just written the offset itself, since the engine's own
* compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without, * compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
* `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be * `0, 0, 0` with it. */
* re-taken forced". */
function rememberRest(force) { function rememberRest(force) {
if (scrolling() && !force) return; if (scrolling() && !force) return;
if (ENGINE_ANCHORS && scrollTop() <= 0) { if (ENGINE_ANCHORS && scrollTop() <= 0) {
@@ -351,124 +333,30 @@ function anchorRef() {
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 }; sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
} }
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
* the late drift correction further down) — same shape twice: the last reason recorded, and the
* last 8 with a timestamp. They are read back only by the theme repository's
* development build; the package exports none of it. One factory, so the cap and the timestamp
* format cannot drift between the two. */
const TRAIL_MAX = 8;
function makeTrail() {
let last = null;
const entries = [];
return {
record(w) {
last = w;
entries.push(w + '@' + Math.round(performance.now()));
if (entries.length > TRAIL_MAX) entries.shift();
},
last: () => last,
entries: () => entries
};
}
let _anchorPending = null; let _anchorPending = null;
const _anchor = makeTrail();
function awhy(w) { _anchor.record(w); }
function anchorEnabled() { function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; } try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; } catch (e) { return true; }
} }
/* ---- what the engine's own anchoring leaves behind ---- /* Floors `holdFloor()` could not clear while the reader scrolled, cleared once they let go: written
* It keeps a reference still; it does not promise that a section can vanish and come back. An * in the frame the floors came off, so no frame is painted with the reader moved. The offset as
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */ * laid out after the clear tells whether the engine already gave it back. */
let _lateFrame = 0;
const _late = makeTrail();
function why(w) { _late.record(w); }
function lateDrift(ref, grow, floorShrink) {
if (_lateFrame) return why('busy');
if (!ref) return why('no-reference');
why('armed');
_lateFrame = requestAnimationFrame(() => {
why('frame');
const seen = scrollTop();
const settle = () => {
_lateFrame = 0;
why('settle');
if (!anchorEnabled()) return why('anchoring-off');
if (Date.now() < _userUntil) return why('reader-intent');
if (_restPage !== pageStamp()) return why('page-changed');
/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
* in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
* comparison becomes a value against itself (320px through, @1440 side). */
if (scrollTop() !== seen) return why('offset-moved');
let el = ref.el, was = ref.top;
if (!el || !el.isConnected) {
if (!ref.sec || !ref.sec.isConnected) return why('reference-gone');
el = ref.sec; was = ref.secTop;
}
let drift = el.getBoundingClientRect().top - was;
/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
* 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
* own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
if (Math.abs(drift) < 1 && grow > 1) {
const compensated = seen - ref.at;
if (Math.abs(compensated) < 1) drift = grow - compensated;
else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
rememberRest(true);
return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
}
}
if (Math.abs(drift) < 1) {
if (grow > 1 || floorShrink > 1) rememberRest(true);
return why('no-drift-grow-' + Math.round(grow)); /* the engine put it back */
}
if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
writeOffset(scrollTop() + drift);
why('wrote-' + Math.round(drift));
rememberRest(true);
if (floorShrink > 1) return;
if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
};
/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
* the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
});
}
/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
* own frame slot, and the offset's own response to the write below as the check. Not gated on
* `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
* motion window, still reading true one rAF later on all three cells.
* docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
let _floorLateFrame = 0;
function settleDeferredFloor(offsetBefore, shrink) { function settleDeferredFloor(offsetBefore, shrink) {
if (_floorLateFrame) return; if (!anchorEnabled() || Date.now() < _userUntil) return;
_floorLateFrame = requestAnimationFrame(() => { const seen = scrollTop();
const seen = scrollTop(); const gap = offsetBefore - shrink - seen;
_floorLateFrame = requestAnimationFrame(() => { /* 16: a 32-36 row lease table rounds by 8-12.25px at 390 wide */
_floorLateFrame = 0; if (Math.abs(gap) <= 16) return; /* the engine already gave it back */
if (!anchorEnabled() || Date.now() < _userUntil) return; /* no cap at the viewport: four leases stacked at 390px give up 1032px, all of it above */
if (_restPage !== pageStamp()) return; writeOffset(seen + gap);
if (scrollTop() !== seen) return; /* still settling, or the reader has moved */ if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
const wanted = offsetBefore - shrink; _restAt = scrollTop();
const gap = wanted - seen;
if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return; /* the engine already gave it back */
if (Math.abs(gap) > (window.innerHeight || 800)) return;
writeOffset(scrollTop() + gap);
if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
_restAt = scrollTop();
});
});
} }
function scheduleAnchor(ref) { function scheduleAnchor(ref) {
if (!ref) return awhy('no-reference'); if (!ref) return;
if (!anchorEnabled()) return awhy('anchoring-off'); if (!anchorEnabled()) return;
if (_anchorPending) return awhy('pending-kept-first'); if (_anchorPending) return;
_anchorPending = ref; _anchorPending = ref;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const pending = _anchorPending; const pending = _anchorPending;
@@ -479,23 +367,20 @@ function scheduleAnchor(ref) {
} }
function applyAnchor(ref) { function applyAnchor(ref) {
/* Not into a moving page — unless the motion is the clamp this correction is for, which held a /* Not into a moving page — unless the motion is the clamp this correction is for, which held a
* correct -60px correction unwritten. docs/anchoring-log.md, "A clamp is not the reader". */ * correct -60px correction unwritten. */
if (scrolling() && !sawClamp()) return awhy('refused-moving'); if (scrolling() && !sawClamp()) return;
const at = scrollTop(); const at = scrollTop();
if (ref.by != null) { if (ref.by != null) {
if (ref.by < 1) return awhy('by-under-1'); if (ref.by < 1) return;
writeOffset(at + ref.by); writeOffset(at + ref.by);
awhy('wrote-by-' + Math.round(ref.by));
return true; return true;
} }
if (at <= 0) return awhy('at-top'); if (at <= 0) return;
if (!ref.el.isConnected) return awhy('reference-gone'); if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top; const drift = ref.el.getBoundingClientRect().top - ref.top;
if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */ if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
_lateHits = 0; if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
writeOffset(at + drift); writeOffset(at + drift);
awhy('wrote-' + Math.round(drift));
return true; return true;
} }
@@ -505,56 +390,28 @@ function observeContent() {
.filter(Boolean); .filter(Boolean);
const viewHost = hosts[0]; const viewHost = hosts[0];
_mo = new MutationObserver((records) => { _mo = new MutationObserver((records) => {
const settled = _rest; const ref = ENGINE_ANCHORS ? null : anchorFor();
const trustEngine = _engineTrusted;
const ref = trustEngine ? null : anchorFor();
/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
* whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
* that tested the engine from one that did not. The floored box the record sits in, since
* `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
* "A witness that cannot see the box it is measuring"). */
let box = null;
for (const m of records) {
if (m.type !== 'childList') continue;
const b = m.target.closest(FLOORED);
if (b) { box = b; break; }
}
const before = box && (parseFloat(box.style.minHeight) || 0);
let grew = before ? box.offsetHeight - before : 0;
/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
* offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
if (grew > 1 && settled && settled.el.isConnected
&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
&& !scrolling() && _restPage === pageStamp()
&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
_engineTrusted = true;
_lateMisses = _lateHits = 0;
}
const wasScrolling = scrolling(); const wasScrolling = scrolling();
if (before && wasScrolling) _deferredFloor = box; /* every floored box a record sits in, since `dom.content()` refills a node inside the pinned box
* on 1 of 12 nodes, and one batch refills several: only the first lost the lease table's.
* Only a box already floored before run(): one run() floors now has nothing to settle later. */
if (wasScrolling)
for (const m of records) {
const b = m.type === 'childList' && m.target.closest(FLOORED);
if (b && (parseFloat(b.style.minHeight) || 0) > 0) _deferredFloors.add(b);
}
run(records); run(records);
if (!wasScrolling) _deferredFloor = null; if (!wasScrolling) _deferredFloors.clear();
const floorShrink = before ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px /* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
* out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */ * out and written over a correct restoreScroll(). */
let gone = 0, came = 0, took = 0, gave = 0; let gone = 0, came = 0;
for (const m of records) { for (const m of records)
if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; } if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
}
if (gone && came) { if (gone && came) {
forgetRest(); forgetRest();
return; return;
} }
/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding scheduleAnchor(ref);
* with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
if (trustEngine) {
if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
else lateDrift(settled, grew, floorShrink);
}
else scheduleAnchor(ref);
}); });
for (const host of hosts) { for (const host of hosts) {
_mo.observe(host, { childList: true, subtree: true }); _mo.observe(host, { childList: true, subtree: true });
@@ -564,9 +421,9 @@ function observeContent() {
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] }); _moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
/* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the /* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the
* `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab * `height: 0` they collapse with: 1299px of blank left standing. A same-value write is not a
* switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes * change, though: the fitters re-apply classes every pass, and an unguarded `class` watch froze
* every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms. */ * the tab, 391 callbacks in 432ms. */
const _moTabs = new MutationObserver((records) => const _moTabs = new MutationObserver((records) =>
records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName) records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
&& (r.attributeName !== 'class' || r.target.dataset.field)) && run()); && (r.attributeName !== 'class' || r.target.dataset.field)) && run());
@@ -585,20 +442,8 @@ return baseclass.extend({
}, },
scrolling, scrolling,
/* test-only: the two trails of `makeTrail()` above */
lateWhy: () => _late.last(), /* fs:probe */
lateTrail: () => _late.entries().slice(), /* fs:probe */
anchorWhy: () => _anchor.last(), /* fs:probe */
anchorTrail: () => _anchor.entries().slice(), /* fs:probe */
deferMeasurement, deferMeasurement,
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
* of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
restAt: () => _restAt,
/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
engineTrusted: () => _engineTrusted,
forgetRest, forgetRest,
armGate, armGate,
@@ -317,9 +317,4 @@ function patchOverview() {
return baseclass.extend({ return baseclass.extend({
/* called by menu-footstrap-common's loader on every data-page stamp that maps here */ /* called by menu-footstrap-common's loader on every data-page stamp that maps here */
wire, wire,
/* test-only: the percent parse and the clamp/threshold split, driven directly rather than through
* a live `.cbi-progressbar[title]` walk */
parseMeterPercent, /* fs:probe */
annotateMeter, /* fs:probe */
}); });
@@ -50,8 +50,6 @@ function currentMode() {
/* Dark mode is announced in three dialects because apps sniff for it: `data-theme="dark"` /* Dark mode is announced in three dialects because apps sniff for it: `data-theme="dark"`
* (luci-app-justclash keys 21 rules off it), Bootstrap's `data-bs-theme` and `data-darkmode`. */ * (luci-app-justclash keys 21 rules off it), Bootstrap's `data-bs-theme` and `data-darkmode`. */
function stampDark(root, dark) { function stampDark(root, dark) {
/* the names stay literal: a gate reads this function's source, and a hoisted const would
* look like no attribute at all */
root.setAttribute('data-darkmode', dark ? 'true' : 'false'); root.setAttribute('data-darkmode', dark ? 'true' : 'false');
root.setAttribute('data-theme', dark ? 'dark' : 'light'); root.setAttribute('data-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
@@ -110,7 +110,6 @@ function markAlive() {
_expired = false; _expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.'); console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
} }
function sessionExpired() { return _expired; }
function watchSession() { function watchSession() {
const req = window.L.Request; const req = window.L.Request;
if (req && typeof req.addInterceptor === 'function') if (req && typeof req.addInterceptor === 'function')
@@ -809,22 +808,5 @@ return baseclass.extend({
seed, seed,
wire: wireRouter, wire: wireRouter,
onNavigate, onNavigate,
/* test-only, driven against a hand-broken `L`: the one way to see the off branch */
contractBreaks, /* fs:probe */
/* test-only: the navigation sweep around a visibilitychange, and the session verdict the
* interceptors reached */
clearViewIntervals, /* fs:probe */
sessionExpired, /* fs:probe */
/* test-only: the deferred `poll-stop` listener's own check, called directly so a test need not
* fake a microtask tick */
hidePollIndicatorIfEmpty, /* fs:probe */
/* test-only: the body-litter classifier and counter, and navigate() to prove the early return
* reaches them */
strayBodyNode, /* fs:probe */
bodyLittered, /* fs:probe */
navigate, /* fs:probe */
/* test-only: the progress bar's start/end pair, to drive the fade without a navigation */
progressStart, /* fs:probe */
progressEnd, /* fs:probe */
prefetchSegs prefetchSegs
}); });
@@ -571,11 +571,6 @@ function wireTypeahead() {
} }
return baseclass.extend({ return baseclass.extend({
/* test-only: the slot-cache key, driven directly */
shapeOf, /* fs:probe */
/* test-only: the token-cell predicate */
markTokenCells, /* fs:probe */
__init__() { __init__() {
wireTypeahead(); wireTypeahead();
@@ -411,7 +411,10 @@ function reassertLayerOrder() {
if (_layerStmt) _layerStmt.remove(); if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style'); _layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER; _layerStmt.textContent = LAYER_ORDER;
head.insertBefore(_layerStmt, head.firstChild); /* before the first sheet, not into <head>: a document_start script (an extension) appends its
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
const first = document.querySelector('style, link[rel~="stylesheet"]');
first.parentNode.insertBefore(_layerStmt, first);
} }
function sweepViewSheets() { function sweepViewSheets() {
@@ -449,10 +452,4 @@ return baseclass.extend({
documentPoisoned, documentPoisoned,
scopeToCurrentPage, scopeToCurrentPage,
watchViewSheets, watchViewSheets,
/* test-only: the duplicate sweep, the fence rewrite, the owner stamp and the re-host, driven
* directly instead of through a live document walk */
dedupeViewSheets, /* fs:probe */
fenceRules, /* fs:probe */
_owner, /* fs:probe */
rehostIntoThemeLayer, /* fs:probe */
}); });
@@ -93,9 +93,9 @@ function wireSearch() {
/* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which /* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which
* stock includes call bare and an SPA arrival never defines. */ * stock includes call bare and an SPA arrival never defines. */
function ensureOverviewHelpers() { function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so /* Adapted copies of LuCI's admin_status/index.ut (window.L, an array child for E(), the title
they can be diffed against upstream when it changes. Modernising the `var`s would break string hoisted into `reading`); keep the shape, `var`s included, so a diff against upstream
that property, which is what makes carrying the copies safe. */ stays readable when it changes. */
if (typeof window.progressbar !== 'function') if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) { window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query), var pg = document.querySelector(query),
@@ -127,7 +127,6 @@ function ensureOverviewHelpers() {
window.L.itemlist(E('span'), [].slice.call(arguments, 2)) window.L.itemlist(E('span'), [].slice.call(arguments, 2))
]); ]);
}; };
/* eslint-enable no-var */
} }
ensureOverviewHelpers(); ensureOverviewHelpers();
+7 -9
View File
@@ -1,15 +1,13 @@
# Single source of the openwrt/luci commit whose two build tools we download and run as gates, and # Single source of the openwrt/luci commit whose i18n-scan.pl update-po.sh downloads and runs by
# of their checksums. Sourced as shell (`. luci-upstream.pin`) by update-po.sh and CI. # perl to decide whether the translation catalogue is complete, and of its checksum. Sourced as
# - jsmin.c compiled; its regex-vs-division lookback can silently eat a shipped JS file # shell (`. luci-upstream.pin`) by update-po.sh, build-apk.sh and tools/build-usign.sh.
# - i18n-scan.pl run by perl; decides whether the translation catalogue is complete # Off a moving `master` the check would be whatever upstream pushed last.
# Off a moving `master` the gate would be whatever upstream pushed last. # To bump: change LUCI_PIN, re-fetch the file at the new commit, update its sha256.
# To bump: change LUCI_PIN, re-fetch both files at the new commit, update their sha256s.
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3 LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
JSMIN_SHA256=0568b14873f27f339cf66a9c43603d0bc8ca501ff62522ea924c6e8daf1be215
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3 I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
# No ucode pin, deliberately: the `verify` job runs the router's own interpreter over the installed # No ucode pin, deliberately: the `e2e` job renders every installed template with the router's own
# templates on both release lines (docs/ci.md), so nothing is pinned, built or stubbed here. # ucode on both release lines (tools/e2e.mjs), so nothing is pinned, built or stubbed here.
# usign, OpenWrt's ed25519 signer: owfeed signs each release package with it, and build-apk.sh # usign, OpenWrt's ed25519 signer: owfeed signs each release package with it, and build-apk.sh
# builds this copy (via tools/build-usign.sh, not run in CI) to check the SDK it downloads. This is # builds this copy (via tools/build-usign.sh, not run in CI) to check the SDK it downloads. This is
+1 -2
View File
@@ -6,8 +6,7 @@
# 14% of the sheet: 18283 of 134210 bytes (measured 2026-10-14), wire bytes too: uhttpd serves /www # 14% of the sheet: 18283 of 134210 bytes (measured 2026-10-14), wire bytes too: uhttpd serves /www
# uncompressed. # uncompressed.
# The same map rewrites the .js and .ut under each <rewrite-dir> in place, BEFORE `strip-build.sh # The same map rewrites the .js and .ut under each <rewrite-dir> in place, BEFORE `strip-build.sh
# templates` and the JS minifier. Verify with `npm run computed-diff` (tools/computed-diff.mjs), # templates` and the JS minifier.
# mangled against plain.
# Safe because (each checked): # Safe because (each checked):
# * `--fs-*` is the private tier; the export tier `--*-color-*` is untouched, and no installed # * `--fs-*` is the private tier; the export tier `--*-color-*` is untouched, and no installed
# luci-app reads a `--fs-` name. # luci-app reads a `--fs-` name.
+1 -1
View File
@@ -169,7 +169,7 @@ msgctxt "footstrap"
msgid "File" msgid "File"
msgstr "Archivo" msgstr "Archivo"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake, # Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string. # not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3 #: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap" msgid "Footstrap"
+1 -1
View File
@@ -164,7 +164,7 @@ msgctxt "footstrap"
msgid "File" msgid "File"
msgstr "Файл" msgstr "Файл"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake, # Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string. # not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3 #: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap" msgid "Footstrap"
@@ -13,9 +13,10 @@ set_opt() {
set_opt themes.Footstrap /luci-static/footstrap set_opt themes.Footstrap /luci-static/footstrap
# A fresh install may pick the theme; an upgrade must never move a router off its theme. `changed` # set_opt never overwrites, and luci-base ships main.mediaurlbase, so on a stock router this line
# tells the two apart (on an upgrade the entry exists) and carries the decision alone: apk exports # sets nothing on install or upgrade: the admin picks Footstrap in Language and Style. An upgrade
# no PKG_UPGRADE. # must never move a router off its theme; on an upgrade the themes entry exists, so `changed` is 0
# (apk exports no PKG_UPGRADE).
if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then
set_opt main.mediaurlbase /luci-static/footstrap set_opt main.mediaurlbase /luci-static/footstrap
fi fi
+3 -43
View File
@@ -1,10 +1,9 @@
#!/bin/sh #!/bin/sh
# Strip build-time-only bytes from a BUILD TREE, one mode per kind of file: # Strip build-time-only bytes from a BUILD TREE, one mode per kind of file:
# ./strip-build.sh templates|shell|probes|assets <dir> # ./strip-build.sh templates|shell|assets <dir>
# #
# templates `{# … #}`, whole-line `/* … */` and the indentation out of the .ut files # templates `{# … #}`, whole-line `/* … */` and the indentation out of the .ut files
# shell whole-line `#` out of the shell under root/ (uci-defaults, the CGI, keep.d) # shell whole-line `#` out of the shell under root/ (uci-defaults, the CGI, keep.d)
# probes the `/* fs:probe */` export lines out of the JS
# assets the SVG favicon's comment and the whitespace in the manifest JSON # assets the SVG favicon's comment and the whitespace in the manifest JSON
# #
# Run from the Makefile over $(PKG_BUILD_DIR) and from tools/stage.sh over the staged payload, never # Run from the Makefile over $(PKG_BUILD_DIR) and from tools/stage.sh over the staged payload, never
@@ -14,11 +13,11 @@
# executes it. Reasons per mode sit above each branch below; docs/package.md has the rest. # executes it. Reasons per mode sit above each branch below; docs/package.md has the rest.
set -eu set -eu
usage() { echo "usage: strip-build.sh templates|shell|probes|assets <dir>" >&2; exit 2; } usage() { echo "usage: strip-build.sh templates|shell|assets <dir>" >&2; exit 2; }
MODE="${1:-}" MODE="${1:-}"
DIR="${2:-}" DIR="${2:-}"
case "$MODE" in templates|shell|probes|assets) ;; *) usage ;; esac case "$MODE" in templates|shell|assets) ;; *) usage ;; esac
[ -n "$DIR" ] && [ -d "$DIR" ] || usage [ -n "$DIR" ] && [ -d "$DIR" ] || usage
LIST=$(mktemp) LIST=$(mktemp)
@@ -29,7 +28,6 @@ trap 'exit 130' INT TERM
# The list goes through a file: `for f in $(find …)` splits on whitespace. # The list goes through a file: `for f in $(find …)` splits on whitespace.
case "$MODE" in case "$MODE" in
templates) find "$DIR" -type f -name '*.ut' ;; templates) find "$DIR" -type f -name '*.ut' ;;
probes) find "$DIR" -type f -name '*.js' ;;
assets) find "$DIR" -type f \( -name '*.svg' -o -name '*.json' \) ;; assets) find "$DIR" -type f \( -name '*.svg' -o -name '*.json' \) ;;
shell) find "$DIR" -type f ;; shell) find "$DIR" -type f ;;
esac | sort > "$LIST" esac | sort > "$LIST"
@@ -50,11 +48,6 @@ want() {
echo "strip-build: $1:$(grep -n '<<' "$1" | head -1 | cut -d: -f1): a heredoc — refusing (a '#' line inside one is DATA, not a comment)" >&2 echo "strip-build: $1:$(grep -n '<<' "$1" | head -1 | cut -d: -f1): a heredoc — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1 exit 1
fi ;; fi ;;
# a few module-private functions are also exported for one reason: a gate here calls them.
# The function stays, the export line goes, with the comment block directly above it. Only a
# complete export entry may go; anything else fails the build, since a half-removed object
# literal is a module that does not parse.
probes) grep -q '/\* fs:probe \*/' "$1" ;;
# acl.d is excluded: rpcd skips a malformed ACL SILENTLY, so the grant would go to nobody, for ~200 B # acl.d is excluded: rpcd skips a malformed ACL SILENTLY, so the grant would go to nobody, for ~200 B
assets) case "$1" in */rpcd/acl.d/*.json) return 1 ;; esac ;; assets) case "$1" in */rpcd/acl.d/*.json) return 1 ;; esac ;;
esac esac
@@ -222,36 +215,6 @@ strip() {
echo "strip-build: $1 lost its shebang — refusing" >&2 echo "strip-build: $1 lost its shebang — refusing" >&2
exit 1 exit 1
fi ;; fi ;;
probes)
pre=$(grep -c '/\* fs:probe \*/$' "$1" || true)
awk '
# a comment block directly above a probe entry describes that export, so it is held back
# and goes with it; above anything else it is released untouched
function flush() { if (nb) { printf "%s", buf; lastblank = 0 } buf = ""; nb = 0 }
incom { buf = buf $0 "\n"; nb++; if ($0 ~ /\*\//) { incom = 0 } ; next }
/^[ \t]*\/\*/ && !/\/\* fs:probe \*\/$/ {
flush()
buf = $0 "\n"; nb = 1
if ($0 !~ /\*\//) incom = 1
else if ($0 !~ /\*\/[ \t]*$/) flush()
next
}
/\/\* fs:probe \*\/$/ {
line = $0
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
# a complete entry, in either form the modules use: `name,` or `name: expression,`
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { buf = ""; nb = 0; skipblank = lastblank; next }
print "strip-build: " FILENAME ":" NR ": not a whole export entry: " line > "/dev/stderr"
bad = 1
}
# a drop between two blank lines would leave a run the source did not have
skipblank && /^[ \t]*$/ { skipblank = 0; next }
{ flush(); skipblank = 0; lastblank = ($0 ~ /^[ \t]*$/); print }
END { flush(); exit bad }
' "$1" > "$CUR"
post=$(grep -c '/\* fs:probe \*/$' "$CUR" || true)
dropped=$((dropped + pre - post)) ;;
assets) assets)
case "$1" in case "$1" in
*.svg) *.svg)
@@ -315,7 +278,6 @@ strip() {
before=0 before=0
after=0 after=0
found=0 found=0
dropped=0
while IFS= read -r f; do while IFS= read -r f; do
want "$f" || continue want "$f" || continue
found=$((found + 1)) found=$((found + 1))
@@ -333,11 +295,9 @@ done < "$LIST"
case "$MODE" in case "$MODE" in
templates) [ "$found" -gt 0 ] || { echo "strip-build: no .ut under $DIR" >&2; exit 1; } ;; templates) [ "$found" -gt 0 ] || { echo "strip-build: no .ut under $DIR" >&2; exit 1; } ;;
shell) [ "$found" -gt 0 ] || { echo "strip-build: no shell file under $DIR" >&2; exit 1; } ;; shell) [ "$found" -gt 0 ] || { echo "strip-build: no shell file under $DIR" >&2; exit 1; } ;;
probes) [ "$found" -gt 0 ] || { echo "strip-build: no marked export in $DIR" >&2; exit 1; } ;;
assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;; assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;;
esac esac
case "$MODE" in case "$MODE" in
probes) echo "strip-build: probes: $dropped export(s) dropped from $found file(s)" ;;
assets) echo "strip-build: assets: $found file(s)" ;; assets) echo "strip-build: assets: $found file(s)" ;;
*) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;; *) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;;
esac esac
+3 -5
View File
@@ -8,8 +8,7 @@
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft); --fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent); --fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill); --fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs /* opaque, not a tint (4.21:1 in dark) */
* (`selected-row/paint`), docs/css.md "@mirror" */
--fs-row-rail: inset 2px 0 0 var(--fs-accent); --fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* every separator is this one line, stated once rather than in 69 longhand declarations */ /* every separator is this one line, stated once rather than in 69 longhand declarations */
@@ -41,7 +40,7 @@
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as /* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
* tokens, not fetched". */ * tokens, not fetched". */
--fs-icon-refresh: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'><path d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/><path d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/><path fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/><path fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/></svg>"); --fs-icon-refresh: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'><path d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/><path d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/><path fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/><path fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/></svg>");
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */ /* a glyph inlined once, sized at two call sites — keep them in step by hand */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type)); --fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */ /* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
@@ -154,7 +153,7 @@
--fs-z-tooltip: 1000; --fs-z-tooltip: 1000;
--fs-z-dropdown: 1100; --fs-z-dropdown: 1100;
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md /* outbound only: styles/ never reads one (nothing checks it). docs/design-system.md
* "The export tier is a RAMP" */ * "The export tier is a RAMP" */
--background-color-high: var(--fs-panel); --background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2)); --background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
@@ -171,7 +170,6 @@
--border-color-medium: var(--fs-border); --border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel)); --border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* tools/export-tier.mjs proves the whole tier across palette x mode */
--primary-color-high: var(--fs-accent); --primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim)); --primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim)); --primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
+1 -1
View File
@@ -20,7 +20,7 @@
--fs-good: var(--fs-good-base); --fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base); --fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base); --fs-danger: var(--fs-danger-base);
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`. /* deliberately under AA — the "nothing here" ink.
* docs/design-system.md "The fourth ink is the one". */ * docs/design-system.md "The fourth ink is the one". */
--fs-placeholder-mix: 55%; --fs-placeholder-mix: 55%;
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2)); --fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
@@ -50,7 +50,7 @@
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */ padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
} }
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */ /* Physical: border-block-start/-end need a browser-floor support decision first. */
.tr.drag-over-above { .tr.drag-over-above {
border-top: 2px solid var(--fs-accent); border-top: 2px solid var(--fs-accent);
} }
@@ -36,8 +36,7 @@
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE /* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full * pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict * (same selectors), so a copy left behind would be a dead declaration. */
* reports. */
.cbi-tab-disabled[data-errors]::after { .cbi-tab-disabled[data-errors]::after {
content: attr(data-errors); content: attr(data-errors);
@@ -203,7 +203,7 @@
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th, :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; } :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, chosen instead of :is(): .cbi-value carries no :has(), and a selector list is not /* own rule, chosen instead of :is(): .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */ * forgiving below the browser floor */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; } :where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's /* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
+4 -13
View File
@@ -165,10 +165,8 @@
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before { .fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz); content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good); background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat; -webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat; mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
} }
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before { .fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim); background: var(--fs-dim);
@@ -180,10 +178,8 @@
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center; min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
} }
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after { .fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge); content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums; font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
} }
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the /* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
@@ -412,10 +408,8 @@
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] { :root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0; font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good); border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat; -webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat; mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* Static, deliberately: this is a poll running quietly forever, not something being /* Static, deliberately: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */ * waited on, and a permanently spinning glyph also broke the click that pauses it. */
} }
@@ -427,9 +421,8 @@
} }
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A /* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". One rule per box: two would need an extra * collapsed-rail indicator badges its count". `.fs-sidebar` is deliberately absent:
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.fs-sidebar` is * #indicators is an id. */
* deliberately absent: #indicators is an id. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) { :root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits) /* min-width, not width: the count is unbounded (a big config can queue three digits)
* and a fixed box would clip it. */ * and a fixed box would clip it. */
@@ -437,13 +430,11 @@
padding: 0 var(--fs-space-1); overflow: hidden; padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
} }
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this /* .fs-ind-compact above paints the same badge but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */ * rule: it applies at every width, this one must stay inside the 521px floor. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after { :root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge); content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums; font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
} }
} }
@@ -536,7 +527,7 @@
.fs-main .alert-message button { white-space: normal; max-width: 100%; } .fs-main .alert-message button { white-space: normal; max-width: 100%; }
} }
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word /* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). Outside the @media: table-contract refuses overflow-wrap in a width query */ * overflowed a 320px button). */
.fs-main .alert-message .btn, .fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; } .fs-main .alert-message button { overflow-wrap: anywhere; }
@@ -40,33 +40,25 @@
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table /* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead * meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the * of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
* whole rule, form row included. @mirror pins the shared declarations. */ * whole rule, form row included. */
.cbi-value-field > .cbi-progressbar { .cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value); margin-inline-end: var(--fs-meter-value);
margin-block-start: 0; margin-block-start: 0;
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2); --fs-meter-value: calc(var(--fs-space-10) * 2);
} }
.cbi-value-field > .cbi-progressbar::after { .cbi-value-field > .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%; inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
/* @endmirror */
} }
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar { .cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value); margin-inline-end: var(--fs-meter-value);
margin-block-start: 0; margin-block-start: 0;
/* @endmirror */
--fs-meter-value: var(--fs-space-10); --fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */ min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
} }
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after { .cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%; inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
/* @endmirror */
} }
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves /* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
@@ -114,7 +114,7 @@
} }
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it /* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */ * (18 jumps/1500 frames measured). */
.table.fs-dt { overflow-anchor: none; } .table.fs-dt { overflow-anchor: none; }
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); } .cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
@@ -306,7 +306,7 @@
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a /* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
* 1-line neighbour's (52px) */ * 1-line neighbour's (52px) */
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list, /* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */ * losing display:flex on every card. Keep both in step by hand. */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; } .table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)), .table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; } .table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
@@ -315,14 +315,13 @@
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start; padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
overflow-wrap: anywhere; white-space: normal; overflow-wrap: anywhere; white-space: normal;
} }
/* the column label, lifted above its value. PINNED: the config table needs this under its /* the column label, lifted above its value. A copy sits in theme/65-dropdown.css: the config
* own @container guard, which cannot share a block with a class. */ * table needs it under its own @container guard, which cannot share a block with a class. Keep
* the two identical by hand. */
.table.fs-stacked .td[data-title]::before { .table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1); content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color); color: var(--fs-eyebrow-color);
/* @endmirror */
} }
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */ /* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
.table.fs-stacked .td.cbi-section-actions::before, .table.fs-stacked .td.cbi-section-actions::before,
@@ -330,26 +329,20 @@
.table.fs-stacked .td[data-title=""]::before, .table.fs-stacked .td[data-title=""]::before,
.table.fs-stacked tfoot .td[data-title]::before, .table.fs-stacked tfoot .td[data-title]::before,
.table.fs-stacked .tfoot .td[data-title]::before { content: none; } .table.fs-stacked .tfoot .td[data-title]::before { content: none; }
/* row buttons are not a label/value pair — full width below the pairs. PINNED, see above. */ /* row buttons are not a label/value pair — full width below the pairs. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions { .table.fs-stacked .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start; display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2); flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
} }
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped /* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. PINNED to the config table's copy. */ * the tail button. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) { .table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2); flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
} }
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group /* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */ * past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown { .table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0; flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
} }
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips, /* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
* it does not scroll to an over-wide child */ * it does not scroll to an over-wide child */
@@ -273,9 +273,7 @@
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); } .cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */ /* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
.cbi-value-field select option:checked { .cbi-value-field select option:checked {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail); background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
} }
} }
@@ -271,9 +271,7 @@
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md /* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
* "Dropdown: the open list's rail and the selected-row's contrast". */ * "Dropdown: the open list's rail and the selected-row's contrast". */
.cbi-dropdown[open] > ul.dropdown > li[selected] { .cbi-dropdown[open] > ul.dropdown > li[selected] {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail); background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
} }
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides /* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
* INSIDE it so a focused+selected row keeps both */ * INSIDE it so a focused+selected row keeps both */
@@ -282,8 +280,8 @@
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft); box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
} }
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to /* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; the same declarations as
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */ * `select option:checked` in theme/60-inputs.css, which has no checkbox (kept identical by hand) */
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; } .cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible { .cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
box-shadow: inset 0 0 0 2px var(--fs-accent-soft); box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
@@ -352,36 +350,28 @@
.table.cbi-section-table .td[data-widget="dvalue"], .table.cbi-section-table .td[data-widget="dvalue"],
.table.cbi-section-table .td[data-widget="fvalue"], .table.cbi-section-table .td[data-widget="fvalue"],
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; } .table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a /* a copy of the data table's rule (theme/30-tables.css), needed under a CLASS there and
* CLASS there and under this @container here, and CSS cannot OR the two guards. */ * under this @container here, and CSS cannot OR the two guards. Keep them identical by hand. */
.table.cbi-section-table .td[data-title]::before { .table.cbi-section-table .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1); content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color); color: var(--fs-eyebrow-color);
/* @endmirror */
} }
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */ /* the row's buttons are not a label/value pair: full width, sharing the line. Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions { .table.cbi-section-table .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start; display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2); flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
} }
.table.cbi-section-table .td.cbi-section-actions::before { content: none; } .table.cbi-section-table .td.cbi-section-actions::before { content: none; }
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the /* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */ * div is a single flex item. Copy in theme/30-tables.css; same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) { .table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2); flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
} }
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group /* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */ * past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown { .table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0; flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
} }
.table.cbi-section-table .td .zone-forwards { width: 100%; } .table.cbi-section-table .td .zone-forwards { width: 100%; }
} }
@@ -20,11 +20,9 @@
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */ /* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
#modal_overlay .modal label.btn { #modal_overlay .modal label.btn {
/* @mirror btn-wrap/anywhere */
white-space: normal; white-space: normal;
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
/* @endmirror */
} }
#modal_overlay .modal li { overflow-wrap: anywhere; } #modal_overlay .modal li { overflow-wrap: anywhere; }
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */ /* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
@@ -4,11 +4,9 @@
@container fs-view (max-width: 360px) { @container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn, #view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] { #view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
/* @mirror btn-wrap/anywhere */
white-space: normal; white-space: normal;
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
/* @endmirror */
} }
} }
@@ -1,7 +1,7 @@
@layer theme { @layer theme {
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules, /* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below * and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
* (.stylelintrc.json holds the allowlist). .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */ * .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
animation-duration: .01ms !important; animation-duration: .01ms !important;
@@ -67,7 +67,7 @@
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked * 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused * one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the * keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at the stylelint ceiling (1,7,0) and still win by file order. */ * glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at (1,7,0) and still win by file order. */
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before, .fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"], :root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after, input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
@@ -127,8 +127,7 @@
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live /* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
applier in fs-prefs.js (no shared code: this runs before the module loader exists). applier in fs-prefs.js (no shared code: this runs before the module loader exists).
It carries no interpolation, unlike the data blob above: an interpolated body is not It carries no interpolation, unlike the data blob above: an interpolated body is not
JS until rendered, so it is exempt from the inline-script lint and anything merged JS until rendered.
into it would go unchecked.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in. Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */ Dark mode: a saved preference, else the router default, else the OS. */
(() => { (() => {
@@ -144,8 +144,7 @@
{# {#
The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS
until the server renders it, so this line carries no logic at all and eslint's .ut processor until the server renders it, so this line carries no logic at all. The script itself is plain JS.
enforces that. The script itself is plain JS and is linted.
#} #}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script> <script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script> <script>
+3 -3
View File
@@ -4,9 +4,9 @@
# #
# ./update-po.sh rescan, merge, report what is still untranslated # ./update-po.sh rescan, merge, report what is still untranslated
# ./update-po.sh --check change nothing; fail if the .pot is stale or a string is # ./update-po.sh --check change nothing; fail if the .pot is stale or a string is
# untranslated. The CI gate. # untranslated.
# #
# An untranslated _() falls through to its English msgid and nothing reports it, so --check is a gate. # An untranslated _() falls through to its English msgid and nothing else reports it.
# `po/` is what LUCI_LANGUAGES globs and Weblate translates (docs/package.md): do not rename it. # `po/` is what LUCI_LANGUAGES globs and Weblate translates (docs/package.md): do not rename it.
# Not run on the buildbot (luci.mk calls po2lmo); needs perl and gettext. # Not run on the buildbot (luci.mk calls po2lmo); needs perl and gettext.
# The scanner is LuCI's own build/i18n-scan.pl, not a grep: it lexes .ut into JS before xgettext and # The scanner is LuCI's own build/i18n-scan.pl, not a grep: it lexes .ut into JS before xgettext and
@@ -16,7 +16,7 @@ set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
# Pinned by commit and sha256 in luci-upstream.pin: a fetched perl script is EXECUTED, and off a # Pinned by commit and sha256 in luci-upstream.pin: a fetched perl script is EXECUTED, and off a
# moving branch the completeness gate would be whatever upstream pushed last. # moving branch the completeness check would be whatever upstream pushed last.
. ./luci-upstream.pin . ./luci-upstream.pin
SCANNER_URL="https://raw.githubusercontent.com/openwrt/luci/${LUCI_PIN}/build/i18n-scan.pl" SCANNER_URL="https://raw.githubusercontent.com/openwrt/luci/${LUCI_PIN}/build/i18n-scan.pl"
SCANNER_SHA256="$I18N_SCAN_SHA256" SCANNER_SHA256="$I18N_SCAN_SHA256"
+4 -4
View File
@@ -7,13 +7,13 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=openlist2 PKG_NAME:=openlist2
PKG_VERSION:=4.2.8 PKG_VERSION:=4.2.9
PKG_WEB_VERSION:=4.2.8 PKG_WEB_VERSION:=4.2.9
PKG_RELEASE:=1 PKG_RELEASE:=1
PKG_SOURCE:=openlist-$(PKG_VERSION).tar.gz PKG_SOURCE:=openlist-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/OpenListTeam/OpenList/tar.gz/v$(PKG_VERSION)? PKG_SOURCE_URL:=https://codeload.github.com/OpenListTeam/OpenList/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=9b3ecb32ab23ecc79a0d471cff3e62a50a93a52fa79245cb6cfc38994546a606 PKG_HASH:=a762ff50b71b384f03af2996cac2aba471b0d5ec44aad0255e646a41c8af7f49
PKG_BUILD_DIR:=$(BUILD_DIR)/OpenList-$(PKG_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/OpenList-$(PKG_VERSION)
@@ -24,7 +24,7 @@ PKG_MAINTAINER:=sbwml <admin@cooluc.com>
define Download/openlist-frontend define Download/openlist-frontend
FILE:=openlist-frontend-dist-lite-v$(PKG_WEB_VERSION).tar.gz FILE:=openlist-frontend-dist-lite-v$(PKG_WEB_VERSION).tar.gz
URL:=https://github.com/OpenListTeam/OpenList-Frontend/releases/download/v$(PKG_WEB_VERSION)/ URL:=https://github.com/OpenListTeam/OpenList-Frontend/releases/download/v$(PKG_WEB_VERSION)/
HASH:=5f74d21c9841c4f132c39c4ddcf35e4855020a3fe0b877f4b6e465700c976503 HASH:=af0b7214fb3a5c34f27366acf528378d9501e27f6e36d5d9c6533c6fee67e4df
endef endef
PKG_BUILD_DEPENDS:=golang/host PKG_BUILD_DEPENDS:=golang/host
+2 -2
View File
@@ -21,13 +21,13 @@ define Download/geoip
HASH:=4ff4b2ec242a9e87b296ba27ddb85f8311e8d3e071eaebac5be9231c60e408dd HASH:=4ff4b2ec242a9e87b296ba27ddb85f8311e8d3e071eaebac5be9231c60e408dd
endef endef
GEOSITE_VER:=20261024132648 GEOSITE_VER:=20261025014458
GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER) GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER)
define Download/geosite define Download/geosite
URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/ URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/
URL_FILE:=dlc.dat URL_FILE:=dlc.dat
FILE:=$(GEOSITE_FILE) FILE:=$(GEOSITE_FILE)
HASH:=80c7e23186671049ea54280bf0a18e1f782a81fde92c56694044f4bcb5cd077a HASH:=dae1d66a2055a3e88fdf68e4932ec0e1fbfcdb6c5ec824031af2ea74458bdec3
endef endef
GEOSITE_IRAN_VER:=202610230802 GEOSITE_IRAN_VER:=202610230802