update 2026-10-25 16:53:21

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

No files matched your search

+4 -4
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
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+
+25 -2
View File
@@ -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
@@ -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'])
+2 -2
View File
@@ -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() {
+2 -2
View File
@@ -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
}
+83 -33
View File
@@ -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 }}*
fail_on_unmatched_files: true
files: dist/*.apk
+2 -1
View File
@@ -1 +1,2 @@
.DS_Store
.DS_Store
.local
+6 -4
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
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
-39
View File
@@ -1,39 +0,0 @@
# luci-app-adguardhome
基于原版 `luci-app-adguardhome` 修改,主要针对现代 OpenWrt 环境进行了 `nftables` 适配,并从 **v2.4.0** 开始完成 LuCI 前端 JavaScript 化。
当前 `main` 分支为 JavaScript 版本,旧版 Lua 实现保留在 [`lua`](https://github.com/w9315273/luci-app-adguardhome/tree/lua) 分支。
## 主要变化
### v2.4.0 — LuCI JavaScript 迁移
从 **v2.4.0** 开始,LuCI 页面由传统 Lua CBI / HTM 架构迁移至 JavaScript。
- LuCI 页面由 Lua CBI / HTM 迁移到 JavaScript
- 后端接口迁移到 `rpcd + ucode`
- 使用 LuCI RPC ACL 控制前后端权限
- 删除旧的 `luasrc/controller`、`luasrc/model/cbi` 和 HTM 页面
- 保留原有 AdGuardHome 配置方式与主要功能
- 优化 YAML 配置编辑与配置检查
### nftables DNS 重定向
将原版的 DNS 重定向实现从 `iptables` 迁移到 `nftables`,核心使用方式保持不变。
### WAN 设备排除
从 **v2.2.0** 开始,可直接在 LuCI 界面的 **WAN 接口** 选项中选择需要排除的网卡,无需手动编辑 `adguardhome.nft.tpl`。
该选项仅在 **「重定向」模式** 下生效,用于避免将来自公网接口的 DNS 请求重定向到本机,从而防止 AdGuardHome 被暴露为公共 DNS 解析器。
请仅选择直接面向公网的接口,例如:
- `eth0`
- `pppoe-wan`
## 界面
<img width="677" height="695" src="https://github.com/user-attachments/assets/4b436eec-8e05-4bf6-82d9-a972164e9659" />
<img width="536" height="714" src="https://github.com/user-attachments/assets/575c2d6c-e289-425d-91b9-faf01b5a7262" />
+35
View File
@@ -0,0 +1,35 @@
# AdGuard Home for LuCI
Manage AdGuard Home in OpenWrt's LuCI interface. LuCI pages are rewritten in JavaScript, with an rpcd + ucode backend and nftables-based DNS forwarding.
[简体中文](README.md) · [English](README.en.md)
## Features
- Service status and core updates
- DNS handling through firewall forwarding, dnsmasq upstream, or port 53 replacement
- YAML configuration editing, validation, and web login password changes
- Runtime logs with reverse order, local timestamps, download, and a verbose logging toggle
> [!TIP]
> **Quick installation**
>
> Run in the terminal of an OpenWrt router using the APK package manager:
>
> ```sh
> uclient-fetch -O /tmp/install-adguardhome.sh https://raw.githubusercontent.com/w9315273/luci-app-adguardhome/main/install.sh && sh /tmp/install-adguardhome.sh
> ```
## Screenshots
### Basic settings
![Basic settings](screenshots/base-en.png)
### Manual configuration
![Manual configuration](screenshots/manual-en.png)
### Logs
![Runtime logs](screenshots/log-en.png)
+35
View File
@@ -0,0 +1,35 @@
# AdGuard Home for LuCI
在 OpenWrt 的 LuCI 界面中管理 AdGuard Home。使用 JavaScript 重写 LuCI 页面,后端采用 rpcd + ucode,并适配 nftables DNS 转发。
[简体中文](README.md) · [English](README.en.md)
## 功能
- 服务状态查看与核心更新
- 防火墙转发、dnsmasq 上游、替代 dnsmasq 使用 53 端口等 DNS 接入方式
- YAML 配置编辑与校验、网页登录密码修改
- 运行日志查看,支持逆序、本地时间、下载和详细日志开关
> [!TIP]
> **一键安装**
>
> 在使用 APK 包管理器的 OpenWrt 路由器终端执行:
>
> ```sh
> uclient-fetch -O /tmp/install-adguardhome.sh https://raw.githubusercontent.com/w9315273/luci-app-adguardhome/main/install.sh && sh /tmp/install-adguardhome.sh
> ```
## 截图
### 基础设置
![基础设置](screenshots/base-zh-cn.png)
### 手动设置
![手动设置](screenshots/manual-zh-cn.png)
### 日志
![运行日志](screenshots/log-zh-cn.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+43 -22
View File
@@ -17,48 +17,36 @@ msgstr "启用"
msgid "Browser management port"
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 "无法应用详细日志设置"
+270 -57
View File
@@ -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"
@@ -7,5 +7,8 @@ uci -q batch <<-EOF >/dev/null 2>&1
commit ucitrack
EOF
rm -f /tmp/luci-indexcache
exit 0
# 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
@@ -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
Check_Updates "$update_mode"
@@ -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" ]
}
}
}
}
@@ -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" ]
}
@@ -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 </dev/null &'
: '/bin/sh /usr/share/AdGuardHome/update_core.sh >/tmp/AdGuardHome_update.log 2>&1 </dev/null &';
? '/bin/sh /usr/share/AdGuardHome/update_core.sh force >/dev/null 2>&1 </dev/null &'
: '/bin/sh /usr/share/AdGuardHome/update_core.sh >/dev/null 2>&1 </dev/null &';
return { started: system(['/bin/sh', '-c', command], 3000) == 0 };
}
@@ -150,8 +181,22 @@ return {
return { changed: true };
}
},
setVerboseLog: {
args: { enabled: false },
call: function(request) {
const conf = cursor();
if (!conf.set('AdGuardHome', 'AdGuardHome', 'verbose', request.args.enabled ? '1' : '0') ||
!conf.commit('AdGuardHome'))
return { saved: false, applied: false };
conf.unload('AdGuardHome');
return {
saved: true,
applied: system(['/etc/init.d/AdGuardHome', 'reload'], 30000) == 0
};
}
},
getRuntimeLog: {
args: { offset: 0 },
args: { offset: 0, identity: '' },
call: function(request) {
const conf = cursor();
const logfile = conf.get('AdGuardHome', 'AdGuardHome', 'logfile') || '';
@@ -167,18 +212,40 @@ return {
system(['/bin/sh', '-c', '/bin/sh /usr/share/AdGuardHome/getsyslog.sh >/dev/null 2>&1 &'], 3000);
}
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
};
}
},
@@ -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;
});
}
});
});
@@ -0,0 +1,153 @@
/* Scoped to the settings and log cards; shared status badges keep their layout. */
.agh-compact-card {
margin-bottom: 0.75rem;
}
.agh-compact-card .cbi-value {
margin-bottom: 0;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.agh-compact-card .cbi-value-description {
margin-top: 0.25rem;
}
.agh-compact-card .agh-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.agh-compact-card .agh-toolbar > * {
margin: 0;
}
.agh-compact-card .agh-update-content {
width: 100%;
max-width: 40rem;
min-width: 0;
}
.agh-compact-card .agh-update-options {
align-items: center;
gap: 0.35rem;
width: auto;
margin: 0.5rem 0 0;
padding: 0;
float: none;
}
.agh-compact-card .agh-update-options input[type="checkbox"] {
position: static;
flex: 0 0 auto;
margin: 0;
}
.agh-compact-card textarea.agh-update-log {
width: 100%;
height: 10rem;
min-height: 6rem;
box-sizing: border-box;
resize: vertical;
margin: 0.35rem 0 0;
}
.agh-log-view {
padding: 0.6em;
}
.agh-log-view textarea {
display: block;
width: 100%;
height: clamp(12rem, 32vh, 20rem);
min-height: 10rem;
box-sizing: border-box;
resize: vertical;
margin: 0 0 0.6em;
}
.agh-manual-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding: 0;
}
.agh-compact-card .agh-password-controls label {
float: none;
width: auto;
padding: 0;
}
.agh-compact-card .agh-password-controls input {
width: 14rem;
max-width: 100%;
min-width: 0;
}
.agh-manual-card {
padding: 0.6em;
}
.agh-editor-wrap {
margin: 0 0 0.6em;
}
.agh-config-actions {
margin-left: auto;
}
/* Keep the base card heading in the theme's original position. */
.agh-base-card .cbi-section-node {
margin: 0;
padding: 0.6em;
}
.agh-log-view .agh-log-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6em 1.2em;
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}
.agh-log-view .agh-log-toggles {
justify-content: flex-start;
flex: 0 1 auto;
}
.agh-log-view .agh-log-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.6em 1.2em;
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
}
.agh-log-view .agh-log-actions > .cbi-value {
display: flex;
align-items: center;
flex-wrap: wrap;
flex: 0 1 auto;
gap: 0.5em;
width: auto;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.agh-log-view .agh-log-actions .cbi-value-title {
float: none;
flex: 0 0 auto;
width: auto;
margin: 0;
padding: 0;
}
.agh-log-view .agh-log-actions .cbi-value-field {
flex: 0 1 auto;
width: auto;
min-width: 0;
margin: 0;
padding: 0;
}
.agh-log-view .agh-log-actions .cbi-value-field input {
width: 22em;
max-width: 100%;
box-sizing: border-box;
}
.agh-log-controls .agh-toolbar > span:empty {
display: none;
}
@@ -1,5 +1,6 @@
'use strict';
'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;
});
}
});
@@ -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;
}
});
});
@@ -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 };
}
});
});
+1 -1
View File
@@ -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
@@ -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 + ' ] ')
+3 -3
View File
@@ -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
@@ -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;
}
@@ -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.'),
@@ -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;
}
+18 -6
View File
@@ -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 位,只能包含字母、数字、点、下划线或短横线。"
@@ -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
+5 -8
View File
@@ -30,8 +30,8 @@ LUCI_MINIFY_CSS:=0
#
# JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the
# 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/<name>/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/<name>/compile` fails with "No rule to make target".
# call BuildPackage - OpenWrt buildroot signature
+9 -11
View File
@@ -11,9 +11,7 @@ Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
by default, or the side menu), mode, palette, density, wallpaper, rounding and every colour — accent,
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).
@@ -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 */
});
@@ -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 */
});
@@ -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);
}
@@ -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 */
});
@@ -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,
@@ -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 */
});
@@ -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');
@@ -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
});
@@ -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();
@@ -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 <head>: a document_start script (an extension) appends its
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
const first = document.querySelector('style, link[rel~="stylesheet"]');
first.parentNode.insertBefore(_layerStmt, first);
}
function sweepViewSheets() {
@@ -449,10 +452,4 @@ return baseclass.extend({
documentPoisoned,
scopeToCurrentPage,
watchViewSheets,
/* test-only: the duplicate sweep, the fence rewrite, the owner stamp and the re-host, driven
* directly instead of through a live document walk */
dedupeViewSheets, /* fs:probe */
fenceRules, /* fs:probe */
_owner, /* fs:probe */
rehostIntoThemeLayer, /* fs:probe */
});
@@ -93,9 +93,9 @@ function wireSearch() {
/* The three template globals Status -> Overview needs (progressbar, renderBox, renderBadge), which
* stock includes call bare and an SPA arrival never defines. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
they can be diffed against upstream when it changes. Modernising the `var`s would break
that property, which is what makes carrying the copies safe. */
/* Adapted copies of LuCI's admin_status/index.ut (window.L, an array child for E(), the title
string hoisted into `reading`); keep the shape, `var`s included, so a diff against upstream
stays readable when it changes. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
@@ -127,7 +127,6 @@ function ensureOverviewHelpers() {
window.L.itemlist(E('span'), [].slice.call(arguments, 2))
]);
};
/* eslint-enable no-var */
}
ensureOverviewHelpers();
+7 -9
View File
@@ -1,15 +1,13 @@
# Single source of the openwrt/luci commit whose two build tools we download and run as gates, and
# of their checksums. Sourced as shell (`. luci-upstream.pin`) by update-po.sh and CI.
# - jsmin.c compiled; its regex-vs-division lookback can silently eat a shipped JS file
# - i18n-scan.pl run by perl; decides whether the translation catalogue is complete
# Off a moving `master` the gate would be whatever upstream pushed last.
# To bump: change LUCI_PIN, re-fetch both files at the new commit, update their sha256s.
# Single source of the openwrt/luci commit whose i18n-scan.pl update-po.sh downloads and runs by
# perl to decide whether the translation catalogue is complete, and of its checksum. Sourced as
# shell (`. luci-upstream.pin`) by update-po.sh, build-apk.sh and tools/build-usign.sh.
# Off a moving `master` the check would be whatever upstream pushed last.
# To bump: change LUCI_PIN, re-fetch the file at the new commit, update its sha256.
LUCI_PIN=5cf20c5741dc34eea0faaf7324ef3751b05fa1f3
JSMIN_SHA256=0568b14873f27f339cf66a9c43603d0bc8ca501ff62522ea924c6e8daf1be215
I18N_SCAN_SHA256=a1823d1fdd942f31a105e1e53b401e146bc10527f231e016930b1a5123ef21d3
# No ucode pin, deliberately: the `verify` job runs the router's own interpreter over the installed
# templates on both release lines (docs/ci.md), so nothing is pinned, built or stubbed here.
# No ucode pin, deliberately: the `e2e` job renders every installed template with the router's own
# ucode on both release lines (tools/e2e.mjs), so nothing is pinned, built or stubbed here.
# usign, OpenWrt's ed25519 signer: owfeed signs each release package with it, and build-apk.sh
# builds this copy (via tools/build-usign.sh, not run in CI) to check the SDK it downloads. This is
+1 -2
View File
@@ -6,8 +6,7 @@
# 14% of the sheet: 18283 of 134210 bytes (measured 2026-10-14), wire bytes too: uhttpd serves /www
# uncompressed.
# The same map rewrites the .js and .ut under each <rewrite-dir> in place, BEFORE `strip-build.sh
# templates` and the JS minifier. Verify with `npm run computed-diff` (tools/computed-diff.mjs),
# mangled against plain.
# templates` and the JS minifier.
# Safe because (each checked):
# * `--fs-*` is the private tier; the export tier `--*-color-*` is untouched, and no installed
# luci-app reads a `--fs-` name.
+1 -1
View File
@@ -169,7 +169,7 @@ msgctxt "footstrap"
msgid "File"
msgstr "Archivo"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
# Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap"
+1 -1
View File
@@ -164,7 +164,7 @@ msgctxt "footstrap"
msgid "File"
msgstr "Файл"
# Product name, kept identical on purpose (task 0165): translating a brand name is a mistake,
# Product name, kept identical on purpose: translating a brand name is a mistake,
# not a missing string.
#: root/usr/share/luci/menu.d/luci-theme-footstrap.json:3
msgid "Footstrap"
@@ -13,9 +13,10 @@ set_opt() {
set_opt themes.Footstrap /luci-static/footstrap
# A fresh install may pick the theme; an upgrade must never move a router off its theme. `changed`
# tells the two apart (on an upgrade the entry exists) and carries the decision alone: apk exports
# no PKG_UPGRADE.
# set_opt never overwrites, and luci-base ships main.mediaurlbase, so on a stock router this line
# sets nothing on install or upgrade: the admin picks Footstrap in Language and Style. An upgrade
# must never move a router off its theme; on an upgrade the themes entry exists, so `changed` is 0
# (apk exports no PKG_UPGRADE).
if [ "${PKG_UPGRADE:-}" != 1 ] && [ "$changed" = 1 ]; then
set_opt main.mediaurlbase /luci-static/footstrap
fi
+3 -43
View File
@@ -1,10 +1,9 @@
#!/bin/sh
# Strip build-time-only bytes from a BUILD TREE, one mode per kind of file:
# ./strip-build.sh templates|shell|probes|assets <dir>
# ./strip-build.sh templates|shell|assets <dir>
#
# templates `{# … #}`, whole-line `/* … */` and the indentation out of the .ut files
# shell whole-line `#` out of the shell under root/ (uci-defaults, the CGI, keep.d)
# probes the `/* fs:probe */` export lines out of the JS
# assets the SVG favicon's comment and the whitespace in the manifest JSON
#
# Run from the Makefile over $(PKG_BUILD_DIR) and from tools/stage.sh over the staged payload, never
@@ -14,11 +13,11 @@
# executes it. Reasons per mode sit above each branch below; docs/package.md has the rest.
set -eu
usage() { echo "usage: strip-build.sh templates|shell|probes|assets <dir>" >&2; exit 2; }
usage() { echo "usage: strip-build.sh templates|shell|assets <dir>" >&2; exit 2; }
MODE="${1:-}"
DIR="${2:-}"
case "$MODE" in templates|shell|probes|assets) ;; *) usage ;; esac
case "$MODE" in templates|shell|assets) ;; *) usage ;; esac
[ -n "$DIR" ] && [ -d "$DIR" ] || usage
LIST=$(mktemp)
@@ -29,7 +28,6 @@ trap 'exit 130' INT TERM
# The list goes through a file: `for f in $(find …)` splits on whitespace.
case "$MODE" in
templates) find "$DIR" -type f -name '*.ut' ;;
probes) find "$DIR" -type f -name '*.js' ;;
assets) find "$DIR" -type f \( -name '*.svg' -o -name '*.json' \) ;;
shell) find "$DIR" -type f ;;
esac | sort > "$LIST"
@@ -50,11 +48,6 @@ want() {
echo "strip-build: $1:$(grep -n '<<' "$1" | head -1 | cut -d: -f1): a heredoc — refusing (a '#' line inside one is DATA, not a comment)" >&2
exit 1
fi ;;
# a few module-private functions are also exported for one reason: a gate here calls them.
# The function stays, the export line goes, with the comment block directly above it. Only a
# complete export entry may go; anything else fails the build, since a half-removed object
# literal is a module that does not parse.
probes) grep -q '/\* fs:probe \*/' "$1" ;;
# acl.d is excluded: rpcd skips a malformed ACL SILENTLY, so the grant would go to nobody, for ~200 B
assets) case "$1" in */rpcd/acl.d/*.json) return 1 ;; esac ;;
esac
@@ -222,36 +215,6 @@ strip() {
echo "strip-build: $1 lost its shebang — refusing" >&2
exit 1
fi ;;
probes)
pre=$(grep -c '/\* fs:probe \*/$' "$1" || true)
awk '
# a comment block directly above a probe entry describes that export, so it is held back
# and goes with it; above anything else it is released untouched
function flush() { if (nb) { printf "%s", buf; lastblank = 0 } buf = ""; nb = 0 }
incom { buf = buf $0 "\n"; nb++; if ($0 ~ /\*\//) { incom = 0 } ; next }
/^[ \t]*\/\*/ && !/\/\* fs:probe \*\/$/ {
flush()
buf = $0 "\n"; nb = 1
if ($0 !~ /\*\//) incom = 1
else if ($0 !~ /\*\/[ \t]*$/) flush()
next
}
/\/\* fs:probe \*\/$/ {
line = $0
sub(/[ \t]*\/\* fs:probe \*\/$/, "", line)
# a complete entry, in either form the modules use: `name,` or `name: expression,`
if (line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*,[ \t]*$/ ||
line ~ /^[ \t]*[A-Za-z_$][A-Za-z0-9_$]*[ \t]*:.*,[ \t]*$/) { buf = ""; nb = 0; skipblank = lastblank; next }
print "strip-build: " FILENAME ":" NR ": not a whole export entry: " line > "/dev/stderr"
bad = 1
}
# a drop between two blank lines would leave a run the source did not have
skipblank && /^[ \t]*$/ { skipblank = 0; next }
{ flush(); skipblank = 0; lastblank = ($0 ~ /^[ \t]*$/); print }
END { flush(); exit bad }
' "$1" > "$CUR"
post=$(grep -c '/\* fs:probe \*/$' "$CUR" || true)
dropped=$((dropped + pre - post)) ;;
assets)
case "$1" in
*.svg)
@@ -315,7 +278,6 @@ strip() {
before=0
after=0
found=0
dropped=0
while IFS= read -r f; do
want "$f" || continue
found=$((found + 1))
@@ -333,11 +295,9 @@ done < "$LIST"
case "$MODE" in
templates) [ "$found" -gt 0 ] || { echo "strip-build: no .ut under $DIR" >&2; exit 1; } ;;
shell) [ "$found" -gt 0 ] || { echo "strip-build: no shell file under $DIR" >&2; exit 1; } ;;
probes) [ "$found" -gt 0 ] || { echo "strip-build: no marked export in $DIR" >&2; exit 1; } ;;
assets) [ "$found" -gt 0 ] || { echo "strip-build: no .svg or .json in $DIR" >&2; exit 1; } ;;
esac
case "$MODE" in
probes) echo "strip-build: probes: $dropped export(s) dropped from $found file(s)" ;;
assets) echo "strip-build: assets: $found file(s)" ;;
*) echo "strip-build: $MODE: $found file(s), $before -> $after bytes (-$((before - after)))" ;;
esac
+3 -5
View File
@@ -8,8 +8,7 @@
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
* (`selected-row/paint`), docs/css.md "@mirror" */
/* opaque, not a tint (4.21:1 in dark) */
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
/* every separator is this one line, stated once rather than in 69 longhand declarations */
@@ -41,7 +40,7 @@
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
* tokens, not fetched". */
--fs-icon-refresh: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'><path d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/><path d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/><path fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/><path fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/></svg>");
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
/* a glyph inlined once, sized at two call sites — keep them in step by hand */
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
@@ -154,7 +153,7 @@
--fs-z-tooltip: 1000;
--fs-z-dropdown: 1100;
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
/* outbound only: styles/ never reads one (nothing checks it). docs/design-system.md
* "The export tier is a RAMP" */
--background-color-high: var(--fs-panel);
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
@@ -171,7 +170,6 @@
--border-color-medium: var(--fs-border);
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
/* tools/export-tier.mjs proves the whole tier across palette x mode */
--primary-color-high: var(--fs-accent);
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
+1 -1
View File
@@ -20,7 +20,7 @@
--fs-good: var(--fs-good-base);
--fs-warn: var(--fs-warn-base);
--fs-danger: var(--fs-danger-base);
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`.
/* deliberately under AA — the "nothing here" ink.
* docs/design-system.md "The fourth ink is the one". */
--fs-placeholder-mix: 55%;
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
@@ -50,7 +50,7 @@
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
}
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */
/* Physical: border-block-start/-end need a browser-floor support decision first. */
.tr.drag-over-above {
border-top: 2px solid var(--fs-accent);
}
@@ -36,8 +36,7 @@
/* the tab focus ring lives in theme/40-tabs.css: an accent outline is invisible on the ACTIVE
* pill, whose fill is that same accent. The theme repaints the rule that stood here in full
* (same selectors), so a copy left behind would be a dead declaration tools/audit.py --strict
* reports. */
* (same selectors), so a copy left behind would be a dead declaration. */
.cbi-tab-disabled[data-errors]::after {
content: attr(data-errors);
@@ -203,7 +203,7 @@
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, chosen instead of :is(): .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */
* forgiving below the browser floor */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
+4 -13
View File
@@ -165,10 +165,8 @@
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before {
content: ""; width: var(--fs-icon-refresh-sz); height: var(--fs-icon-refresh-sz);
background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]:not([data-style="active"])::before {
background: var(--fs-dim);
@@ -180,10 +178,8 @@
min-width: var(--fs-btn-size); padding: 0 var(--fs-space-1); font-size: 0; justify-content: center;
}
.fs-sidebar.fs-ind-compact #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
/* Exactly ONE member of the far-edge cluster may carry an auto inline-start margin, and it must be the
@@ -412,10 +408,8 @@
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
border: 0; box-shadow: none; background: var(--fs-good);
/* @mirror poll-glyph/mask */
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
/* @endmirror */
/* Static, deliberately: this is a poll running quietly forever, not something being
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
}
@@ -427,9 +421,8 @@
}
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
* attribute + :not() past stylelint's selector-max-specificity (1,7,0). `.fs-sidebar` is
* deliberately absent: #indicators is an id. */
* collapsed-rail indicator badges its count". `.fs-sidebar` is deliberately absent:
* #indicators is an id. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
/* min-width, not width: the count is unbounded (a big config can queue three digits)
* and a fixed box would clip it. */
@@ -437,13 +430,11 @@
padding: 0 var(--fs-space-1); overflow: hidden;
display: inline-flex; align-items: center; justify-content: center;
}
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
/* .fs-ind-compact above paints the same badge but cannot SHARE this
* rule: it applies at every width, this one must stay inside the 521px floor. */
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
/* @mirror ind-badge/paint */
content: attr(data-fs-badge);
font-size: var(--fs-type-xs); font-weight: var(--fs-weight-bold); font-variant-numeric: tabular-nums;
/* @endmirror */
}
}
@@ -536,7 +527,7 @@
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
}
/* anywhere, not break-word: an inline-flex button keeps its min-content under break-word (a 496px word
* overflowed a 320px button). Outside the @media: table-contract refuses overflow-wrap in a width query */
* overflowed a 320px button). */
.fs-main .alert-message .btn,
.fs-main .alert-message button { overflow-wrap: anywhere; }
@@ -40,33 +40,25 @@
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
* meter column, sharing one geometry via --fs-meter-value. One rule per shape, chosen instead
* of :is() — the table half needs :has(), and in a plain list a non-parsing engine drops the
* whole rule, form row included. @mirror pins the shared declarations. */
* whole rule, form row included. */
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
.cbi-value-field > .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
margin-block-start: 0;
/* @endmirror */
--fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
}
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
/* @mirror meter/beside-value */
inset-inline: calc(100% + var(--fs-space-2)) auto; bottom: auto; top: 50%;
transform: translateY(-50%);
/* @endmirror */
}
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
@@ -114,7 +114,7 @@
}
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */
* (18 jumps/1500 frames measured). */
.table.fs-dt { overflow-anchor: none; }
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
@@ -306,7 +306,7 @@
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
* 1-line neighbour's (52px) */
/* a <tfoot> with no .tr IS the row. Two rules, chosen instead of :is(): a browser without :has() drops a whole list,
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */
* losing display:flex on every card. Keep both in step by hand. */
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
.table.fs-stacked tfoot:not(:has(> .tr)),
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
@@ -315,14 +315,13 @@
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
overflow-wrap: anywhere; white-space: normal;
}
/* the column label, lifted above its value. PINNED: the config table needs this under its
* own @container guard, which cannot share a block with a class. */
/* the column label, lifted above its value. A copy sits in theme/65-dropdown.css: the config
* table needs it under its own @container guard, which cannot share a block with a class. Keep
* the two identical by hand. */
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
.table.fs-stacked .td.cbi-section-actions::before,
@@ -330,26 +329,20 @@
.table.fs-stacked .td[data-title=""]::before,
.table.fs-stacked tfoot .td[data-title]::before,
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
/* row buttons are not a label/value pair — full width below the pairs. PINNED, see above. */
/* row buttons are not a label/value pair — full width below the pairs. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
* the tail button. PINNED to the config table's copy. */
* the tail button. Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/65-dropdown.css. */
.table.fs-stacked .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
* it does not scroll to an over-wide child */
@@ -273,9 +273,7 @@
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
.cbi-value-field select option:checked {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
}
@@ -271,9 +271,7 @@
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
* "Dropdown: the open list's rail and the selected-row's contrast". */
.cbi-dropdown[open] > ul.dropdown > li[selected] {
/* @mirror selected-row/paint */
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
/* @endmirror */
}
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
* INSIDE it so a focused+selected row keeps both */
@@ -282,8 +280,8 @@
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
}
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; the same declarations as
* `select option:checked` in theme/60-inputs.css, which has no checkbox (kept identical by hand) */
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
@@ -352,36 +350,28 @@
.table.cbi-section-table .td[data-widget="dvalue"],
.table.cbi-section-table .td[data-widget="fvalue"],
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
/* a copy of the data table's rule (theme/30-tables.css), needed under a CLASS there and
* under this @container here, and CSS cannot OR the two guards. Keep them identical by hand. */
.table.cbi-section-table .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
color: var(--fs-eyebrow-color);
/* @endmirror */
}
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
/* the row's buttons are not a label/value pair: full width, sharing the line. Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions {
/* @mirror table-card/actions */
display: flex; flex: 1 1 100%; justify-content: flex-start;
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
/* @endmirror */
}
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */
* div is a single flex item. Copy in theme/30-tables.css; same trap on Startup. */
.table.cbi-section-table .td.cbi-section-actions > :not(.cbi-dropdown) {
/* @mirror table-card/actions-inner */
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
/* @endmirror */
}
/* width: 0 — the label's nowrap min-content (288px for a Russian item at 360px) sizes the table-row-group
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). PINNED. */
* past the card (td right 442 vs 315); basis `content` still sizes the item to its label (it was 266px, a whole card, with `1 1 auto`). Copy in theme/30-tables.css. */
.table.cbi-section-table .td.cbi-section-actions > .cbi-dropdown {
/* @mirror table-card/actions-dropdown */
flex: 0 1 content; width: 0; min-width: 0;
/* @endmirror */
}
.table.cbi-section-table .td .zone-forwards { width: 100%; }
}
@@ -20,11 +20,9 @@
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
#modal_overlay .modal label.btn {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
#modal_overlay .modal li { overflow-wrap: anywhere; }
/* tints come from base's ladder alone (base/95-luci.css); radii sit on the Rounding axis */
@@ -4,11 +4,9 @@
@container fs-view (max-width: 360px) {
#view .cbi-button, #view .btn,
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
/* @mirror btn-wrap/anywhere */
white-space: normal;
min-width: 0;
overflow-wrap: anywhere;
/* @endmirror */
}
}
@@ -1,7 +1,7 @@
@layer theme {
/* honoured globally, every transition here is decoration. This file is the one place for flags against the theme's own rules,
* and there are two: this block (a layer cannot reach base's own transitions) and the forced-colors focus outline below
* (.stylelintrc.json holds the allowlist). .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
* .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
@@ -67,7 +67,7 @@
* 103 ink pixels its box shows at rest, and a checked checkbox or radio is pixel-identical to an unchecked
* one. A system colour with the fill un-forced brings it back; ButtonText polling and GrayText paused
* keep the green/grey state — docs/css.md "A11y: forced-colors state repairs". Selectors repeat the
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at the stylelint ceiling (1,7,0) and still win by file order. */
* glyph rules' own (20-shell, 60-inputs, base/95-luci) with one class or attribute doubled, one step above the original; the two rail selectors already sit at (1,7,0) and still win by file order. */
.fs-sidebar.fs-sidebar.fs-ind-compact #indicators [data-indicator="poll-status"]::before,
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"],
input[type="checkbox"][type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
@@ -127,8 +127,7 @@
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
applier in fs-prefs.js (no shared code: this runs before the module loader exists).
It carries no interpolation, unlike the data blob above: an interpolated body is not
JS until rendered, so it is exempt from the inline-script lint and anything merged
into it would go unchecked.
JS until rendered.
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
Dark mode: a saved preference, else the router default, else the OS. */
(() => {
@@ -144,8 +144,7 @@
{#
The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS
until the server renders it, so this line carries no logic at all and eslint's .ut processor
enforces that. The script itself is plain JS and is linted.
until the server renders it, so this line carries no logic at all. The script itself is plain JS.
#}
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
<script>
+3 -3
View File
@@ -4,9 +4,9 @@
#
# ./update-po.sh rescan, merge, report what is still untranslated
# ./update-po.sh --check change nothing; fail if the .pot is stale or a string is
# untranslated. The CI gate.
# untranslated.
#
# An untranslated _() falls through to its English msgid and nothing reports it, so --check is a gate.
# An untranslated _() falls through to its English msgid and nothing else reports it.
# `po/` is what LUCI_LANGUAGES globs and Weblate translates (docs/package.md): do not rename it.
# Not run on the buildbot (luci.mk calls po2lmo); needs perl and gettext.
# The scanner is LuCI's own build/i18n-scan.pl, not a grep: it lexes .ut into JS before xgettext and
@@ -16,7 +16,7 @@ set -eu
cd "$(dirname "$0")"
# Pinned by commit and sha256 in luci-upstream.pin: a fetched perl script is EXECUTED, and off a
# moving branch the completeness gate would be whatever upstream pushed last.
# moving branch the completeness check would be whatever upstream pushed last.
. ./luci-upstream.pin
SCANNER_URL="https://raw.githubusercontent.com/openwrt/luci/${LUCI_PIN}/build/i18n-scan.pl"
SCANNER_SHA256="$I18N_SCAN_SHA256"
+4 -4
View File
@@ -7,13 +7,13 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=openlist2
PKG_VERSION:=4.2.8
PKG_WEB_VERSION:=4.2.8
PKG_VERSION:=4.2.9
PKG_WEB_VERSION:=4.2.9
PKG_RELEASE:=1
PKG_SOURCE:=openlist-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/OpenListTeam/OpenList/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=9b3ecb32ab23ecc79a0d471cff3e62a50a93a52fa79245cb6cfc38994546a606
PKG_HASH:=a762ff50b71b384f03af2996cac2aba471b0d5ec44aad0255e646a41c8af7f49
PKG_BUILD_DIR:=$(BUILD_DIR)/OpenList-$(PKG_VERSION)
@@ -24,7 +24,7 @@ PKG_MAINTAINER:=sbwml <admin@cooluc.com>
define Download/openlist-frontend
FILE:=openlist-frontend-dist-lite-v$(PKG_WEB_VERSION).tar.gz
URL:=https://github.com/OpenListTeam/OpenList-Frontend/releases/download/v$(PKG_WEB_VERSION)/
HASH:=5f74d21c9841c4f132c39c4ddcf35e4855020a3fe0b877f4b6e465700c976503
HASH:=af0b7214fb3a5c34f27366acf528378d9501e27f6e36d5d9c6533c6fee67e4df
endef
PKG_BUILD_DEPENDS:=golang/host
+2 -2
View File
@@ -21,13 +21,13 @@ define Download/geoip
HASH:=4ff4b2ec242a9e87b296ba27ddb85f8311e8d3e071eaebac5be9231c60e408dd
endef
GEOSITE_VER:=20261024132648
GEOSITE_VER:=20261025014458
GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER)
define Download/geosite
URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/
URL_FILE:=dlc.dat
FILE:=$(GEOSITE_FILE)
HASH:=80c7e23186671049ea54280bf0a18e1f782a81fde92c56694044f4bcb5cd077a
HASH:=dae1d66a2055a3e88fdf68e4932ec0e1fbfcdb6c5ec824031af2ea74458bdec3
endef
GEOSITE_IRAN_VER:=202610230802