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 + +![Basic settings](screenshots/base-en.png) + +### Manual configuration + +![Manual configuration](screenshots/manual-en.png) + +### Logs + +![Runtime logs](screenshots/log-en.png) 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 +> ``` + +## 截图 + +### 基础设置 + +![基础设置](screenshots/base-zh-cn.png) + +### 手动设置 + +![手动设置](screenshots/manual-zh-cn.png) + +### 日志 + +![运行日志](screenshots/log-zh-cn.png) 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 + *