diff --git a/clashoo/Makefile b/clashoo/Makefile
index 488bb6d4..e17b1c0c 100644
--- a/clashoo/Makefile
+++ b/clashoo/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=clashoo
-PKG_SHORT_SHA:=91e85ee
-PKG_COMMIT_DATE:=2026.10.24
+PKG_SHORT_SHA:=6780eee
+PKG_COMMIT_DATE:=2026.10.25
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
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_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_VERSION:=91e85eebf0b654c73424f0a215b3c56f6b39614f
-PKG_HASH:=efa0d9d66a589f06f402f50e0dea349ab9923d36d181303769f0575c0779e562
+PKG_SOURCE_VERSION:=6780eeeb27c7eda5e79a6310a4f7f91ba7cb725d
+PKG_HASH:=affb9e916e750c6bd4385c6718660a02cc213936063141fd1c2422011e408b47
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
PKG_LICENSE:=GPL3.0+
diff --git a/clashoo/files/etc/init.d/clashoo b/clashoo/files/etc/init.d/clashoo
index 1bb5b2fa..abd5f7cc 100755
--- a/clashoo/files/etc/init.d/clashoo
+++ b/clashoo/files/etc/init.d/clashoo
@@ -1049,7 +1049,7 @@ _user_dns_fields() {
command -v yq >/dev/null 2>&1 || return 0
[ -s "$CONFIG_YAML" ] || return 0
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
[ "$(yq -M e "(.dns // {}) | has(\"${field}\")" "$CONFIG_YAML" 2>/dev/null)" = "true" ] &&
out="${out}${out:+,}${field}"
@@ -1091,7 +1091,7 @@ _clashoo_dns_force_fields() {
command -v yq >/dev/null 2>&1 || return 0
[ -s "$CONFIG_YAML" ] || return 0
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)"
if [ "$managed" = "true" ]; then
printf '%s' "$fields"
@@ -2156,6 +2156,17 @@ _core_only_ui_args() {
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() {
local _cmd _core_only
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.
[ "$_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
# firewall/dnsmasq, and never let a geo auto-download (mihomo blocks ~90s on
# a missing GeoIP/GeoSite when GitHub is unreachable) freeze startup/UI.
@@ -2557,6 +2575,11 @@ reload_service()
rm -f "$_candidate" "$_previous" "$_response" "$_dns_backup"
return 1
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
SAFE_PATHS=/ _run_with_timeout 15 "$CLASH" -d "$CLASH_CONFIG" -f "$_candidate" -t >> "$CORE_LOG_PATH" 2>&1
else
diff --git a/clashoo/files/usr/share/clashoo/runtime/mixin.uc b/clashoo/files/usr/share/clashoo/runtime/mixin.uc
index ddbf845f..6ace606f 100644
--- a/clashoo/files/usr/share/clashoo/runtime/mixin.uc
+++ b/clashoo/files/usr/share/clashoo/runtime/mixin.uc
@@ -145,7 +145,7 @@ if (type(inherited_nameserver) != 'array') {
inherited_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;
for (let f in dns_role_fields)
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;
let policies = {};
+let proxy_policies = {};
uci.foreach('clashoo', 'dns_policy', function(sec) {
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 servers = sec.nameserver;
if (type(servers) != 'array') servers = servers != null ? [servers] : [];
if (!length(matcher) || !length(servers)) return;
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']))
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');
if (dns_force['respect-rules'])
diff --git a/honk/files/control.sh b/honk/files/control.sh
index 1c6c5629..c161c5bb 100755
--- a/honk/files/control.sh
+++ b/honk/files/control.sh
@@ -35,8 +35,8 @@ valid_clash_api() {
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" != "$listen_port" ] || return 1
- case "$clash_api_secret" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac
- [ "${#clash_api_secret}" -ge 16 ] && [ "${#clash_api_secret}" -le 128 ]
+ case "$clash_api_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac
+ [ "${#clash_api_secret}" -ge 6 ] && [ "${#clash_api_secret}" -le 128 ]
}
valid_network_name() {
diff --git a/honk/files/maintenance.sh b/honk/files/maintenance.sh
index 2497ccb9..1335ef9f 100755
--- a/honk/files/maintenance.sh
+++ b/honk/files/maintenance.sh
@@ -452,8 +452,8 @@ load_uci_values() {
[ "$u_clash_port" -ge 1024 ] && [ "$u_clash_port" -le 65535 ] || return 1
if [ "$u_clash_enabled" = 1 ]; then
[ "$u_initialized" = 1 ] && [ "$u_clash_port" != "$u_port" ] || return 1
- case "$u_clash_secret" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac
- [ "${#u_clash_secret}" -ge 16 ] && [ "${#u_clash_secret}" -le 128 ] || return 1
+ case "$u_clash_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac
+ [ "${#u_clash_secret}" -ge 6 ] && [ "${#u_clash_secret}" -le 128 ] || return 1
fi
}
diff --git a/luci-app-adguardhome/.github/workflows/build-apk.yaml b/luci-app-adguardhome/.github/workflows/build-apk.yaml
index af5e77b6..128f5855 100644
--- a/luci-app-adguardhome/.github/workflows/build-apk.yaml
+++ b/luci-app-adguardhome/.github/workflows/build-apk.yaml
@@ -5,18 +5,23 @@ on:
tags:
- 'v*'
- 'dev-*'
+ workflow_dispatch:
permissions:
contents: write
env:
TARGET: x86
- SUBTARGET: 64
+ SUBTARGET: '64'
PKG_DIR_NAME: luci-app-adguardhome
jobs:
build:
runs-on: ubuntu-latest
+ timeout-minutes: 90
+ defaults:
+ run:
+ shell: bash
steps:
- name: 检出代码
@@ -28,47 +33,79 @@ jobs:
run: |
sudo apt update
sudo apt install -y build-essential clang flex bison g++ gawk \
- gcc-multilib g++-multilib gettext git libncurses5-dev libssl-dev \
- python3-setuptools rsync swig unzip zlib1g-dev file wget zstd jq
+ gcc-multilib g++-multilib gettext git libncurses-dev libssl-dev \
+ python3-setuptools rsync swig unzip zlib1g-dev file curl zstd jq
- - name: 下载 SDK
+ - name: 确定 SDK 版本
+ id: sdk
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
-
- echo "🔍 获取 OpenWrt 25.12 最新版本..."
- LATEST_TAG=$(
- curl -fsSL https://api.github.com/repos/openwrt/openwrt/releases?per_page=50 \
- | jq -r '.[].tag_name' \
- | grep -E '^v25\.12\.[0-9]+(-rc[0-9]+)?$' \
- | sort -V \
- | tail -n 1
+ RELEASE=$(
+ curl --fail --silent --show-error --location --retry 3 \
+ -H "Authorization: Bearer ${GH_TOKEN}" \
+ 'https://api.github.com/repos/openwrt/openwrt/releases?per_page=100' \
+ | jq -r '.[] | select(.draft == false and .prerelease == false) | .tag_name
+ | select(test("^v25\\.12\\.[0-9]+$")) | ltrimstr("v")' \
+ | sort -V | 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
- echo "❌ 获取 OpenWrt 25.12.x tag 失败"
- exit 1
+ - name: 缓存 SDK 压缩包
+ uses: actions/cache@v5
+ 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
+ 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}"
- echo "检测到 25.12 系列最新版本: ${RELEASE}"
-
- echo "📥 下载 SDK..."
- BASE="https://downloads.openwrt.org/releases/${RELEASE}/targets/${TARGET}/${SUBTARGET}/"
- SDK_TARBALL=$(curl -fsSL "$BASE" | grep -o 'openwrt-sdk-[^"]*\.tar\.zst' | head -n1)
- mkdir -p _sdk
- 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: 缓存依赖源码下载
+ uses: actions/cache@v5
+ with:
+ path: ${{ env.SDK_DIR }}/dl
+ key: adguardhome-dl-v1-${{ runner.os }}-${{ steps.sdk.outputs.sha256 }}-${{ hashFiles('Makefile', '.github/workflows/build-apk.yaml') }}
+ restore-keys: |
+ adguardhome-dl-v1-${{ runner.os }}-${{ steps.sdk.outputs.sha256 }}-
- name: 构建包
run: |
+ set -euo pipefail
cd "$SDK_DIR"
mkdir -p 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 install luci-base tar
@@ -91,17 +128,30 @@ jobs:
make package/compile -j"$(nproc)" V=sc
- - name: 上传产物
- uses: actions/upload-artifact@v4
+ - name: 整理 APK 产物
+ 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:
- name: pkg-${{ github.ref_name }}
- path: _sdk/**/bin/packages/**/${{ env.PKG_DIR_NAME }}*
+ path: dist/*.apk
+ archive: false
if-no-files-found: error
- name: 发布到 Releases
+ if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
prerelease: ${{ startsWith(github.ref_name, 'dev-') }}
make_latest: ${{ startsWith(github.ref_name, 'dev-') && 'false' || 'true' }}
- files: _sdk/**/bin/packages/**/${{ env.PKG_DIR_NAME }}*
\ No newline at end of file
+ fail_on_unmatched_files: true
+ files: dist/*.apk
diff --git a/luci-app-adguardhome/.gitignore b/luci-app-adguardhome/.gitignore
index 496ee2ca..05dfba47 100644
--- a/luci-app-adguardhome/.gitignore
+++ b/luci-app-adguardhome/.gitignore
@@ -1 +1,2 @@
-.DS_Store
\ No newline at end of file
+.DS_Store
+.local
\ No newline at end of file
diff --git a/luci-app-adguardhome/Makefile b/luci-app-adguardhome/Makefile
index 64d690fb..07cea060 100644
--- a/luci-app-adguardhome/Makefile
+++ b/luci-app-adguardhome/Makefile
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-adguardhome
-PKG_VERSION:=2.4.1
-PKG_RELEASE:=1
+PKG_VERSION:=2.5.0
+PKG_RELEASE:=2
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
@@ -24,7 +24,7 @@ define Package/luci-app-adguardhome
TITLE:=LuCI app for AdGuardHome
PKG_MAINTAINER:=https://github.com/w9315273/luci-app-adguardhome
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
define Package/luci-app-adguardhome/description
@@ -61,7 +61,9 @@ if [ "$$enable" = "1" ]; then
/etc/init.d/AdGuardHome reload
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/*
/etc/init.d/rpcd reload
diff --git a/luci-app-adguardhome/README.md b/luci-app-adguardhome/README.md
deleted file mode 100644
index 7749f150..00000000
--- a/luci-app-adguardhome/README.md
+++ /dev/null
@@ -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`
-
-## 界面
-
-
-
-
diff --git a/luci-app-adguardhome/docs/README.en.md b/luci-app-adguardhome/docs/README.en.md
new file mode 100644
index 00000000..4145f130
--- /dev/null
+++ b/luci-app-adguardhome/docs/README.en.md
@@ -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
+
+
+
+### Manual configuration
+
+
+
+### Logs
+
+
diff --git a/luci-app-adguardhome/docs/README.md b/luci-app-adguardhome/docs/README.md
new file mode 100644
index 00000000..d3aa8d9d
--- /dev/null
+++ b/luci-app-adguardhome/docs/README.md
@@ -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
+> ```
+
+## 截图
+
+### 基础设置
+
+
+
+### 手动设置
+
+
+
+### 日志
+
+
diff --git a/luci-app-adguardhome/docs/screenshots/base-en.png b/luci-app-adguardhome/docs/screenshots/base-en.png
new file mode 100644
index 00000000..6e67b273
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/base-en.png differ
diff --git a/luci-app-adguardhome/docs/screenshots/base-zh-cn.png b/luci-app-adguardhome/docs/screenshots/base-zh-cn.png
new file mode 100644
index 00000000..221291f6
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/base-zh-cn.png differ
diff --git a/luci-app-adguardhome/docs/screenshots/log-en.png b/luci-app-adguardhome/docs/screenshots/log-en.png
new file mode 100644
index 00000000..554ae409
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/log-en.png differ
diff --git a/luci-app-adguardhome/docs/screenshots/log-zh-cn.png b/luci-app-adguardhome/docs/screenshots/log-zh-cn.png
new file mode 100644
index 00000000..c16c2aa7
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/log-zh-cn.png differ
diff --git a/luci-app-adguardhome/docs/screenshots/manual-en.png b/luci-app-adguardhome/docs/screenshots/manual-en.png
new file mode 100644
index 00000000..c05878cc
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/manual-en.png differ
diff --git a/luci-app-adguardhome/docs/screenshots/manual-zh-cn.png b/luci-app-adguardhome/docs/screenshots/manual-zh-cn.png
new file mode 100644
index 00000000..870cafef
Binary files /dev/null and b/luci-app-adguardhome/docs/screenshots/manual-zh-cn.png differ
diff --git a/luci-app-adguardhome/po/zh-cn/AdGuardHome.po b/luci-app-adguardhome/po/zh-cn/AdGuardHome.po
index d6a9ea6c..9361fc0d 100644
--- a/luci-app-adguardhome/po/zh-cn/AdGuardHome.po
+++ b/luci-app-adguardhome/po/zh-cn/AdGuardHome.po
@@ -17,48 +17,36 @@ msgstr "启用"
msgid "Browser management port"
msgstr "网页管理端口"
-msgid "Redirect mode"
-msgstr "重定向模式"
+msgid "DNS handling mode"
+msgstr "DNS 接入方式"
-msgid "AdGuardHome redirect mode"
-msgstr "AdGuardHome 重定向模式"
-
-msgid "None"
-msgstr "无"
+msgid "Do not handle DNS"
+msgstr "不接管 DNS"
msgid "Run as dnsmasq upstream server"
msgstr "作为 dnsmasq 的上游服务器"
-msgid "Redirect port 53 to AdGuardHome"
-msgstr "将 53 端口重定向到 AdGuardHome"
+msgid "Forward port 53 requests through the firewall"
+msgstr "通过防火墙转发 53 端口请求"
msgid "Use port 53 instead of dnsmasq"
msgstr "使用 53 端口替代 dnsmasq"
-msgid "WAN Interface"
-msgstr "WAN 接口"
+msgid "Interfaces excluded from forwarding"
+msgstr "不参与转发的接口"
-msgid "Only effective in redirect mode, bypass specified interfaces to avoid becoming a public DNS resolver"
-msgstr "仅在重定向模式下生效,绕过指定接口以避免成为公共 DNS 解析器"
+msgid "DNS requests arriving on these interfaces are not forwarded to AdGuardHome"
+msgstr "从这些接口进入的 DNS 请求不转发到 AdGuardHome"
msgid "Bin Path"
msgstr "执行文件路径"
-msgid "AdGuardHome binary path"
-msgstr "AdGuardHome 执行文件路径"
-
msgid "Config Path"
msgstr "配置文件路径"
-msgid "AdGuardHome config path"
-msgstr "AdGuardHome 配置文件路径"
-
msgid "Work dir"
msgstr "工作目录"
-msgid "AdGuardHome work dir including rules, audit log and database"
-msgstr "AdGuardHome 工作目录,包含规则、审计日志和数据库"
-
msgid "Runtime log file"
msgstr "运行日志文件"
@@ -208,3 +196,36 @@ msgstr "恢复失败,备份文件位于"
msgid "Config changed on disk; reload before saving."
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 "无法应用详细日志设置"
diff --git a/luci-app-adguardhome/root/etc/init.d/AdGuardHome b/luci-app-adguardhome/root/etc/init.d/AdGuardHome
index c5fc4579..dfa233b6 100755
--- a/luci-app-adguardhome/root/etc/init.d/AdGuardHome
+++ b/luci-app-adguardhome/root/etc/init.d/AdGuardHome
@@ -56,43 +56,195 @@ clear_nft_redirect() {
nft delete table inet "$NFT_TABLE" 2>/dev/null
}
-set_forward_dnsmasq() {
- local PORT="$1"
- addr="127.0.0.1#$PORT"
- OLD_SERVER="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)"
- echo "$OLD_SERVER" | grep -q "^$addr" && return 0
-
- uci delete dhcp.@dnsmasq[0].server 2>/dev/null
- 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"
+wait_for_pid_exit() {
+ local pid="$1" attempts=0
+ case "$pid" in ''|*[!0-9]*) return 0 ;; esac
+ while kill -0 "$pid" 2>/dev/null; do
+ [ "$attempts" -lt 15 ] || return 1
+ sleep 1
+ attempts=$((attempts + 1))
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() {
- local OLD_PORT="$1"
- addr="127.0.0.1#$OLD_PORT"
- OLD_SERVER="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)"
- echo "$OLD_SERVER" | grep -q "^$addr" || return 0
+wait_for_dns_port_release() {
+ local port="$1" attempts=0 tables="" table busy
+ for table in /proc/net/tcp /proc/net/tcp6 /proc/net/udp /proc/net/udp6; do
+ [ -r "$table" ] && tables="$tables $table"
+ 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
- addrlist="$(uci get dhcp.@dnsmasq[0].server 2>/dev/null)"
- if [ -z "$addrlist" ]; then
- resolvfile="/tmp/resolv.conf.d/resolv.conf.auto"
- [ ! -f "$resolvfile" ] && resolvfile="/tmp/resolv.conf.auto"
- uci set dhcp.@dnsmasq[0].resolvfile="$resolvfile" 2>/dev/null
- uci delete dhcp.@dnsmasq[0].noresolv 2>/dev/null
+restart_dnsmasq() {
+ local released_port="$1" service pids pid
+ service="$(ubus call service list '{"name":"dnsmasq"}' 2>/dev/null)" || return 1
+ pids="$(printf '%s' "$service" | jsonfilter -e '@.dnsmasq.instances.*.pid')"
+ /etc/init.d/dnsmasq stop 8>&- 1000>&- || return 1
+ for pid in $pids; do
+ 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
- uci commit dhcp
- /etc/init.d/dnsmasq restart >/dev/null 2>&1
}
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
}
@@ -100,7 +252,7 @@ rm_port53() {
local configpath
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")
dnsmasq_port=$(uci get dhcp.@dnsmasq[0].port 2>/dev/null)
[ -z "$dnsmasq_port" ] && dnsmasq_port="53"
@@ -111,13 +263,12 @@ rm_port53() {
return
fi
+ stop_core || return 1
config_editor "dns.port" "$dnsmasq_port" "$configpath"
- uci set dhcp.@dnsmasq[0].port="53"
- uci commit dhcp
-
- config_get binpath $CONFIGURATION binpath "/usr/bin/AdGuardHome/AdGuardHome"
- killall -9 "$binpath" 2>/dev/null
- /etc/init.d/dnsmasq restart
+ [ "$(config_editor "dns.port" "" "$configpath" "1")" = "$dnsmasq_port" ] || return 1
+ uci set dhcp.@dnsmasq[0].port="53" || return 1
+ uci commit dhcp || return 1
+ apply_dnsmasq_config "$dnsmasq_port" || return 1
agh_reload
}
@@ -136,10 +287,12 @@ use_port53() {
return
fi
+ stop_core || return 1
config_editor "dns.port" "53" "$configpath"
- uci set dhcp.@dnsmasq[0].port="$AdGuardHome_PORT"
- uci commit dhcp
- /etc/init.d/dnsmasq restart
+ [ "$(config_editor "dns.port" "" "$configpath" "1")" = "53" ] || return 1
+ uci set dhcp.@dnsmasq[0].port="$AdGuardHome_PORT" || return 1
+ uci commit dhcp || return 1
+ apply_dnsmasq_config "$dnsmasq_port" || return 1
agh_reload
}
@@ -175,12 +328,44 @@ mark_redirect_flag() {
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"
args=""
ipv6_server=1
tcp_server=0
enabled="$1"
+ AGH_REDIRECT_RELOAD="${2:-$enabled}"
config_get configpath $CONFIGURATION configpath "/etc/AdGuardHome.yaml"
AdGuardHome_PORT="$(config_editor "dns.port" "" "$configpath" "1")"
@@ -203,9 +388,18 @@ _do_redirect() {
if [ "$old_redirect" = "redirect" ] && [ "$old_port" != "0" ]; then
clear_nft_redirect
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
- rm_port53
+ if ! rm_port53; then
+ echo -n "0" > /var/run/AdGredir
+ return 1
+ fi
fi
fi
elif [ "$old_enabled" = "1" ] && [ "$enabled" = "1" ]; then
@@ -223,6 +417,7 @@ _do_redirect() {
uci commit AdGuardHome
if [ "$enabled" = "0" ] || [ "$AdGuardHome_PORT" = "0" ]; then
+ finish_dnsmasq_config || return 1
echo -n "0" > /var/run/AdGredir
return 1
fi
@@ -230,13 +425,23 @@ _do_redirect() {
if [ "$redirect" = "redirect" ]; then
set_nft_redirect "$AdGuardHome_PORT"
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
- use_port53
+ if ! use_port53; then
+ echo -n "0" > /var/run/AdGredir
+ return 1
+ 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() {
config_load "${CONFIGURATION}"
@@ -326,6 +531,7 @@ boot_service() {
}
start_service() {
+ stop_core || return 1
rm -f /var/run/AdGserverdis 2>/dev/null
config_load "${CONFIGURATION}"
config_get hashpass $CONFIGURATION hashpass ""
@@ -379,7 +585,7 @@ start_service() {
if [ ! -f "$binpath" ]; then
_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
fi
@@ -388,6 +594,20 @@ start_service() {
ADDITIONAL_ARGS="$ADDITIONAL_ARGS -v"
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
get_tz
if [ -n "$SET_TZ" ]; then
@@ -399,10 +619,7 @@ start_service() {
procd_set_param command "$binpath" $ADDITIONAL_ARGS
procd_set_param file "$configpath" "/etc/hosts" "/etc/config/AdGuardHome"
procd_close_instance
- if [ -f "$configpath" ]; then
- (sleep 5 && _do_redirect 1) &
- else
- _do_redirect 0
+ if [ ! -f "$configpath" ]; then
config_get "redirect" "AdGuardHome" "redirect" "none"
if [ "$redirect" != "none" ]; then
procd_open_instance "waitconfig"
@@ -413,12 +630,7 @@ start_service() {
fi
echo "AdGuardHome service 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() {
@@ -428,6 +640,7 @@ reload_service() {
}
stop_service() {
+ stop_core || return 1
config_load "${CONFIGURATION}"
_do_redirect 0
echo "AdGuardHome service disabled"
diff --git a/luci-app-adguardhome/root/etc/uci-defaults/40_luci-AdGuardHome b/luci-app-adguardhome/root/etc/uci-defaults/40_luci-AdGuardHome
index dc177342..e736ba60 100755
--- a/luci-app-adguardhome/root/etc/uci-defaults/40_luci-AdGuardHome
+++ b/luci-app-adguardhome/root/etc/uci-defaults/40_luci-AdGuardHome
@@ -7,5 +7,8 @@ uci -q batch <<-EOF >/dev/null 2>&1
commit ucitrack
EOF
-rm -f /tmp/luci-indexcache
-exit 0
\ No newline at end of file
+# Remove the former menu and both Lua and ucode menu caches.
+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
diff --git a/luci-app-adguardhome/root/usr/share/AdGuardHome/update_core.sh b/luci-app-adguardhome/root/usr/share/AdGuardHome/update_core.sh
index 546ac0a4..2be7a26c 100755
--- a/luci-app-adguardhome/root/usr/share/AdGuardHome/update_core.sh
+++ b/luci-app-adguardhome/root/usr/share/AdGuardHome/update_core.sh
@@ -2,6 +2,28 @@
PATH="/usr/sbin:/usr/bin:/sbin:/bin"
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)
if [ -z "$binpath" ]; then
uci set AdGuardHome.AdGuardHome.binpath="/tmp/AdGuardHome/AdGuardHome"
@@ -11,31 +33,9 @@ fi
enabled=$(uci get AdGuardHome.AdGuardHome.enabled 2>/dev/null)
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(){
downloader_cmd="uclient-fetch"
- downloader_opts="--no-check-certificate -T 5 -q -O"
+ downloader_opts="-T 5 -q -O"
downloader_quiet_opts="-q -O -"
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)"
@@ -67,7 +67,7 @@ Update_Core(){
printf "\n开始下载 AdGuardHome 核心文件 ...\n\n"
${downloader_cmd} ${downloader_opts} "/tmp/AdGuardHome_Update/${link##*/}" "$link" > /dev/null 2>&1
if [ $? -ne 0 ]; then
- printf "\nAdGuardHome 核心下载失败 ...\n"
+ printf "\nAdGuardHome 核心下载失败,请检查网络、系统时间及 CA 证书 ...\n"
rm -rf /tmp/AdGuardHome_Update
EXIT 1
fi
@@ -87,10 +87,9 @@ Update_Core(){
chmod +x "$downloadbin"
size_bytes=$(wc -c < "$downloadbin" 2>/dev/null || echo 0)
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%/*}"
- mv -f "$downloadbin" "$binpath" > /dev/null 2>&1
- if [ ! -s "$binpath" ] && [ $? -ne 0 ]; then
+ if ! mv -f "$downloadbin" "$binpath" > /dev/null 2>&1 || [ ! -s "$binpath" ]; then
printf "AdGuardHome 核心移动失败!\n可能是设备空间不足导致, 请尝试更改 [执行文件路径] 为 /tmp/AdGuardHome\n"
EXIT 1
fi
@@ -98,7 +97,7 @@ Update_Core(){
chmod +x "$binpath"
if [ "$enabled" = 1 ]; then
printf "\n正在重启 AdGuardHome 服务...\n"
- /etc/init.d/AdGuardHome restart > /dev/null 2>&1
+ /etc/init.d/AdGuardHome restart 9>&- > /dev/null 2>&1
fi
printf "\nAdGuardHome 核心更新成功!\n"
}
@@ -159,18 +158,7 @@ GET_Arch() {
}
EXIT(){
- rm -rf /var/run/update_core $LOCKU 2>/dev/null
- [ "$1" -ne 0 ] && touch /var/run/update_core_error
- exit $1
+ exit "$1"
}
-main(){
- 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
\ No newline at end of file
+Check_Updates "$update_mode"
diff --git a/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome-js.json b/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json
similarity index 83%
rename from luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome-js.json
rename to luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json
index c7bebea7..6cdcf8bb 100644
--- a/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome-js.json
+++ b/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json
@@ -1,6 +1,6 @@
{
- "admin/services/AdGuardHomeJS": {
- "title": "AdGuard Home JS",
+ "admin/services/AdGuardHome": {
+ "title": "AdGuard Home",
"order": 12,
"action": {
"type": "firstchild"
@@ -9,7 +9,7 @@
"acl": [ "luci-app-adguardhome" ]
}
},
- "admin/services/AdGuardHomeJS/base": {
+ "admin/services/AdGuardHome/base": {
"title": "Base Setting",
"order": 1,
"action": {
@@ -20,7 +20,7 @@
"acl": [ "luci-app-adguardhome" ]
}
},
- "admin/services/AdGuardHomeJS/log": {
+ "admin/services/AdGuardHome/log": {
"title": "Log",
"order": 3,
"action": {
@@ -31,7 +31,7 @@
"acl": [ "luci-app-adguardhome" ]
}
},
- "admin/services/AdGuardHomeJS/manual": {
+ "admin/services/AdGuardHome/manual": {
"title": "Manual Config",
"order": 2,
"action": {
@@ -42,4 +42,4 @@
"acl": [ "luci-app-adguardhome" ]
}
}
-}
\ No newline at end of file
+}
diff --git a/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json b/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json
index aa6dfeca..41727fce 100644
--- a/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json
+++ b/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json
@@ -7,13 +7,13 @@
},
"ubus": {
"service": [ "list" ],
- "luci.adguardhome": [ "getCoreInfo", "getCoreUpdate", "getInterfaces", "getRuntimeLog", "getManualConfig" ]
+ "luci.adguardhome": [ "getCoreInfo", "getRedirectStatus", "getCoreUpdate", "getInterfaces", "getRuntimeLog", "getManualConfig" ]
},
"uci": [ "AdGuardHome" ]
},
"write": {
"ubus": {
- "luci.adguardhome": [ "startCoreUpdate", "changeWebPassword", "clearRuntimeLog", "setManualConfig" ]
+ "luci.adguardhome": [ "setVerboseLog", "startCoreUpdate", "changeWebPassword", "clearRuntimeLog", "setManualConfig" ]
},
"uci": [ "AdGuardHome" ]
}
diff --git a/luci-app-adguardhome/root/usr/share/rpcd/ucode/luci.adguardhome b/luci-app-adguardhome/root/usr/share/rpcd/ucode/luci.adguardhome
index d4987a4a..6e46f612 100644
--- a/luci-app-adguardhome/root/usr/share/rpcd/ucode/luci.adguardhome
+++ b/luci-app-adguardhome/root/usr/share/rpcd/ucode/luci.adguardhome
@@ -1,15 +1,48 @@
#!/usr/bin/ucode
'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';
function shellQuote(value) {
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 {
'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: {
call: function() {
const conf = cursor();
@@ -98,8 +131,9 @@ return {
},
getCoreUpdate: {
call: function() {
- const running = stat('/var/run/update_core') != null;
- const failed = stat('/var/run/update_core_error') != null;
+ const running = coreUpdateRunning();
+ const failed = stat('/var/run/update_core_error') != null ||
+ (!running && stat('/var/run/update_core') != null);
const log = readfile('/tmp/AdGuardHome_update.log', 131072) || '';
return {
@@ -111,15 +145,12 @@ return {
startCoreUpdate: {
args: { force: false },
call: function(request) {
- if (stat('/var/run/update_core') != null)
+ if (coreUpdateRunning())
return { started: false, reason: 'busy' };
- unlink('/var/run/update_core_error');
- unlink('/tmp/AdGuardHome_update.log');
-
const command = request.args.force
- ? '/bin/sh /usr/share/AdGuardHome/update_core.sh force >/tmp/AdGuardHome_update.log 2>&1 /tmp/AdGuardHome_update.log 2>&1 /dev/null 2>&1 /dev/null 2>&1 /dev/null 2>&1 &'], 3000);
}
- const log = readfile(source) || '';
- const size = length(log);
- let offset = +request.args.offset || 0;
- if (offset < 0 || offset > size)
+ // Bound memory and start with the most recent 256 KiB on a new view.
+ const limit = 262144;
+ const info = stat(source);
+ 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;
- 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 {
enabled: true,
localtime: logfile != 'syslog',
- content: content,
- offset: offset + length(content)
+ // ubus strings stop at NUL; cleared files can contain sparse zero-filled gaps.
+ content_b64: b64enc(content),
+ offset: offset + length(content),
+ identity: identity,
+ reset: reset
};
}
},
diff --git a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/base.js b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/base.js
index b17993a2..07334ac8 100644
--- a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/base.js
+++ b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/base.js
@@ -3,6 +3,7 @@
'require form';
'require poll';
'require rpc';
+'require ui';
'require view/adguardhome/status as status';
var callInterfaces = rpc.declare({
@@ -28,12 +29,27 @@ var callStartCoreUpdate = rpc.declare({
});
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() {
return callInterfaces().catch(function() { return { interfaces: [] }; });
},
render: function(data) {
let m, s, o;
+ var self = this;
var interfaces = data.interfaces || [];
m = new form.Map('AdGuardHome');
@@ -52,6 +68,7 @@ return view.extend({
'enabled',
_('Enable')
);
+ this.enabledOption = o;
o.default = '0';
o.rmempty = false;
@@ -68,13 +85,13 @@ return view.extend({
o = s.option(
form.ListValue,
'redirect',
- _('Redirect mode'),
- _('AdGuardHome redirect mode')
+ _('DNS handling 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('redirect', _('Redirect port 53 to AdGuardHome'));
o.value('exchange', _('Use port 53 instead of dnsmasq'));
o.default = 'none';
@@ -83,9 +100,11 @@ return view.extend({
o = s.option(
form.DynamicList,
'wan_ifname',
- _('WAN Interface'),
- _('Only effective in redirect mode, bypass specified interfaces to avoid becoming a public DNS resolver')
+ _('Interfaces excluded from forwarding'),
+ _('DNS requests arriving on these interfaces are not forwarded to AdGuardHome')
);
+ o.depends('redirect', 'redirect');
+ o.retain = true;
o.datatype = 'string';
o.rmempty = true;
interfaces.filter(function(name) { return name !== 'lo'; }).forEach(function(name) {
@@ -95,8 +114,7 @@ return view.extend({
o = s.option(
form.Value,
'binpath',
- _('Bin Path'),
- _('AdGuardHome binary path')
+ _('Bin Path')
);
o.default = '/usr/bin/AdGuardHome/AdGuardHome';
o.rmempty = false;
@@ -104,8 +122,7 @@ return view.extend({
o = s.option(
form.Value,
'configpath',
- _('Config Path'),
- _('AdGuardHome config path')
+ _('Config Path')
);
o.default = '/etc/AdGuardHome.yaml';
o.rmempty = false;
@@ -113,50 +130,37 @@ return view.extend({
o = s.option(
form.Value,
'workdir',
- _('Work dir'),
- _('AdGuardHome work dir including rules, audit log and database')
+ _('Work dir')
);
o.default = '/usr/bin/AdGuardHome';
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) {
var sharedStatus = status.render();
+ self.sharedStatus = sharedStatus;
var updateButton = E('button', {
'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
}, _('Update core version'));
var forceButton = E('button', {
- 'class': 'btn cbi-button', 'type': 'button', 'style': 'margin-left:0.6em;'
+ 'class': 'btn cbi-button', 'type': 'button'
}, _('Force update'));
- var updateMessage = E('span', { 'style': 'margin-left:0.8em;' }, '');
+ var updateMessage = E('span', {}, '');
var reverseLog = E('input', { 'type': 'checkbox' });
var updateLog = E('textarea', {
- 'class': 'cbi-input-textarea', 'rows': 8, 'readonly': 'readonly',
- 'style': 'display:none;width:100%;margin-top:0.6em;box-sizing:border-box;'
+ 'class': 'cbi-input-textarea agh-update-log', 'rows': 8, 'readonly': 'readonly',
+ 'style': 'display:none;'
});
- var logOptions = E('label', { 'style': 'display:none;margin-top:0.6em;' }, [
- reverseLog, ' ' + _('reverse')
+ var logOptions = E('label', { 'class': 'agh-update-options', 'style': 'display:none;' }, [
+ reverseLog, E('span', {}, _('reverse'))
]);
- var updatePanel = E('div', { 'class': 'cbi-section' }, [
- E('h3', {}, _('Upgrade Core')),
- E('div', { 'style': 'padding:0.6em;' }, [updateButton, forceButton, updateMessage]),
- logOptions, updateLog
+ var updatePanel = E('div', { 'class': 'cbi-value' }, [
+ E('div', { 'class': 'cbi-value-title' }, _('Upgrade Core')),
+ E('div', { 'class': 'cbi-value-field' }, E('div', { 'class': 'agh-update-content' }, [
+ E('div', { 'class': 'agh-toolbar' }, [updateButton, forceButton, updateMessage]),
+ logOptions, updateLog
+ ]))
]);
var lastLog = '';
var startingAt = 0;
@@ -186,7 +190,8 @@ return view.extend({
updateMessage.textContent = state === 'running' ? _('Check...') :
state === 'finished' ? _('Updated') : _('Update failed');
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();
} else {
updateMessage.textContent = '';
@@ -227,8 +232,14 @@ return view.extend({
updateMessage.textContent = _('Update status unavailable');
});
}, 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);
- 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 portField = node.querySelector('[data-name="httpport"] .cbi-value-field');
@@ -239,7 +250,7 @@ return view.extend({
'target': '_blank',
'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;
if (host.indexOf(':') >= 0 && host.charAt(0) !== '[')
host = '[' + host + ']';
@@ -262,4 +273,4 @@ return view.extend({
return node;
});
}
-});
\ No newline at end of file
+});
diff --git a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/compact.css b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/compact.css
new file mode 100644
index 00000000..ec755092
--- /dev/null
+++ b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/compact.css
@@ -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;
+}
diff --git a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/log.js b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/log.js
index d44dd65c..8fc0bf9e 100644
--- a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/log.js
+++ b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/log.js
@@ -1,5 +1,6 @@
'use strict';
'require view';
+'require form';
'require poll';
'require rpc';
'require view/adguardhome/status as status';
@@ -7,7 +8,7 @@
var callRuntimeLog = rpc.declare({
object: 'luci.adguardhome',
method: 'getRuntimeLog',
- params: [ 'offset' ],
+ params: [ 'offset', 'identity' ],
expect: { '': {} },
reject: true
});
@@ -19,6 +20,28 @@ var callClearRuntimeLog = rpc.declare({
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) {
return value < 10 ? '0' + value : value;
}
@@ -35,120 +58,185 @@ function localizeTimestamps(content) {
}
return view.extend({
- handleSave: null,
- handleSaveApply: null,
- handleReset: null,
-
load: function() {
- return callRuntimeLog(0).catch(function() {
- return { enabled: false, localtime: false, content: '', offset: 0 };
+ return callRuntimeLog(0, '').catch(function() {
+ return { error: true, localtime: false, content: '', offset: 0 };
});
},
render: function(initialLog) {
- var sharedStatus = status.render();
- var content = initialLog.content || '';
- var offset = initialLog.offset || 0;
- var enabled = initialLog.enabled;
- var localtime = initialLog.localtime;
- 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;' }, '');
+ var m = new form.Map('AdGuardHome');
+ var s = m.section(form.TypedSection, 'AdGuardHome');
+ s.anonymous = true;
+ s.addremove = false;
+ var o;
- function renderLog() {
- var displayed = convertTime ? localizeTimestamps(content) : content;
- logArea.value = reverse ? displayed.split('\n').reverse().join('\n') : displayed;
- }
+ o = s.option(
+ form.Value,
+ 'logfile',
+ _('Runtime log file')
+ );
+ o.rmempty = true;
- function downloadLog() {
- var now = new Date();
- var link = document.createElement('a');
- link.download = 'AdGuardHome' + (now.getMonth() + 1) + '-' + now.getDate() + '-' +
- pad(now.getHours()) + '_' + pad(now.getMinutes()) + '.log';
- link.href = URL.createObjectURL(new Blob([logArea.value], { type: 'text/plain' }));
- link.click();
- URL.revokeObjectURL(link.href);
- }
+ return m.render().then(function(settingsNode) {
+ var sharedStatus = status.render();
+ var logLimit = 262144;
+ var content = decodeLog(initialLog).slice(-logLimit);
+ var identity = initialLog.identity || '';
+ var generation = 0;
+ var offset = initialLog.offset || 0;
+ 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', {
- 'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
- }, _('dellog'));
- var downloadButton = E('button', {
- 'class': 'btn cbi-button', 'type': 'button', 'style': 'margin-left:0.6em;'
- }, _('download log'));
- reverseToggle.addEventListener('change', function() {
- reverse = reverseToggle.checked;
- renderLog();
- });
- timeToggle.addEventListener('change', function() {
- convertTime = timeToggle.checked;
- renderLog();
- });
- clearButton.addEventListener('click', function() {
- clearButton.disabled = true;
- callClearRuntimeLog().then(function(result) {
- if (!result.cleared)
- throw new Error(result.reason || 'clear failed');
- content = '';
- offset = 0;
- message.textContent = '';
+ function renderLog() {
+ var displayed = convertTime ? localizeTimestamps(content) : content;
+ logArea.value = reverse ? displayed.split('\n').reverse().join('\n') : displayed;
+ }
+
+ function downloadLog() {
+ var now = new Date();
+ var link = document.createElement('a');
+ link.download = 'AdGuardHome' + (now.getMonth() + 1) + '-' + now.getDate() + '-' +
+ pad(now.getHours()) + '_' + pad(now.getMinutes()) + '.log';
+ link.href = URL.createObjectURL(new Blob([logArea.value], { type: 'text/plain' }));
+ link.click();
+ URL.revokeObjectURL(link.href);
+ }
+
+ var clearButton = E('button', {
+ 'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
+ }, _('dellog'));
+ var downloadButton = E('button', {
+ 'class': 'btn cbi-button', 'type': 'button'
+ }, _('download log'));
+ reverseToggle.addEventListener('change', function() {
+ reverse = reverseToggle.checked;
renderLog();
- }).catch(function() {
- message.textContent = _('Status unavailable');
- }).then(function() {
- clearButton.disabled = false;
});
- });
- downloadButton.addEventListener('click', downloadLog);
-
- var page = E('div', { 'class': 'cbi-map' }, [
- sharedStatus.node,
- logArea,
- E('div', { 'style': 'display:flex;align-items:center;padding:0.6em;' }, [
- reverseLabel, timeLabel
- ]),
- E('div', { 'style': 'padding:0 0.6em 0.6em;' }, [clearButton, downloadButton, message])
- ]);
-
- 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)
+ timeToggle.addEventListener('change', function() {
+ convertTime = timeToggle.checked;
+ renderLog();
+ });
+ clearButton.addEventListener('click', function() {
+ clearButton.disabled = true;
+ generation++;
+ callClearRuntimeLog().then(function(result) {
+ if (!result.cleared)
+ throw new Error(result.reason || 'clear failed');
content = '';
- offset = result.offset;
- if (result.content) {
- content += result.content;
+ offset = 0;
+ identity = '';
+ generation++;
+ message.textContent = '';
renderLog();
- }
- }).catch(function() {
- message.textContent = _('Status unavailable');
+ }).catch(function() {
+ 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;
+ });
}
});
\ No newline at end of file
diff --git a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/manual.js b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/manual.js
index 8e280f2d..638604de 100644
--- a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/manual.js
+++ b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/manual.js
@@ -102,11 +102,11 @@ return view.extend({
var message = E('span', { 'style': 'margin-left:0.8em;' }, '');
var configArea = E('textarea', {
'class': 'cbi-input-textarea',
- 'rows': 40,
+ 'rows': 20,
'wrap': 'off',
'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', {
'class': 'btn cbi-button', 'type': 'button'
}, _('Use template'));
@@ -118,22 +118,15 @@ return view.extend({
}, _('Save & Apply'));
var passwordInput = E('input', {
'class': 'cbi-input-password', 'type': 'password',
- 'autocomplete': 'new-password', 'spellcheck': 'false'
+ 'id': 'agh-web-password', 'autocomplete': 'new-password', 'spellcheck': 'false'
});
var passwordButton = E('button', {
'class': 'btn cbi-button cbi-button-apply', 'type': 'button'
}, _('Change password'));
var passwordMessage = E('span', { 'style': 'margin-left:0.8em;' }, '');
- var passwordPanel = E('div', { 'class': 'cbi-section' }, [
- E('h3', {}, _('Change browser management password')),
- E('div', { 'class': 'cbi-value', 'style': 'padding:0.6em 0;' }, [
- E('label', { 'class': 'cbi-value-title' }, _('New password')),
- E('div', { 'class': 'cbi-value-field' }, [
- passwordInput,
- E('span', { 'style': 'margin-left:0.6em;' }, passwordButton),
- passwordMessage
- ])
- ])
+ var passwordPanel = E('div', { 'class': 'agh-toolbar agh-password-controls' }, [
+ E('label', { 'for': 'agh-web-password' }, _('Change browser management password')),
+ passwordInput, passwordButton, passwordMessage
]);
function showConfig() {
@@ -207,14 +200,15 @@ return view.extend({
showConfig();
var page = E('div', {}, [
+ E('link', { 'rel': 'stylesheet', 'href': L.resource('view/adguardhome/compact.css') }),
sharedStatus.node,
- E('div', { 'class': 'cbi-section' }, [
- E('div', { 'style': 'padding:0.6em;' }, [
- templateButton, reloadButton, saveButton, message
- ]),
- editorWrap
- ]),
- passwordPanel
+ E('div', { 'class': 'cbi-section agh-compact-card agh-manual-card' }, [
+ editorWrap,
+ E('div', { 'class': 'agh-manual-toolbar' }, [
+ passwordPanel,
+ E('div', { 'class': 'agh-toolbar agh-config-actions' }, [message, templateButton, reloadButton, saveButton])
+ ])
+ ])
]);
loadCodeMirror().then(function(CodeMirror) {
editor = CodeMirror.fromTextArea(configArea, {
@@ -226,6 +220,8 @@ return view.extend({
});
editor.getWrapperElement().style.fontSize = '15px';
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();
showConfig();
}).catch(function() {
@@ -233,4 +229,4 @@ return view.extend({
});
return page;
}
-});
\ No newline at end of file
+});
diff --git a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/status.js b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/status.js
index 5e59b009..f976e2a9 100644
--- a/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/status.js
+++ b/luci-app-adguardhome/root/www/luci-static/resources/view/adguardhome/status.js
@@ -2,8 +2,6 @@
'require baseclass';
'require poll';
'require rpc';
-'require fs';
-'require uci';
var callCoreInfo = rpc.declare({
object: 'luci.adguardhome',
@@ -20,15 +18,20 @@ var callServiceList = rpc.declare({
reject: true
});
-var loadConfigPromise;
+var callRedirectStatus = rpc.declare({
+ object: 'luci.adguardhome',
+ method: 'getRedirectStatus',
+ expect: { '': {} },
+ reject: true
+});
-function getConfiguredBinPath() {
- if (!loadConfigPromise)
- loadConfigPromise = uci.load('AdGuardHome').catch(function() { return null; });
-
- return loadConfigPromise.then(function() {
- return uci.get('AdGuardHome', 'AdGuardHome', 'binpath') || '/usr/bin/AdGuardHome/AdGuardHome';
- });
+function modeLabel(mode) {
+ return {
+ none: _('Do not handle DNS'),
+ 'dnsmasq-upstream': _('Run as dnsmasq upstream server'),
+ redirect: _('Forward port 53 requests through the firewall'),
+ exchange: _('Use port 53 instead of dnsmasq')
+ }[mode] || _('Status unavailable');
}
function coreIsRunning(services, binpath) {
@@ -57,9 +60,9 @@ return baseclass.extend({
function setBadge(badge, label, state) {
badge.textContent = label;
- badge.style.backgroundColor = state === true ? '#dcfce7' : state === false ? '#fee2e2' : '#e5e7eb';
- badge.style.color = state === true ? '#14532d' : state === false ? '#7f1d1d' : '#374151';
- badge.style.borderColor = state === true ? '#86efac' : state === false ? '#fca5a5' : '#d1d5db';
+ badge.style.backgroundColor = state === 'pending' ? '#fef3c7' : state === true ? '#dcfce7' : state === false ? '#fee2e2' : '#e5e7eb';
+ badge.style.color = state === 'pending' ? '#92400e' : state === true ? '#14532d' : state === false ? '#7f1d1d' : '#374151';
+ badge.style.borderColor = state === 'pending' ? '#fcd34d' : state === true ? '#86efac' : state === false ? '#fca5a5' : '#d1d5db';
}
function refreshCore() {
@@ -77,25 +80,125 @@ return baseclass.extend({
});
}
- refreshCore();
- poll.add(function() {
- var redirectFlag = fs.read('/var/run/AdGredir').catch(function(error) {
- if (error.name === 'NotFoundError')
- return '';
- throw error;
- });
+ var storageKey = 'adguardhome.pendingSwitch';
+ var pending = null;
+ var generation = 0;
+ var stable = 0;
+ var nextRefresh = 0;
+ 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) {
- var isRunning = coreIsRunning(results[1], results[0]);
- setBadge(serviceBadge, 'AdGuardHome: ' + (isRunning ? _('RUNNING') : _('NOT RUNNING')), isRunning);
- setBadge(redirectBadge, _('Redirect') + ': ' + (results[2].trim() === '1' ? _('Redirected') : _('Not redirect')),
- results[2].trim() === '1');
+ function remember() {
+ try {
+ if (pending)
+ sessionStorage.setItem(storageKey, JSON.stringify(pending));
+ 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() {
- setBadge(serviceBadge, _('Status unavailable'), null);
- setBadge(redirectBadge, _('Status unavailable'), null);
+ if (currentGeneration !== generation)
+ 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 };
}
-});
\ No newline at end of file
+});
diff --git a/luci-app-amlogic/Makefile b/luci-app-amlogic/Makefile
index 997e3bc8..7de59558 100644
--- a/luci-app-amlogic/Makefile
+++ b/luci-app-amlogic/Makefile
@@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic
# DD = day (01 = 1st)
# 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
-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)
# 1 = Lua version (lua branch) → tag: 26.10.0101
# 2 = JS version (main branch) → tag: 26.10.0101-js
diff --git a/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/check.js b/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/check.js
index 8243b4c5..9a4cb555 100644
--- a/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/check.js
+++ b/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/check.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-err' }, _('Invalid value.')));
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.')));
dom.content(verKernel, s && s.current_kernel_version
? E('span', { class: 'amlogic-status-ok' }, _('Current Version') + ' [ ' + s.current_kernel_version + ' ] ')
diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile
index b3e4b1ac..64e19b5b 100644
--- a/luci-app-honk/Makefile
+++ b/luci-app-honk/Makefile
@@ -5,14 +5,14 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-honk
-PKG_VERSION:=2026.10.24
+PKG_VERSION:=2026.10.25
PKG_RELEASE:=1
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_SUBDIR:=doona
-PKG_HASH:=f6612106faf8897178f91d464e0ba5f99eb46220eba0d984518104dd87fdcf57
+PKG_HASH:=e60ed58ef40e849269219de38dddf6974f1dbd2915675390d4b748eb7f78db2b
-include $(CURDIR)/generated-doona-stage.mk
diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js
index 8321756d..96a9e76b 100644
--- a/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js
+++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js
@@ -5,6 +5,7 @@
'require poll';
'require view';
'require view.honk.rpc as honk';
+'require uci';
function getNetworkNames(networks) {
if (!networks)
@@ -81,9 +82,10 @@ return view.extend({
load: function() {
return Promise.all([
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) {
- 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 setupArea = E('div');
let currentStatus = data.status;
+ const clashSecret = data.clash_secret || '';
let busy = false;
let setupBusy = false;
let settingsBusy = false;
@@ -128,7 +131,7 @@ return view.extend({
'type': webUi ? null : 'button',
'disabled': webUi ? null : true
}, _('Open Doona'));
- const clashUi = honk.localClashUi(status);
+ const clashUi = honk.localClashUi(status, clashSecret);
const openClash = E(clashUi ? 'a' : 'button', {
'class': 'cbi-button cbi-button-action',
'href': clashUi || null,
@@ -211,7 +214,8 @@ return view.extend({
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 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 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(_('Enable Clash API and Zashboard'), clashEnabledInput));
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('div', { 'class': 'cbi-page-actions' }, save));
form.appendChild(message);
@@ -248,10 +252,20 @@ return view.extend({
message.textContent = _('Choose a LAN network and a port from 1024 to 65535.');
return;
}
- if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535 ||
- clashEnabledInput.checked && (clashPort === port || !status.clash_api_secret_set && !clashSecret ||
- !!clashSecret && !/^[A-Za-z0-9_-]{16,128}$/.test(clashSecret))) {
- message.textContent = _('Choose a different Clash API port and enter a valid secret.');
+ if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
+ message.textContent = _('Clash API port must be between 1024 and 65535.');
+ return;
+ }
+ 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;
}
diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js
index 969ffc40..2f91162d 100644
--- a/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js
+++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js
@@ -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)
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) {
@@ -205,7 +222,7 @@ function tokenMessage(token) {
lan_network_unavailable: _('The selected LAN network is unavailable.'),
listen_port_occupied: _('The listen 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.'),
initialize_before_enabling_service: _('Complete initial setup before enabling Honk.'),
system_config_unavailable: _('Honk system configuration is unavailable.'),
diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js
index 39e6a231..1481a38f 100644
--- a/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js
+++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js
@@ -51,7 +51,8 @@ return view.extend({
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 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 save = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Save settings'));
save.disabled = !!data.statusError;
@@ -81,7 +82,7 @@ return view.extend({
section.appendChild(field(_('Enable Clash API and Zashboard'), clashEnabledInput));
section.appendChild(field(_('Clash API port'), clashPortInput));
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(message);
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.');
return;
}
- if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535 ||
- clashEnabledInput.checked && (clashPort === port || !(data.status && data.status.clash_api_secret_set) && !clashSecret ||
- !!clashSecret && !/^[A-Za-z0-9_-]{16,128}$/.test(clashSecret))) {
- message.textContent = _('Choose a different Clash API port and enter a valid secret.');
+ if (!Number.isInteger(clashPort) || clashPort < 1024 || clashPort > 65535) {
+ message.textContent = _('Clash API port must be between 1024 and 65535.');
+ return;
+ }
+ 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;
}
diff --git a/luci-app-honk/po/zh-cn/honk.po b/luci-app-honk/po/zh-cn/honk.po
index 0908f900..1c233a0f 100644
--- a/luci-app-honk/po/zh-cn/honk.po
+++ b/luci-app-honk/po/zh-cn/honk.po
@@ -1281,8 +1281,8 @@ msgstr "Clash API 密钥"
msgid "A secret is set. Leave blank to keep it."
msgstr "已设置密钥,留空则保持原值。"
-msgid "Required when enabling. Use 16–128 letters, numbers, underscores or hyphens."
-msgstr "启用时必填,使用 16~128 位英文字母、数字、下划线或短横线。"
+msgid "Required when enabling. Use 6–128 letters, numbers, dots, underscores or hyphens."
+msgstr "启用时必填,使用 6~128 位英文字母、数字、点、下划线或短横线。"
msgid "Use your existing HTTPS reverse proxy for external access. Enter the secret in Zashboard when connecting."
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."
msgstr "已设置密钥,留空则保持原值;外网访问请使用现有 HTTPS 反向代理。"
-msgid "A secret is required when enabling Clash API. Use 16–128 letters, numbers, underscores or hyphens."
-msgstr "启用 Clash API 时必须填写密钥,使用 16~128 位英文字母、数字、下划线或短横线。"
+msgid "A secret is required when enabling Clash API. Use 6–128 letters, numbers, dots, underscores or hyphens."
+msgstr "启用 Clash API 时必须填写密钥,使用 6~128 位英文字母、数字、点、下划线或短横线。"
msgid "The Clash API port is already in use."
msgstr "Clash API 端口已被占用。"
-msgid "Enter a different Clash API port and a secret of 16–128 letters, numbers, underscores or hyphens."
-msgstr "请设置不同的 Clash API 端口,以及 16~128 位英文字母、数字、下划线或短横线组成的密钥。"
+msgid "Enter a different Clash API port and a secret of 6–128 letters, numbers, dots, underscores or hyphens."
+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 位,只能包含字母、数字、点、下划线或短横线。"
diff --git a/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua b/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua
index 9eea2b8f..e4d127fe 100755
--- a/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua
+++ b/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua
@@ -741,6 +741,11 @@ Xray.outbounds = {
else
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
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
local g = { type = "mkcp-legacy" }
g.settings = { header = map[server.kcp_guise] }
@@ -749,11 +754,6 @@ Xray.outbounds = {
end
udp[#udp+1] = g
end
- local s = { type = "mkcp-legacy" }
- if server.seed and server.seed ~= "" then
- s.settings = { value = server.seed }
- end
- udp[#udp+1] = s
end
finalmask.udp = udp
elseif PT == "hysteria2" then
diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile
index bf93bb20..34ff9d88 100644
--- a/luci-theme-footstrap/Makefile
+++ b/luci-theme-footstrap/Makefile
@@ -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
# 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
-# `wrap-regex` bans the shape and tools/jsmin-verify.mjs proves the output token-identical.
+# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299): wrap a
+# 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,
# 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.
$(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/
$(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
- # take the comments it reads.
+ # Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates strip, which takes the
+ # comments it reads.
$(SHELL) $(CURDIR)/mangle-tokens.sh \
$(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \
$(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).
# 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
- # 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);
# whole-line `#` only.
$(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
# `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
-# `make package//compile` fails with "No rule to make target". tools/scan-marker.sh re-derives
-# the grep and fails the build if it goes missing.
+# `make package//compile` fails with "No rule to make target".
# call BuildPackage - OpenWrt buildroot signature
diff --git a/luci-theme-footstrap/README.md b/luci-theme-footstrap/README.md
index f9291621..9a235522 100644
--- a/luci-theme-footstrap/README.md
+++ b/luci-theme-footstrap/README.md
@@ -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,
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
-router until "Save as default" is pressed. There are no per-layout or per-mode themes; every legacy
-name (`FootstrapSidebar`, `FootstrapOnTop`, `…-dark`/`…-light`) is deleted by
-`root/etc/uci-defaults/30_luci-theme-footstrap`.
+router until "Save as default" is pressed. There are no per-layout or per-mode themes.
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.
@@ -26,11 +24,11 @@ and only re-arranges the stock overview sections.
```
Makefile luci.mk; LUCI_MINIFY_CSS:=0; Build/Prepare (CSS, mangle, strip,
- version, po2lmo)
+ version)
styles/ CSS SOURCE: layers tokens / base / theme / pages
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
-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)
ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/
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)
htdocs/luci-static/resources/ menu-footstrap.js (footer entry), menu-footstrap-common.js,
fs-{fit,menutree,prefs,widgets,chrome,router,sheets,search,
- select,appearance,version,overview}.js
-root/etc/uci-defaults/ registration and legacy-name migration
-root/usr/share/rpcd/acl.d/ ACL: uci footstrap (Save as default) + login-background upload
+ select,appearance,assets,axes,version,overview}.js
+root/etc/uci-defaults/ registration and the upload symlinks
+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
-`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The Footstrap
-tab shows the version it is running and makes no network call to do it.
+`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The System → Footstrap
+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.
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
```
-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/package.md`](../docs/package.md).
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
index eecfe041..12165843 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js
@@ -632,8 +632,4 @@ function build() {
return baseclass.extend({
/* the dispatched page's whole render(), called by view/footstrap/appearance.js */
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 */
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js
index 8238907c..988c07e6 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js
@@ -288,7 +288,4 @@ return baseclass.extend({
removePattern: PATTERN.remove,
uploadLoginBg: LOGIN_BG.upload,
removeLoginBg: LOGIN_BG.remove,
- /* test-only: the SVG cleaner and the CSS-escape decoder */
- _sanitizeSvg, /* fs:probe */
- _cssUnescape, /* fs:probe */
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js
index 59ff2475..c33829a6 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js
@@ -7,13 +7,12 @@
/* 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)
- * 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(),
* 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
- * needed to paint a page correctly, only to change one from the form. The axis list is read from the
- * whole resources directory, so an axis may live in a second file. */
+ * needed to paint a page correctly, only to change one from the form. */
/* 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
- * with a list of one — kept as its own name because the call is matched by name, and because
- * "two-valued" is what most of these axes are. */
+ * with a list of one — kept as its own name because "two-valued" is what most of these
+ * axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
index 774ed9e0..aecbbdeb 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
@@ -657,13 +657,4 @@ return baseclass.extend({
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 */
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
index e8f6c4a5..01046200 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
@@ -6,17 +6,12 @@
* 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
* 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
- * place is docs/anchoring.md, "The corrections". */
+ * (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. */
/* 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() {
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';
}
@@ -30,7 +25,7 @@ function runAll(list, what) {
}
}
-/* dev switch — docs/anchoring.md, "The dev switches" */
+/* dev switch */
function fittersEnabled() {
try { return localStorage.getItem('fsFit') !== 'off'; }
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
* 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
- * 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 FLOORED = '[data-fs-floor]';
@@ -88,14 +83,14 @@ function holdFloor(records) {
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 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);
dirty.forEach((box, i) => {
if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('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
- * document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */
+ * document lost at its bottom. */
const landed = scrollTop();
if (landed >= at) return;
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
* 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 —
- * one pixel of rounding per frame, the floor — at 250ms and above.
- * docs/anchoring.md, "Is the page moving: `scrolling()`". */
+ * one pixel of rounding per frame, the floor — at 250ms and above. */
const SCROLL_IDLE = 400;
let _deferred = false;
function deferMeasurement() { _deferred = true; }
@@ -120,7 +114,7 @@ function sawOwnWrite(y) {
return true;
}
/* 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;
function sawClamp() {
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
- * stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
+ * stylesheet decides it. */
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
function layoutKey() {
const root = document.documentElement;
@@ -168,16 +162,16 @@ function sampleMotion() {
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
_sampling = false;
/* 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". */
- const target = _deferredFloor;
- _deferredFloor = null;
- const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
+ * on wrong ground shows none. */
+ /* Only a box wholly above the viewport moves the reader when it gives its floor up. */
+ const floorsAbove = [..._deferredFloors].filter((b) => b.isConnected && b.getBoundingClientRect().bottom <= 0)
+ .map((b) => [ b, parseFloat(b.style.minHeight) || 0 ]);
+ _deferredFloors.clear();
const offsetBefore = scrollTop();
holdFloor();
- if (target && target.isConnected) {
- const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
- if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
- }
+ let shrink = 0;
+ for (const [ b, h ] of floorsAbove) shrink += h - (parseFloat(b.style.minHeight) || 0);
+ if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
rememberRest();
if (_deferred) {
_deferred = false;
@@ -202,9 +196,16 @@ function noteUser() {
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() {
const opts = { passive: true, capture: true };
- window.addEventListener('scroll', noteMotion, opts);
+ window.addEventListener('scroll', noteScroll, opts);
for (const name of [ 'wheel', 'touchmove' ])
window.addEventListener(name, noteUser, opts);
for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
@@ -222,7 +223,7 @@ function frame(fn) {
const schedule = frame(run);
/* 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;
const _lastWidth = new WeakMap();
function onResize(entries) {
@@ -243,38 +244,20 @@ function watch(el) {
}
/* ---- 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
- * reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
- * offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
- * Whether there is one to leave anything behind is asked of the platform, never of a browser name —
- * with anchoring suppressed, a 120px growth moves the reader 120px.
- * docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
+ * Safari and iOS before 27 have no `overflow-anchor`, and a tick's growth above the reader moves
+ * them by all of it. Whether the engine anchors is asked of the platform, never of a browser name;
+ * where it does, the observer leaves the reader to it. Elsewhere the drift is computed from a
+ * reference element, never from the offset. */
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'); }
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 _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() {
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
* 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
- * re-taken forced". */
+ * `0, 0, 0` with it. */
function rememberRest(force) {
if (scrolling() && !force) return;
if (ENGINE_ANCHORS && scrollTop() <= 0) {
@@ -351,124 +333,30 @@ function anchorRef() {
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;
-const _anchor = makeTrail();
-function awhy(w) { _anchor.record(w); }
function anchorEnabled() {
try { return localStorage.getItem('fsAnchor') !== 'off'; }
catch (e) { return true; }
}
-/* ---- what the engine's own anchoring leaves behind ----
- * It keeps a reference still; it does not promise that a section can vanish and come back. An
- * older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
-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;
+/* Floors `holdFloor()` could not clear while the reader scrolled, cleared once they let go: written
+ * in the frame the floors came off, so no frame is painted with the reader moved. The offset as
+ * laid out after the clear tells whether the engine already gave it back. */
function settleDeferredFloor(offsetBefore, shrink) {
- if (_floorLateFrame) return;
- _floorLateFrame = requestAnimationFrame(() => {
- const seen = scrollTop();
- _floorLateFrame = requestAnimationFrame(() => {
- _floorLateFrame = 0;
- if (!anchorEnabled() || Date.now() < _userUntil) return;
- if (_restPage !== pageStamp()) return;
- if (scrollTop() !== seen) return; /* still settling, or the reader has moved */
- const wanted = offsetBefore - shrink;
- 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();
- });
- });
+ if (!anchorEnabled() || Date.now() < _userUntil) return;
+ const seen = scrollTop();
+ const gap = offsetBefore - shrink - seen;
+ /* 16: a 32-36 row lease table rounds by 8-12.25px at 390 wide */
+ if (Math.abs(gap) <= 16) return; /* the engine already gave it back */
+ /* no cap at the viewport: four leases stacked at 390px give up 1032px, all of it above */
+ writeOffset(seen + gap);
+ if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
+ _restAt = scrollTop();
}
function scheduleAnchor(ref) {
- if (!ref) return awhy('no-reference');
- if (!anchorEnabled()) return awhy('anchoring-off');
- if (_anchorPending) return awhy('pending-kept-first');
+ if (!ref) return;
+ if (!anchorEnabled()) return;
+ if (_anchorPending) return;
_anchorPending = ref;
requestAnimationFrame(() => {
const pending = _anchorPending;
@@ -479,23 +367,20 @@ function scheduleAnchor(ref) {
}
function applyAnchor(ref) {
/* 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". */
- if (scrolling() && !sawClamp()) return awhy('refused-moving');
+ * correct -60px correction unwritten. */
+ if (scrolling() && !sawClamp()) return;
const at = scrollTop();
if (ref.by != null) {
- if (ref.by < 1) return awhy('by-under-1');
+ if (ref.by < 1) return;
writeOffset(at + ref.by);
- awhy('wrote-by-' + Math.round(ref.by));
return true;
}
- if (at <= 0) return awhy('at-top');
- if (!ref.el.isConnected) return awhy('reference-gone');
+ if (at <= 0) return;
+ if (!ref.el.isConnected) return;
const drift = ref.el.getBoundingClientRect().top - ref.top;
- if (Math.abs(drift) < 1) return awhy('no-drift'); /* nothing needed correcting here */
- _lateHits = 0;
- if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
+ if (Math.abs(drift) < 1) return; /* nothing needed correcting here */
+ if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return;
writeOffset(at + drift);
- awhy('wrote-' + Math.round(drift));
return true;
}
@@ -505,56 +390,28 @@ function observeContent() {
.filter(Boolean);
const viewHost = hosts[0];
_mo = new MutationObserver((records) => {
- const settled = _rest;
- 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 ref = ENGINE_ANCHORS ? null : anchorFor();
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);
- if (!wasScrolling) _deferredFloor = null;
- const floorShrink = before ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
+ if (!wasScrolling) _deferredFloors.clear();
/* `#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". */
- let gone = 0, came = 0, took = 0, gave = 0;
- for (const m of records) {
+ * out and written over a correct restoreScroll(). */
+ let gone = 0, came = 0;
+ for (const m of records)
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) {
forgetRest();
return;
}
- /* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
- * 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);
+ scheduleAnchor(ref);
});
for (const host of hosts) {
_mo.observe(host, { childList: true, subtree: true });
@@ -564,9 +421,9 @@ function observeContent() {
_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
- * `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab
- * switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
- * every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms. */
+ * `height: 0` they collapse with: 1299px of blank left standing. A same-value write is not a
+ * change, though: the fitters re-apply classes every pass, and an unguarded `class` watch froze
+ * the tab, 391 callbacks in 432ms. */
const _moTabs = new MutationObserver((records) =>
records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
&& (r.attributeName !== 'class' || r.target.dataset.field)) && run());
@@ -585,20 +442,8 @@ return baseclass.extend({
},
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,
- /* -> 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,
armGate,
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
index c9f168c1..8a743dc2 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js
@@ -317,9 +317,4 @@ function patchOverview() {
return baseclass.extend({
/* called by menu-footstrap-common's loader on every data-page stamp that maps here */
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 */
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
index 185f8c53..954a8cd3 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js
@@ -50,8 +50,6 @@ function currentMode() {
/* 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`. */
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-theme', dark ? 'dark' : 'light');
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
index d76f74a3..16824c64 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
@@ -110,7 +110,6 @@ function markAlive() {
_expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
}
-function sessionExpired() { return _expired; }
function watchSession() {
const req = window.L.Request;
if (req && typeof req.addInterceptor === 'function')
@@ -809,22 +808,5 @@ return baseclass.extend({
seed,
wire: wireRouter,
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
});
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js
index 0753b0ba..a64d803c 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js
@@ -571,11 +571,6 @@ function wireTypeahead() {
}
return baseclass.extend({
- /* test-only: the slot-cache key, driven directly */
- shapeOf, /* fs:probe */
- /* test-only: the token-cell predicate */
- markTokenCells, /* fs:probe */
-
__init__() {
wireTypeahead();
diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
index c7ec9af9..b5e7fd1f 100644
--- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
+++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js
@@ -411,7 +411,10 @@ function reassertLayerOrder() {
if (_layerStmt) _layerStmt.remove();
_layerStmt = document.createElement('style');
_layerStmt.textContent = LAYER_ORDER;
- head.insertBefore(_layerStmt, head.firstChild);
+ /* before the first sheet, not into : a document_start script (an extension) appends its
+ *