mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-01 16:44:45 +08:00
update 2026-10-25 16:53:21
This commit is contained in:
1 parent
91f217be10
commit
7fca8e9a3a
74 files changed
+1429
-918
No files matched your search
+4
-4
@@ -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+
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -1 +1,2 @@
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.local
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
@@ -0,0 +1,35 @@
|
||||
# AdGuard Home for LuCI
|
||||
|
||||
Manage AdGuard Home in OpenWrt's LuCI interface. LuCI pages are rewritten in JavaScript, with an rpcd + ucode backend and nftables-based DNS forwarding.
|
||||
|
||||
[简体中文](README.md) · [English](README.en.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Service status and core updates
|
||||
- DNS handling through firewall forwarding, dnsmasq upstream, or port 53 replacement
|
||||
- YAML configuration editing, validation, and web login password changes
|
||||
- Runtime logs with reverse order, local timestamps, download, and a verbose logging toggle
|
||||
|
||||
> [!TIP]
|
||||
> **Quick installation**
|
||||
>
|
||||
> Run in the terminal of an OpenWrt router using the APK package manager:
|
||||
>
|
||||
> ```sh
|
||||
> uclient-fetch -O /tmp/install-adguardhome.sh https://raw.githubusercontent.com/w9315273/luci-app-adguardhome/main/install.sh && sh /tmp/install-adguardhome.sh
|
||||
> ```
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Basic settings
|
||||
|
||||

|
||||
|
||||
### Manual configuration
|
||||
|
||||

|
||||
|
||||
### Logs
|
||||
|
||||

|
||||
@@ -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
|
||||
> ```
|
||||
|
||||
## 截图
|
||||
|
||||
### 基础设置
|
||||
|
||||

|
||||
|
||||
### 手动设置
|
||||
|
||||

|
||||
|
||||
### 日志
|
||||
|
||||

|
||||
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 |
@@ -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 "无法应用详细日志设置"
|
||||
@@ -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"
|
||||
+6
-6
@@ -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 };
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 + ' ] ')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user