mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-02 14:53:56 +08:00
update 2026-10-14 04:56:49
This commit is contained in:
1 parent
1e69cad46b
commit
c90a1f4fc1
766 files changed
+181363
-31
No files matched your search
@@ -0,0 +1,111 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=clashoo
|
||||
PKG_SHORT_SHA:=bc76c13
|
||||
PKG_COMMIT_DATE:=2026.10.11
|
||||
|
||||
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
|
||||
PKG_RELEASE:=1
|
||||
|
||||
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:=bc76c1398c6e70089367a30169d0df7c7f77c42d
|
||||
PKG_HASH:=5e454487796eb7a9445d7420dbe29bdd857cdbbe19a97befeed07e22146eb9dc
|
||||
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
|
||||
|
||||
PKG_LICENSE:=GPL3.0+
|
||||
PKG_MAINTAINER:=kenzok8
|
||||
|
||||
PKG_BUILD_DEPENDS:=golang/host
|
||||
PKG_BUILD_PARALLEL:=1
|
||||
PKG_BUILD_FLAGS:=no-mips16
|
||||
|
||||
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
|
||||
|
||||
GO_PKG:=github.com/metacubex/mihomo
|
||||
GO_PKG_LDFLAGS_X:=$(GO_PKG)/constant.Version=$(PKG_BUILD_VERSION) $(GO_PKG)/constant.BuildTime=$(PKG_BUILD_TIME)
|
||||
GO_PKG_TAGS:=with_gvisor
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
|
||||
|
||||
define Package/clashoo
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=Clashoo (Mihomo)
|
||||
DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle +yq +firewall4 +ip-full +coreutils-nohup +coreutils-timeout +kmod-inet-diag +kmod-nft-socket +kmod-nft-tproxy +kmod-tun +kmod-dummy +unzip
|
||||
PROVIDES:=mihomo clash-meta
|
||||
endef
|
||||
|
||||
define Package/clashoo/description
|
||||
Mihomo proxy core with runtime environment for Clashoo.
|
||||
endef
|
||||
|
||||
define Package/clashoo/conffiles
|
||||
/etc/config/clashoo
|
||||
endef
|
||||
|
||||
define Package/clashoo/install
|
||||
$(call GoPackage/Package/Install/Bin,$(1))
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
ln -sf /usr/bin/mihomo $(1)/usr/bin/clash-meta
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_DIR) $(1)/etc/clashoo
|
||||
$(INSTALL_DIR) $(1)/etc/clashoo/provider
|
||||
$(INSTALL_DIR) $(1)/etc/clashoo/proxyprovider
|
||||
$(INSTALL_DIR) $(1)/etc/clashoo/ruleprovider
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashoo
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashbackup
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashoo/config
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/sub
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/upload
|
||||
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/custom
|
||||
|
||||
$(INSTALL_BIN) $(CURDIR)/files/etc/init.d/clashoo $(1)/etc/init.d/clashoo
|
||||
$(INSTALL_CONF) $(CURDIR)/files/etc/config/clashoo $(1)/etc/config/clashoo
|
||||
$(INSTALL_DATA) $(CURDIR)/files/etc/config/clashoo $(1)/usr/share/clashoo/clashoo.default
|
||||
$(CP) $(CURDIR)/files/usr/share/clashoo/* $(1)/usr/share/clashoo/
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/migrate_lan_acl.sh $(1)/usr/share/clashoo/migrate_lan_acl.sh
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/migrate_fake_ip_filter.sh $(1)/usr/share/clashoo/migrate_fake_ip_filter.sh
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/detect_core_arch.sh $(1)/usr/share/clashoo/detect_core_arch.sh
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/diagnose_bypass.sh $(1)/usr/share/clashoo/diagnose_bypass.sh
|
||||
|
||||
$(RM) $(1)/usr/share/clashoo/dashboard
|
||||
$(RM) $(1)/usr/share/clashoo/yacd
|
||||
$(RM) $(1)/usr/share/clashoo/update.log
|
||||
$(RM) $(1)/usr/share/clashoo/geoip.log
|
||||
$(RM) $(1)/usr/share/clashoo/core_down_complete
|
||||
endef
|
||||
|
||||
define Package/clashoo/postinst
|
||||
#!/bin/sh
|
||||
if [ -z "$${IPKG_INSTROOT}" ]; then
|
||||
[ -x /usr/bin/mihomo ] && [ ! -x /usr/bin/clash-meta ] && ln -sf /usr/bin/mihomo /usr/bin/clash-meta
|
||||
rm -f /usr/share/clashoo/check_luci_version.sh /usr/share/clashoo/check_clash_meta_version.sh /usr/share/clashoo/check_mihomo_core_version.sh
|
||||
rm -f /usr/share/clashoo/cuslist.sh /usr/share/clashoo/groups.sh /usr/share/clashoo/load_groups.sh /usr/share/clashoo/panel_diag.sh
|
||||
rm -f /usr/share/clashoo/rmlist.sh /usr/share/clashoo/uplist.sh /usr/share/clashoo/rpc_restart_async.sh /usr/share/clashoo/rpc_update_china_ip_async.sh
|
||||
rm -rf /usr/share/clashoo/create
|
||||
/usr/share/clashoo/migrate_lan_acl.sh >/dev/null 2>&1 || true
|
||||
/usr/share/clashoo/migrate_fake_ip_filter.sh >/dev/null 2>&1 || true
|
||||
/usr/share/clashoo/detect_core_arch.sh >/dev/null 2>&1 || true
|
||||
if [ "$$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ]; then
|
||||
/etc/init.d/clashoo enable 2>/dev/null || true
|
||||
else
|
||||
/etc/init.d/clashoo disable 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
endef
|
||||
|
||||
define Build/Prepare
|
||||
$(Build/Prepare/Default)
|
||||
$(RM) -r $(PKG_BUILD_DIR)/rules/logic_test
|
||||
endef
|
||||
|
||||
$(eval $(call GoBinPackage,clashoo))
|
||||
$(eval $(call BuildPackage,clashoo))
|
||||
@@ -0,0 +1,156 @@
|
||||
|
||||
config clashoo 'config'
|
||||
option redir_port '7891'
|
||||
option http_port '8080'
|
||||
option socks_port '1080'
|
||||
option mixed_port '7890'
|
||||
option enable_ipv6 'true'
|
||||
option enable '0'
|
||||
option dash_port '9090'
|
||||
option dashboard_panel 'zashboard'
|
||||
option level 'info'
|
||||
option core '3'
|
||||
option allow_lan '1'
|
||||
option dnsforwader '1'
|
||||
option subcri 'meta'
|
||||
option cusrule '0'
|
||||
option dnscache '1'
|
||||
option p_mode 'rule'
|
||||
option append_rules '0'
|
||||
option enhanced_mode 'fake-ip'
|
||||
option fake_ip_range '198.18.0.1/16'
|
||||
option fake_ip_range6 'fc00::/18'
|
||||
list fake_ip_filter '+.lan'
|
||||
list fake_ip_filter '+.local'
|
||||
list fake_ip_filter 'localhost.ptlogin2.qq.com'
|
||||
list fake_ip_filter 'rule-set:cn_domain'
|
||||
option fake_ip_filter_migrated '1'
|
||||
option listen_port '1053'
|
||||
option interf '0'
|
||||
option download_core 'amd64-compatible'
|
||||
option dcore '3'
|
||||
option same_tag '1'
|
||||
option geoip_source '3'
|
||||
option geoip_format 'mmdb'
|
||||
option geodata_loader 'standard'
|
||||
option tproxy_port '7982'
|
||||
option access_control '0'
|
||||
option acl_migrated '1'
|
||||
option tcp_mode 'redirect'
|
||||
option tun_mode '0'
|
||||
option core_mirror_prefix 'https://gh-proxy.com/'
|
||||
option core_download_advanced '0'
|
||||
list default_nameserver '223.5.5.5'
|
||||
list default_nameserver '119.29.29.29'
|
||||
option dns_ecs ''
|
||||
option dns_ecs_override '0'
|
||||
option fallback_filter_geoip '0'
|
||||
list fallback_filter_ipcidr '240.0.0.0/4'
|
||||
option dns_respect_rules '1'
|
||||
option dns_loopback_compat '0'
|
||||
option dns_migrated '1'
|
||||
option block_quic '0'
|
||||
option singbox_independent_cache '0'
|
||||
option udp_mode 'tproxy'
|
||||
option ipv4_dns_hijack '1'
|
||||
option ipv6_dns_hijack '1'
|
||||
option ipv4_proxy '1'
|
||||
option ipv6_proxy '1'
|
||||
option fake_ip_ping_hijack '1'
|
||||
option dns_leak_protect '0'
|
||||
option geoip_mmdb_url 'https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb'
|
||||
option geosite_url 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat'
|
||||
option geoip_dat_url 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat'
|
||||
option enable_udp '1'
|
||||
option stack 'gvisor'
|
||||
option bypass_china '1'
|
||||
option bypass_china_ipv6 '1'
|
||||
option bypass_port_mode 'common'
|
||||
option sniffer_streaming '1'
|
||||
option selection_cache '1'
|
||||
option fake_ip_cache '1'
|
||||
option enable_dns '1'
|
||||
option disable_quic_gso '1'
|
||||
option smart_policy_priority 'Premium:0.9;SG:1.3'
|
||||
option smart_prefer_asn '1'
|
||||
option smart_uselightgbm '1'
|
||||
option smart_collectdata '1'
|
||||
option smart_collect_size '100'
|
||||
option smart_collect_rate '1'
|
||||
option smart_lgbm_auto_update '1'
|
||||
option smart_lgbm_update_interval '72'
|
||||
option smart_lgbm_url 'https://github.com/vernesong/mihomo/releases/download/LightGBM-Model/Model.bin'
|
||||
list defaul_nameserver '114.114.114.114'
|
||||
list defaul_nameserver '223.5.5.5'
|
||||
option auto_update '1'
|
||||
option auto_update_time '72'
|
||||
option auto_subscription_update '0'
|
||||
option subscription_update_interval '72'
|
||||
option auto_clear_log '1'
|
||||
option clear_time '72'
|
||||
option core_type 'mihomo'
|
||||
option smart_auto_switch '0'
|
||||
option core_only '0'
|
||||
|
||||
config hosts
|
||||
option enabled '0'
|
||||
option adress '*.clash.dev'
|
||||
option ip '127.0.0.1'
|
||||
|
||||
config hosts
|
||||
option enabled '0'
|
||||
option adress 'alpha.clash.dev'
|
||||
option ip '::1'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'nameserver'
|
||||
option ser_address 'https://dns.alidns.com/dns-query'
|
||||
option protocol 'none'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'nameserver'
|
||||
option ser_address 'https://doh.pub/dns-query'
|
||||
option protocol 'none'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'proxy-server-nameserver'
|
||||
option ser_address '1.1.1.1'
|
||||
option protocol 'tls://'
|
||||
option ser_port '853'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'fallback'
|
||||
option ser_address 'https://cloudflare-dns.com/dns-query'
|
||||
option protocol 'none'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'fallback'
|
||||
option ser_address 'https://dns.google/dns-query'
|
||||
option protocol 'none'
|
||||
|
||||
config dnsservers
|
||||
option enabled '1'
|
||||
option ser_type 'direct-nameserver'
|
||||
option ser_address '223.5.5.5'
|
||||
option protocol 'udp://'
|
||||
|
||||
config dns_policy
|
||||
option enabled '1'
|
||||
option policy_type 'nameserver-policy'
|
||||
option matcher 'geosite:cn'
|
||||
list nameserver 'https://dns.alidns.com/dns-query'
|
||||
list nameserver 'https://doh.pub/dns-query'
|
||||
|
||||
config authentication
|
||||
|
||||
# default empty custom-rule row so the section shows the input form, not just an add
|
||||
# button; injectors skip rules with an empty ipaaddr, so it generates nothing until filled
|
||||
config addtype
|
||||
option type 'DOMAIN-SUFFIX'
|
||||
option pgroup 'DIRECT'
|
||||
option ipaaddr ''
|
||||
Executable
+2507
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ -n "$(uci -q get clashoo.config.download_core 2>/dev/null)" ] && exit 0
|
||||
|
||||
raw="$(uname -m 2>/dev/null)"
|
||||
[ -n "$raw" ] || raw="$(apk --print-arch 2>/dev/null)"
|
||||
[ -n "$raw" ] || raw="$(opkg status libc 2>/dev/null | awk -F': ' '/^Architecture/{print $2; exit}')"
|
||||
[ -n "$raw" ] || exit 0
|
||||
|
||||
case "$raw" in
|
||||
x86_64|amd64) arch="amd64-compatible" ;;
|
||||
aarch64*|arm64) arch="arm64" ;;
|
||||
armv7*|arm_cortex-a[7-9]*|arm_cortex-a1[0-9]*) arch="armv7" ;;
|
||||
armv6*|arm_cortex-a[56]*) arch="armv6" ;;
|
||||
arm*) arch="armv5" ;;
|
||||
i[3-6]86) arch="386" ;;
|
||||
mips64el*) arch="mips64le" ;;
|
||||
mips64*) arch="mips64" ;;
|
||||
mipsel*) arch="mipsle" ;;
|
||||
mips*) arch="mips" ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
uci -q set clashoo.config.download_core="$arch"
|
||||
uci -q commit clashoo
|
||||
exit 0
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/bin/sh
|
||||
|
||||
umask 077
|
||||
|
||||
CONFIG_YAML="${CONFIG_YAML:-/etc/clashoo/config.yaml}"
|
||||
NFT_V4="${NFT_V4:-/usr/share/clashoo/nftables/geoip_cn.nft}"
|
||||
NFT_V6="${NFT_V6:-/usr/share/clashoo/nftables/geoip6_cn.nft}"
|
||||
MAX_CONN=200
|
||||
|
||||
TMPDIR_D="$(mktemp -d /tmp/.clashoo_diag.XXXXXX 2>/dev/null)" || exit 1
|
||||
trap 'rm -rf "$TMPDIR_D" 2>/dev/null' EXIT INT TERM
|
||||
|
||||
uci_get() {
|
||||
uci -q get "clashoo.config.$1" 2>/dev/null
|
||||
}
|
||||
|
||||
section() {
|
||||
printf '\n===== %s =====\n' "$1"
|
||||
}
|
||||
|
||||
yesno() {
|
||||
[ -n "$1" ] && [ "$1" != "0" ] && [ "$1" != "false" ] && echo "on" || echo "off"
|
||||
}
|
||||
|
||||
bool_on() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bypass4="$(uci_get bypass_china)"
|
||||
bypass6="$(uci_get bypass_china_ipv6)"
|
||||
[ -n "$bypass6" ] || bypass6="$bypass4"
|
||||
|
||||
section "配置摘要"
|
||||
printf '服务开关 : %s\n' "$(yesno "$(uci_get enable)")"
|
||||
printf '运行模式 : %s\n' "$(uci_get enhanced_mode)"
|
||||
printf 'TCP / UDP : %s / %s\n' "$(uci_get tcp_mode)" "$(uci_get udp_mode)"
|
||||
printf '大陆绕过 v4 : %s\n' "$(yesno "$bypass4")"
|
||||
printf '大陆绕过 v6 : %s%s\n' "$(yesno "$bypass6")" \
|
||||
"$([ -z "$(uci_get bypass_china_ipv6)" ] && echo '(未设置,跟随 v4)')"
|
||||
printf '接管端口 : %s' "$(uci_get bypass_port_mode)"
|
||||
[ "$(uci_get bypass_port_mode)" = "custom" ] && printf ' (%s)' "$(uci_get bypass_port_custom)"
|
||||
printf '\n'
|
||||
printf 'IPv6 代理 : %s\n' "$(yesno "$(uci_get ipv6_proxy)")"
|
||||
printf '阻断 QUIC : %s\n' "$(yesno "$(uci_get block_quic)")"
|
||||
|
||||
if [ "$(uci_get bypass_port_mode)" = "common" ]; then
|
||||
printf '\n注意: 接管端口为 common,只代理 22/53/80/443/8080/8443,\n'
|
||||
printf ' 游戏与 P2P 常用的高位端口不会走代理。\n'
|
||||
fi
|
||||
|
||||
section "fake-ip 过滤"
|
||||
if [ -s "$CONFIG_YAML" ]; then
|
||||
if grep -qE '^[[:space:]]*fake-ip-filter:' "$CONFIG_YAML" 2>/dev/null; then
|
||||
filter_mode="$(uci_get fake_ip_filter_mode)"
|
||||
[ -n "$filter_mode" ] || filter_mode="$(yq -M e '(.dns."fake-ip-filter-mode" // "blacklist") | tostring' "$CONFIG_YAML" 2>/dev/null)"
|
||||
if [ "$filter_mode" = "whitelist" ]; then
|
||||
echo "白名单模式:列表中的域名使用 fake-IP"
|
||||
elif grep -qiE 'rule-set:cn_domain|RULE-SET,[[:space:]]*cn_domain,[[:space:]]*real-ip' "$CONFIG_YAML" 2>/dev/null; then
|
||||
echo "含 rule-set:cn_domain (国内域名返回真实 IP)"
|
||||
else
|
||||
echo "缺少 rule-set:cn_domain"
|
||||
echo " 国内域名会拿到 fake-ip 走代理,与硬编码国内 IP 分成两条路。"
|
||||
fi
|
||||
else
|
||||
echo "配置中无 fake-ip-filter"
|
||||
fi
|
||||
else
|
||||
echo "找不到 $CONFIG_YAML"
|
||||
fi
|
||||
|
||||
section "内核分流规则"
|
||||
if [ -s "$CONFIG_YAML" ]; then
|
||||
echo "-- rules 末尾 5 条 --"
|
||||
grep -E '^[[:space:]]*-[[:space:]]*["'"'"']?(GEOIP|RULE-SET|MATCH|IP-CIDR|DOMAIN)' "$CONFIG_YAML" 2>/dev/null | tail -5
|
||||
fi
|
||||
|
||||
section "IP 库"
|
||||
for f in "$NFT_V4" "$NFT_V6"; do
|
||||
if [ -s "$f" ]; then
|
||||
printf '%-46s %6s 条 %s\n' "$f" \
|
||||
"$(grep -cE '^[[:space:]]*[0-9a-fA-F].*/' "$f" 2>/dev/null)" \
|
||||
"$(date -r "$f" '+%Y-%m-%d %H:%M' 2>/dev/null)"
|
||||
else
|
||||
printf '%-46s 缺失\n' "$f"
|
||||
fi
|
||||
done
|
||||
for f in /etc/clashoo/Country.mmdb /etc/clashoo/geoip.metadb; do
|
||||
[ -f "$f" ] && printf '%-46s %s\n' "$f" "$(ls -lh "$f" 2>/dev/null | awk '{print $5}')"
|
||||
done
|
||||
|
||||
set_count() {
|
||||
nft list set inet fw4 "$1" 2>/dev/null | tr ',' '\n' | grep -cE '[0-9a-fA-F]+[.:].*/'
|
||||
}
|
||||
|
||||
section "防火墙绕过规则"
|
||||
if nft list sets inet fw4 2>/dev/null | grep -q clashoo_china; then
|
||||
for ch in dstnat mangle_prerouting; do
|
||||
nft -a list chain inet fw4 "$ch" 2>/dev/null |
|
||||
grep -E '@clashoo_china6? ' | sed "s|^[[:space:]]*| ${ch}: |"
|
||||
done
|
||||
echo
|
||||
echo "已加载元素数(nft auto-merge 会合并相邻网段,少于文件行数属正常):"
|
||||
bool_on "$bypass4" && printf ' clashoo_china : %s\n' "$(set_count clashoo_china)"
|
||||
bool_on "$bypass6" && printf ' clashoo_china6 : %s\n' "$(set_count clashoo_china6)"
|
||||
else
|
||||
echo "当前 nftables 中没有 clashoo_china 集合(绕过未启用或服务未运行)"
|
||||
fi
|
||||
|
||||
section "绕过命中检查"
|
||||
if ! bool_on "$bypass4" && ! bool_on "$bypass6"; then
|
||||
echo "两个绕过均未启用,所有被接管的流量都交给内核判定,无需检查"
|
||||
else
|
||||
SECRET="$(uci_get dash_pass)"
|
||||
PORT="$(uci_get dash_port)"
|
||||
LAN_IP="$(uci -q get network.lan.ipaddr 2>/dev/null | awk -F/ '{print $1}')"
|
||||
[ -n "$LAN_IP" ] || LAN_IP="127.0.0.1"
|
||||
CONN_JSON="$TMPDIR_D/connections.json"
|
||||
|
||||
if [ -z "$PORT" ]; then
|
||||
echo "未配置控制端口,跳过"
|
||||
elif ! curl -m 5 -s -H "Authorization: Bearer ${SECRET}" \
|
||||
"http://${LAN_IP}:${PORT}/connections" -o "$CONN_JSON" 2>/dev/null; then
|
||||
echo "无法访问内核 API (http://${LAN_IP}:${PORT}),跳过"
|
||||
elif [ ! -s "$CONN_JSON" ]; then
|
||||
echo "内核 API 返回为空,跳过"
|
||||
else
|
||||
jsonfilter -i "$CONN_JSON" -e '@["connections"][*]["metadata"]["destinationIP"]' \
|
||||
2>/dev/null > "${CONN_JSON}.ip"
|
||||
jsonfilter -i "$CONN_JSON" -e '@["connections"][*]["chains"][0]' \
|
||||
2>/dev/null > "${CONN_JSON}.ch"
|
||||
|
||||
total="$(wc -l < "${CONN_JSON}.ip" 2>/dev/null | tr -d ' ')"
|
||||
[ -n "$total" ] || total=0
|
||||
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "当前无活动连接"
|
||||
elif [ "$total" != "$(wc -l < "${CONN_JSON}.ch" 2>/dev/null | tr -d ' ')" ]; then
|
||||
echo "内核 API 字段数不一致,跳过"
|
||||
else
|
||||
awk 'NR==FNR { a[FNR] = $0; next } { print a[FNR] "\t" $0 }' \
|
||||
"${CONN_JSON}.ip" "${CONN_JSON}.ch" 2>/dev/null \
|
||||
| awk -F'\t' '$2 != "DIRECT" && $1 != "" && $1 !~ /^(198\.18\.|::|fc00:|fd)/ { print $1 "\t" $2 }' \
|
||||
| sort -u | head -n "$MAX_CONN" > "${CONN_JSON}.chk"
|
||||
|
||||
hit=0
|
||||
: > "${CONN_JSON}.bad"
|
||||
while IFS="$(printf '\t')" read -r dip chain; do
|
||||
[ -n "$dip" ] || continue
|
||||
case "$dip" in
|
||||
*:*) bool_on "$bypass6" || continue
|
||||
set_name=clashoo_china6 ;;
|
||||
*) bool_on "$bypass4" || continue
|
||||
set_name=clashoo_china ;;
|
||||
esac
|
||||
if nft get element inet fw4 "$set_name" "{ $dip }" >/dev/null 2>&1; then
|
||||
hit=$((hit + 1))
|
||||
printf ' %-40s -> %s\n' "$dip" "$chain" >> "${CONN_JSON}.bad"
|
||||
fi
|
||||
done < "${CONN_JSON}.chk"
|
||||
|
||||
printf '活动连接 %s 条,其中走代理的去重目的地 %s 个(上限 %s)\n' \
|
||||
"$total" "$(wc -l < "${CONN_JSON}.chk" 2>/dev/null | tr -d ' ')" "$MAX_CONN"
|
||||
if [ "$hit" -gt 0 ]; then
|
||||
printf '命中绕过白名单却仍走代理: %s 个\n\n' "$hit"
|
||||
head -20 "${CONN_JSON}.bad"
|
||||
echo
|
||||
echo "这些目的地在防火墙白名单内,本应直连,却出现在代理链路上。"
|
||||
echo "可能是连接建立于规则变更之前,或该地址族的绕过规则未生效。"
|
||||
else
|
||||
echo "未发现白名单地址走代理的连接"
|
||||
fi
|
||||
rm -f "${CONN_JSON}.chk" "${CONN_JSON}.bad" 2>/dev/null
|
||||
fi
|
||||
rm -f "${CONN_JSON}.ip" "${CONN_JSON}.ch" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/ucode
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile } from 'fs';
|
||||
|
||||
// Standalone sing-box config version-migration, used by the init.d core-only
|
||||
// path to upgrade an imported config (momo/homeproxy/etc.) to the format the
|
||||
// current sing-box core accepts, WITHOUT clashoo normalize/takeover.
|
||||
//
|
||||
// IMPORTANT: migrate_singbox() below is kept byte-for-byte in sync with the
|
||||
// copy in luci-app-clashoo/.../luci.clashoo (the UI "migrate" button). If you
|
||||
// change one, change the other.
|
||||
|
||||
function migrate_singbox(cfg) {
|
||||
// --- Fix DNS servers: address-based → type-based, remove detour ---
|
||||
if (cfg.dns && cfg.dns.servers) {
|
||||
cfg.dns.servers = map(cfg.dns.servers || [], function(srv) {
|
||||
let addr = srv.address || '';
|
||||
let new_srv = {};
|
||||
let skip = { address: 1, detour: 1, address_resolver: 1, address_strategy: 1 };
|
||||
for (let k in srv) if (!skip[k]) new_srv[k] = srv[k];
|
||||
|
||||
if (addr === 'fakeip') {
|
||||
new_srv.type = 'fakeip';
|
||||
} else if (addr === 'local' || addr === '') {
|
||||
new_srv.type = new_srv.type || 'local';
|
||||
} else if (match(addr, /^rcode:\/\//) || addr === 'rcode') {
|
||||
new_srv._rcode = true; // mark for removal, handled via dns rule action
|
||||
} else if (match(addr, /^h3:\/\//)) {
|
||||
let host = replace(replace(addr, /^h3:\/\//, ''), /\/.*$/, '');
|
||||
new_srv.type = 'h3'; new_srv.server = host;
|
||||
} else if (match(addr, /^https:\/\//)) {
|
||||
let host = replace(replace(addr, /^https:\/\//, ''), /\/.*$/, '');
|
||||
new_srv.type = 'https'; new_srv.server = host;
|
||||
} else if (match(addr, /^tls:\/\//)) {
|
||||
new_srv.type = 'tls'; new_srv.server = replace(addr, /^tls:\/\//, '');
|
||||
} else if (match(addr, /^tcp:\/\//)) {
|
||||
new_srv.type = 'tcp'; new_srv.server = replace(addr, /^tcp:\/\//, '');
|
||||
} else if (addr && !new_srv.type) {
|
||||
new_srv.type = 'udp'; new_srv.server = addr; // plain IP → UDP
|
||||
} else if (addr) {
|
||||
new_srv.address = addr; // unknown format, keep as-is
|
||||
}
|
||||
return new_srv;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Remove rcode servers (unsupported in 1.12+ new format), convert referencing rules to reject ---
|
||||
let rcode_tags = {};
|
||||
if (cfg.dns && cfg.dns.servers) {
|
||||
let good = [];
|
||||
for (let srv in cfg.dns.servers) {
|
||||
if (srv._rcode) rcode_tags[srv.tag] = true;
|
||||
else push(good, srv);
|
||||
}
|
||||
cfg.dns.servers = good;
|
||||
}
|
||||
if (cfg.dns && cfg.dns.rules && length(keys(rcode_tags)) > 0) {
|
||||
cfg.dns.rules = map(cfg.dns.rules || [], function(rule) {
|
||||
if (rule.server && rcode_tags[rule.server]) {
|
||||
let r = {};
|
||||
for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k];
|
||||
r.action = 'reject';
|
||||
return r;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Remove legacy dns.fakeip.enabled (1.12+ fakeip is configured via type:"fakeip" server) ---
|
||||
if (cfg.dns && cfg.dns.fakeip) {
|
||||
let fp = cfg.dns.fakeip;
|
||||
let new_fp = {};
|
||||
for (let k in fp) if (k !== 'enabled') new_fp[k] = fp[k];
|
||||
if (length(keys(new_fp)) > 0) cfg.dns.fakeip = new_fp;
|
||||
else delete cfg.dns['fakeip'];
|
||||
}
|
||||
|
||||
// --- Fix DNS rules: add action:"route" when server is set ---
|
||||
if (cfg.dns && cfg.dns.rules) {
|
||||
cfg.dns.rules = map(cfg.dns.rules || [], function(rule) {
|
||||
if (rule.server && !rule.action) {
|
||||
let r = {};
|
||||
for (let k in rule) r[k] = rule[k];
|
||||
r.action = 'route';
|
||||
return r;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fix legacy special outbounds (block / dns types) ---
|
||||
let special = {};
|
||||
if (cfg.outbounds) {
|
||||
let kept = [];
|
||||
for (let ob in cfg.outbounds) {
|
||||
if (ob.type === 'block' || ob.type === 'dns') {
|
||||
special[ob.tag] = ob.type;
|
||||
} else {
|
||||
push(kept, ob);
|
||||
}
|
||||
}
|
||||
cfg.outbounds = kept;
|
||||
}
|
||||
|
||||
// Patch route rules that pointed to removed special outbounds
|
||||
if (cfg.route && cfg.route.rules && length(keys(special)) > 0) {
|
||||
cfg.route.rules = map(cfg.route.rules || [], function(rule) {
|
||||
let sp = rule.outbound && special[rule.outbound];
|
||||
if (!sp) return rule;
|
||||
let r = {};
|
||||
for (let k in rule) if (k !== 'outbound') r[k] = rule[k];
|
||||
r.action = (sp === 'dns') ? 'hijack-dns' : 'reject';
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Migrate geoip/geosite database refs to rule_set (removed in sing-box 1.12) ---
|
||||
let needed_rule_sets = {};
|
||||
|
||||
let migrate_geo_rule = function(rule) {
|
||||
let has_geo = rule.geoip || rule.geosite;
|
||||
if (!has_geo) return rule;
|
||||
let r = {};
|
||||
let new_rs = [];
|
||||
for (let k in rule) {
|
||||
if (k === 'geoip') {
|
||||
for (let n in rule[k]) {
|
||||
let tag = 'geoip-' + n;
|
||||
needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-' + n + '.srs';
|
||||
push(new_rs, tag);
|
||||
}
|
||||
} else if (k === 'geosite') {
|
||||
for (let n in rule[k]) {
|
||||
let tag = 'geosite-' + n;
|
||||
needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-' + n + '.srs';
|
||||
push(new_rs, tag);
|
||||
}
|
||||
} else {
|
||||
r[k] = rule[k];
|
||||
}
|
||||
}
|
||||
// Merge new rule_set tags with any existing ones
|
||||
let existing_rs = type(r.rule_set) === 'array' ? r.rule_set :
|
||||
(r.rule_set ? [r.rule_set] : []);
|
||||
for (let t in new_rs) push(existing_rs, t);
|
||||
r.rule_set = existing_rs;
|
||||
return r;
|
||||
};
|
||||
|
||||
if (cfg.route && cfg.route.rules)
|
||||
cfg.route.rules = map(cfg.route.rules, migrate_geo_rule);
|
||||
if (cfg.dns && cfg.dns.rules)
|
||||
cfg.dns.rules = map(cfg.dns.rules, migrate_geo_rule);
|
||||
|
||||
// download_detour must be DIRECT: at first start rule-sets aren't loaded, so
|
||||
// routing it via a proxy deadlocks (DNS recursion -> sing-box FATAL).
|
||||
let pick_dl_detour = function() {
|
||||
if (cfg.outbounds) {
|
||||
for (let ob in cfg.outbounds) {
|
||||
if (ob && ob.type === 'direct' && ob.tag) return ob.tag;
|
||||
}
|
||||
}
|
||||
return 'DIRECT';
|
||||
};
|
||||
|
||||
// Add rule_set download entries for each referenced geo tag
|
||||
if (length(keys(needed_rule_sets)) > 0) {
|
||||
if (!cfg.route) cfg.route = {};
|
||||
if (!cfg.route.rule_set) cfg.route.rule_set = [];
|
||||
let existing_tags = {};
|
||||
for (let rs in cfg.route.rule_set) existing_tags[rs.tag] = true;
|
||||
let dl_detour = pick_dl_detour();
|
||||
for (let tag in keys(needed_rule_sets)) {
|
||||
if (!existing_tags[tag]) {
|
||||
push(cfg.route.rule_set, {
|
||||
tag: tag,
|
||||
type: 'remote',
|
||||
format: 'binary',
|
||||
url: needed_rule_sets[tag],
|
||||
download_detour: dl_detour
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remove deprecated 'outbound' DNS rule items (fatal in sing-box 1.13) ---
|
||||
// Replacement: set route.default_domain_resolver to the plain IP-based resolver
|
||||
let dns_resolver_tag = '';
|
||||
if (cfg.dns && cfg.dns.servers) {
|
||||
for (let srv in cfg.dns.servers) {
|
||||
if ((srv.type === 'udp' || srv.type === 'local') && srv.server &&
|
||||
match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
||||
dns_resolver_tag = srv.tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cfg.dns && cfg.dns.rules) {
|
||||
let filtered = [];
|
||||
for (let rule in cfg.dns.rules) {
|
||||
if (!rule.outbound) push(filtered, rule);
|
||||
}
|
||||
cfg.dns.rules = filtered;
|
||||
}
|
||||
// Set route.default_domain_resolver (replaces outbound:any DNS routing)
|
||||
if (dns_resolver_tag) {
|
||||
if (!cfg.route) cfg.route = {};
|
||||
if (!cfg.route.default_domain_resolver)
|
||||
cfg.route.default_domain_resolver = dns_resolver_tag;
|
||||
}
|
||||
|
||||
// --- Fix DNS servers with domain-name server field: add domain_resolver ---
|
||||
// sing-box 1.12+ requires domain_resolver when server= is a hostname (not IP)
|
||||
if (cfg.dns && cfg.dns.servers && dns_resolver_tag) {
|
||||
let domain_types = { https: 1, h3: 1, tls: 1, tcp: 1 };
|
||||
cfg.dns.servers = map(cfg.dns.servers, function(srv) {
|
||||
if (!domain_types[srv.type]) return srv;
|
||||
if (!srv.server || match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return srv;
|
||||
if (srv.domain_resolver) return srv;
|
||||
let r = {};
|
||||
for (let k in srv) r[k] = srv[k];
|
||||
r.domain_resolver = dns_resolver_tag;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fix DNS rules referencing non-existent server tags → action: reject ---
|
||||
if (cfg.dns && cfg.dns.servers && cfg.dns.rules) {
|
||||
let valid_servers = {};
|
||||
for (let srv in cfg.dns.servers) valid_servers[srv.tag] = true;
|
||||
cfg.dns.rules = map(cfg.dns.rules, function(rule) {
|
||||
if (!rule.server || valid_servers[rule.server]) return rule;
|
||||
let r = {};
|
||||
for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k];
|
||||
r.action = 'reject';
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fix reject method: sing-box 1.14 rejects "dropped", only "default"/"drop" ---
|
||||
if (cfg.dns && cfg.dns.rules) {
|
||||
cfg.dns.rules = map(cfg.dns.rules, function(rule) {
|
||||
if (rule.action !== 'reject' || !rule.method) return rule;
|
||||
let m = rule.method;
|
||||
if (m === 'dropped') m = 'drop';
|
||||
if (m !== 'default' && m !== 'drop') {
|
||||
// unknown value: drop it, sing-box defaults to NXDOMAIN
|
||||
let r = {};
|
||||
for (let k in rule) if (k !== 'method') r[k] = rule[k];
|
||||
return r;
|
||||
}
|
||||
if (m === rule.method) return rule;
|
||||
let r = {};
|
||||
for (let k in rule) r[k] = rule[k];
|
||||
r.method = m;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Migrate TUN inet4_address/inet6_address -> address array (1.12+), and
|
||||
// strip legacy inbound fields removed in 1.13 (sniff / sniff_override_destination /
|
||||
// sniff_timeout / domain_strategy). If any inbound had sniff on, prepend a
|
||||
// { "action": "sniff" } route rule as the equivalent. ---
|
||||
let had_sniff = false;
|
||||
if (cfg.inbounds) {
|
||||
cfg.inbounds = map(cfg.inbounds, function(ib) {
|
||||
let r = {};
|
||||
let addrs = [];
|
||||
for (let k in ib) {
|
||||
if (k === 'inet4_address') {
|
||||
if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); }
|
||||
else push(addrs, ib[k]);
|
||||
} else if (k === 'inet6_address') {
|
||||
if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); }
|
||||
else push(addrs, ib[k]);
|
||||
} else if (k === 'sniff') {
|
||||
if (ib[k]) had_sniff = true;
|
||||
} else if (k === 'sniff_override_destination' || k === 'sniff_timeout' ||
|
||||
k === 'domain_strategy') {
|
||||
// drop — moved to route.rules actions in 1.13
|
||||
} else {
|
||||
r[k] = ib[k];
|
||||
}
|
||||
}
|
||||
if (length(addrs) > 0) r.address = addrs;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
if (had_sniff) {
|
||||
if (!cfg.route) cfg.route = {};
|
||||
if (!cfg.route.rules) cfg.route.rules = [];
|
||||
let has_sniff_rule = false;
|
||||
for (let rl in cfg.route.rules)
|
||||
if (rl.action === 'sniff') { has_sniff_rule = true; break; }
|
||||
if (!has_sniff_rule) {
|
||||
let new_rules = [ { action: 'sniff' } ];
|
||||
for (let rl in cfg.route.rules) push(new_rules, rl);
|
||||
cfg.route.rules = new_rules;
|
||||
}
|
||||
}
|
||||
|
||||
// --- sing-box 1.14 requires route.default_domain_resolver; add one if missing,
|
||||
// preferring dns.final, else the first dns.servers entry with a tag. ---
|
||||
if (cfg.route && !cfg.route.default_domain_resolver) {
|
||||
let resolver_tag = '';
|
||||
if (cfg.dns && cfg.dns.final) resolver_tag = cfg.dns.final;
|
||||
else if (cfg.dns && cfg.dns.servers) {
|
||||
for (let srv in cfg.dns.servers)
|
||||
if (srv.tag) { resolver_tag = srv.tag; break; }
|
||||
}
|
||||
if (resolver_tag)
|
||||
cfg.route.default_domain_resolver = { server: resolver_tag };
|
||||
}
|
||||
|
||||
// --- Remove dangling outbound refs from selectors/urltest, and drop airline
|
||||
// pseudo-nodes. Two cases: (1) subconverter may reference a missing tag (e.g.
|
||||
// REJECT); (2) "Traffic:.../Expire:.../quota" pseudo-nodes are real SS/Vmess
|
||||
// outbounds (kept so the UI can read traffic/expiry) but don't forward — in a
|
||||
// selector/urltest they win (0ms) and swallow all foreign traffic. ---
|
||||
let is_pseudo_tag = function(t) {
|
||||
if (!t) return false;
|
||||
return match(t, /^Traffic[::]/) ||
|
||||
match(t, /^Expire[::]/) ||
|
||||
match(t, /剩余流量|剩余[::]/) ||
|
||||
match(t, /距离下次重置/) ||
|
||||
match(t, /到期(时间|日期)?[::]/) ||
|
||||
match(t, /官网[::]|网站[::]|套餐[::]?|客服[::]/) ||
|
||||
match(t, /QQ[群]?[::]/) ||
|
||||
match(t, /Telegram|TG群|官方群/) ||
|
||||
match(t, /续费|订阅地址|流量重置/);
|
||||
};
|
||||
if (cfg.outbounds) {
|
||||
let defined_tags = {};
|
||||
for (let ob in cfg.outbounds) defined_tags[ob.tag] = true;
|
||||
cfg.outbounds = map(cfg.outbounds, function(ob) {
|
||||
if ((ob.type !== 'selector' && ob.type !== 'urltest' && ob.type !== 'loadbalance') ||
|
||||
!ob.outbounds) return ob;
|
||||
let valid = [];
|
||||
for (let t in ob.outbounds) {
|
||||
if (!defined_tags[t]) continue;
|
||||
if (is_pseudo_tag(t)) continue;
|
||||
push(valid, t);
|
||||
}
|
||||
if (length(valid) === length(ob.outbounds)) return ob;
|
||||
let r = {};
|
||||
for (let k in ob) r[k] = ob[k];
|
||||
r.outbounds = valid;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Force download_detour=DIRECT on every remote rule_set ---
|
||||
// subconverter often emits download_detour="auto", but the proxy isn't ready at
|
||||
// first start -> urltest deadlock ("fetch rule-set: deadline exceeded") -> FATAL.
|
||||
// The srs URLs already use a CN-reachable mirror, so DIRECT is always correct.
|
||||
if (cfg.route && cfg.route.rule_set) {
|
||||
let dl_detour = pick_dl_detour();
|
||||
cfg.route.rule_set = map(cfg.route.rule_set, function(rs) {
|
||||
if (!rs || rs.type !== 'remote') return rs;
|
||||
let r = {};
|
||||
for (let k in rs) r[k] = rs[k];
|
||||
r.download_detour = dl_detour;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
|
||||
// ---- CLI entry: ucode migrate_singbox.uc <config.json> ----
|
||||
let _path = ARGV[0] || '';
|
||||
if (!_path) { print("missing path\n"); exit(1); }
|
||||
let _raw = readfile(_path);
|
||||
if (!_raw) { print("read failed\n"); exit(1); }
|
||||
let _cfg = json(_raw);
|
||||
if (!_cfg) { print("json parse failed\n"); exit(1); }
|
||||
let _before = sprintf('%J', _cfg);
|
||||
_cfg = migrate_singbox(_cfg);
|
||||
let _after = sprintf('%J', _cfg);
|
||||
if (_before === _after) { print("nochange\n"); exit(0); }
|
||||
if (writefile(_path, _after) === null) { print("write failed\n"); exit(1); }
|
||||
print("migrated\n");
|
||||
@@ -0,0 +1,939 @@
|
||||
#!/usr/bin/ucode
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile, access, popen } from 'fs';
|
||||
|
||||
let path = ARGV[0] || '';
|
||||
let redir_port = +(ARGV[1] || '7891');
|
||||
let tproxy_port = +(ARGV[2] || '7982');
|
||||
let mixed_port = +(ARGV[3] || '7890');
|
||||
let has_tun_device = (ARGV[4] || '1') == '1';
|
||||
if (has_tun_device && system('(ip tuntap add mode tun name cotuntest >/dev/null 2>&1 && ip link del cotuntest >/dev/null 2>&1)') != 0)
|
||||
has_tun_device = false;
|
||||
|
||||
// default 6666 must match fw4.sh CORE_ROUTING_MARK (0x1a0a)
|
||||
let routing_mark = +(ARGV[5] || '6666');
|
||||
let dns_port = +(ARGV[6] || '1053');
|
||||
let dash_port = +(ARGV[7] || '9090');
|
||||
let dash_secret = ARGV[8] != null ? (ARGV[8] + '') : '';
|
||||
|
||||
if (!path) {
|
||||
print("missing path\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let raw = readfile(path);
|
||||
if (!raw) {
|
||||
print("read failed\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let cfg = json(raw);
|
||||
if (!cfg) {
|
||||
print("json parse failed\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function s_len(s) {
|
||||
return length(s || '');
|
||||
}
|
||||
|
||||
function s_sub(s, start, count) {
|
||||
if (count == null)
|
||||
return substr(s || '', start);
|
||||
return substr(s || '', start, count);
|
||||
}
|
||||
|
||||
function is_space(ch) {
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
|
||||
}
|
||||
|
||||
function trim_s(s) {
|
||||
s = (s == null) ? '' : (s + '');
|
||||
while (s_len(s) > 0 && is_space(s_sub(s, 0, 1)))
|
||||
s = s_sub(s, 1);
|
||||
while (s_len(s) > 0 && is_space(s_sub(s, s_len(s) - 1, 1)))
|
||||
s = s_sub(s, 0, s_len(s) - 1);
|
||||
return s;
|
||||
}
|
||||
|
||||
let default_interface = '';
|
||||
let _default_if_pipe = popen("ip -4 route show default 2>/dev/null | awk '{print $5; exit}'");
|
||||
if (_default_if_pipe) {
|
||||
default_interface = trim_s(_default_if_pipe.read('all'));
|
||||
_default_if_pipe.close();
|
||||
}
|
||||
|
||||
function starts_with(s, prefix) {
|
||||
return s_sub(s || '', 0, s_len(prefix)) == prefix;
|
||||
}
|
||||
|
||||
function find_char(s, ch) {
|
||||
for (let i = 0; i < s_len(s); i++)
|
||||
if (s_sub(s, i, 1) == ch)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function find_last_char(s, ch) {
|
||||
for (let i = s_len(s) - 1; i >= 0; i--)
|
||||
if (s_sub(s, i, 1) == ch)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function split_uci_words(line) {
|
||||
let out = [], cur = '', quote = '', esc = false;
|
||||
for (let i = 0; i < s_len(line); i++) {
|
||||
let ch = s_sub(line, i, 1);
|
||||
if (esc) {
|
||||
cur += ch;
|
||||
esc = false;
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
esc = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch == quote)
|
||||
quote = '';
|
||||
else
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"' || ch == "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (is_space(ch)) {
|
||||
if (s_len(cur) > 0) {
|
||||
push(out, cur);
|
||||
cur = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (s_len(cur) > 0)
|
||||
push(out, cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function load_clashoo_uci() {
|
||||
let uci_path = ARGV[9] || '/etc/config/clashoo';
|
||||
let txt = readfile(uci_path) || '';
|
||||
let sections = [], cur = null;
|
||||
for (let line in split(txt, '\n')) {
|
||||
line = trim_s(line);
|
||||
if (!s_len(line) || starts_with(line, '#'))
|
||||
continue;
|
||||
let words = split_uci_words(line);
|
||||
if (!length(words))
|
||||
continue;
|
||||
if (words[0] == 'config') {
|
||||
cur = { type: words[1] || '', name: words[2] || '', options: {}, lists: {} };
|
||||
push(sections, cur);
|
||||
continue;
|
||||
}
|
||||
if (!cur || length(words) < 3)
|
||||
continue;
|
||||
if (words[0] == 'option')
|
||||
cur.options[words[1]] = words[2];
|
||||
else if (words[0] == 'list') {
|
||||
if (cur.lists[words[1]] == null)
|
||||
cur.lists[words[1]] = [];
|
||||
push(cur.lists[words[1]], words[2]);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
let clashoo_uci = load_clashoo_uci();
|
||||
|
||||
function uci_config_section() {
|
||||
for (let s in clashoo_uci)
|
||||
if (s.type == 'clashoo' && (s.name == 'config' || s.name == ''))
|
||||
return s;
|
||||
return {};
|
||||
}
|
||||
|
||||
let uci_cfg = uci_config_section();
|
||||
|
||||
function uci_opt(key, def) {
|
||||
if (uci_cfg.options && uci_cfg.options[key] != null)
|
||||
return uci_cfg.options[key];
|
||||
return def;
|
||||
}
|
||||
|
||||
function uci_list(key) {
|
||||
if (uci_cfg.lists && uci_cfg.lists[key] != null)
|
||||
return uci_cfg.lists[key];
|
||||
if (uci_cfg.options && uci_cfg.options[key] != null)
|
||||
return [ uci_cfg.options[key] ];
|
||||
return [];
|
||||
}
|
||||
|
||||
function uci_sections(type_name) {
|
||||
let out = [];
|
||||
for (let s in clashoo_uci)
|
||||
if (s.type == type_name)
|
||||
push(out, s);
|
||||
return out;
|
||||
}
|
||||
|
||||
function opt_bool(v, def) {
|
||||
if (v == null || v == '')
|
||||
return def;
|
||||
return v === true || v == '1' || v == 'true' || v == 'yes' || v == 'on';
|
||||
}
|
||||
|
||||
function normalize_dns_uri(address, protocol, port) {
|
||||
address = trim_s(address || '');
|
||||
protocol = trim_s(protocol || '');
|
||||
port = trim_s(port || '');
|
||||
if (!s_len(address))
|
||||
return '';
|
||||
if (find_char(address, ':') >= 0 && find_char(address, '/') >= 0)
|
||||
return address;
|
||||
if (protocol == 'none')
|
||||
protocol = '';
|
||||
if (protocol == 'dot')
|
||||
protocol = 'tls://';
|
||||
else if (protocol == 'doh')
|
||||
protocol = 'https://';
|
||||
else if (protocol == 'doq')
|
||||
protocol = 'quic://';
|
||||
if (s_len(protocol) && !starts_with(protocol, 'udp://') && !starts_with(protocol, 'tcp://') &&
|
||||
!starts_with(protocol, 'tls://') && !starts_with(protocol, 'https://') && !starts_with(protocol, 'quic://'))
|
||||
protocol += '://';
|
||||
return protocol + address + (s_len(port) ? ':' + port : '');
|
||||
}
|
||||
|
||||
function direct_outbound_tag() {
|
||||
for (let ob in (cfg.outbounds || []))
|
||||
if (ob && ob.type == 'direct' && s_len(ob.tag || ''))
|
||||
return ob.tag;
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
function dns_server_obj(uri, tag, fallback_type) {
|
||||
uri = normalize_dns_uri(uri || '', '', '');
|
||||
if (!s_len(uri))
|
||||
return null;
|
||||
let scheme = fallback_type || 'udp';
|
||||
let rest = uri;
|
||||
let p = -1;
|
||||
for (let i = 0; i < s_len(uri) - 2; i++) {
|
||||
if (s_sub(uri, i, 3) == '://') {
|
||||
p = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (p >= 0) {
|
||||
scheme = s_sub(uri, 0, p);
|
||||
rest = s_sub(uri, p + 3);
|
||||
}
|
||||
if (scheme == 'dot')
|
||||
scheme = 'tls';
|
||||
else if (scheme == 'doh')
|
||||
scheme = 'https';
|
||||
else if (scheme == 'doq')
|
||||
scheme = 'quic';
|
||||
|
||||
let path = '';
|
||||
let slash = find_char(rest, '/');
|
||||
if (slash >= 0) {
|
||||
path = s_sub(rest, slash);
|
||||
rest = s_sub(rest, 0, slash);
|
||||
}
|
||||
|
||||
let server = rest, server_port = null;
|
||||
let colon = find_last_char(rest, ':');
|
||||
if (colon > 0 && find_char(rest, ']') < 0) {
|
||||
server = s_sub(rest, 0, colon);
|
||||
let n = +(s_sub(rest, colon + 1));
|
||||
if (n === n)
|
||||
server_port = n;
|
||||
}
|
||||
|
||||
let obj = { type: scheme, tag: tag, server: server };
|
||||
if (server_port != null)
|
||||
obj.server_port = server_port;
|
||||
if (path && (scheme == 'https' || scheme == 'h3'))
|
||||
obj.path = path;
|
||||
if ((scheme == 'https' || scheme == 'tls' || scheme == 'quic') && tag != 'dns_resolver')
|
||||
obj.domain_resolver = 'dns_resolver';
|
||||
/* sing-box 1.12+ DNS servers detour to the proxy by default, so a server
|
||||
* resolving its own domain via dns_resolver loops -> "deadline exceeded".
|
||||
* Force direct/resolver servers to DIRECT; only dns_proxy may use the proxy. */
|
||||
if (tag == 'dns_resolver' || tag == 'dns_direct' || tag == 'dns_foreign')
|
||||
obj.detour = direct_outbound_tag();
|
||||
return obj;
|
||||
}
|
||||
|
||||
function dns_servers_by_role(role) {
|
||||
let out = [];
|
||||
for (let s in uci_sections('dnsservers')) {
|
||||
if (!opt_bool(s.options.enabled, true))
|
||||
continue;
|
||||
if ((s.options.ser_type || 'nameserver') != role)
|
||||
continue;
|
||||
let uri = normalize_dns_uri(s.options.ser_address || '', s.options.protocol || '', s.options.ser_port || '');
|
||||
if (s_len(uri))
|
||||
push(out, uri);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function first_or(arr, fallback) {
|
||||
return length(arr) ? arr[0] : fallback;
|
||||
}
|
||||
|
||||
function local_rule_set_path(tag) {
|
||||
if (!s_len(tag))
|
||||
return '';
|
||||
let path = '/usr/share/clashoo/ruleset/' + tag + '.srs';
|
||||
if (access(path, 'r'))
|
||||
return path;
|
||||
if (tag == 'geolocation-cn' || tag == 'cn') {
|
||||
path = '/usr/share/clashoo/ruleset/geosite-cn.srs';
|
||||
if (access(path, 'r'))
|
||||
return path;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function keep_remote_rule_set(rs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalize_rule_set_url(url) {
|
||||
url = url || '';
|
||||
url = replace(url, /^https:\/\/gh-proxy\.com\//, '');
|
||||
let m = match(url, /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+)$/);
|
||||
if (m)
|
||||
return 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4];
|
||||
m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/raw\/refs\/heads\/([^\/]+)\/(.+)$/);
|
||||
if (m)
|
||||
return 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4];
|
||||
return url;
|
||||
}
|
||||
|
||||
function has_route_rule_set(tag) {
|
||||
for (let rs in (cfg.route || {}).rule_set || [])
|
||||
if (rs && rs.tag == tag)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function add_remote_rule_set(tag, url) {
|
||||
cfg.route = cfg.route || {};
|
||||
cfg.route.rule_set = cfg.route.rule_set || [];
|
||||
if (has_route_rule_set(tag))
|
||||
return;
|
||||
push(cfg.route.rule_set, {
|
||||
tag: tag,
|
||||
type: 'remote',
|
||||
format: 'binary',
|
||||
url: url
|
||||
});
|
||||
}
|
||||
|
||||
function array_has_value(arr, val) {
|
||||
if (type(arr) != 'array')
|
||||
return false;
|
||||
for (let item in arr)
|
||||
if (item == val)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let dnsmasq_uid = null;
|
||||
let _dnsmasq_uid_pipe = popen("id -u dnsmasq 2>/dev/null");
|
||||
if (_dnsmasq_uid_pipe) {
|
||||
let value = trim_s(_dnsmasq_uid_pipe.read('all'));
|
||||
_dnsmasq_uid_pipe.close();
|
||||
if (match(value, /^[0-9]+$/))
|
||||
dnsmasq_uid = +value;
|
||||
}
|
||||
|
||||
function ensure_tun_dnsmasq_exclude(ib) {
|
||||
if (dnsmasq_uid == null)
|
||||
return;
|
||||
let excludes = ib.exclude_uid;
|
||||
if (type(excludes) != 'array')
|
||||
excludes = excludes != null ? [ excludes ] : [];
|
||||
if (!array_has_value(excludes, dnsmasq_uid))
|
||||
push(excludes, dnsmasq_uid);
|
||||
ib.exclude_uid = excludes;
|
||||
}
|
||||
|
||||
function ensure_tun_cn_exclude(ib) {
|
||||
let excludes = ib.route_exclude_address_set;
|
||||
if (type(excludes) != 'array')
|
||||
excludes = excludes ? [ excludes ] : [];
|
||||
if (!array_has_value(excludes, 'cn-ip'))
|
||||
push(excludes, 'cn-ip');
|
||||
ib.route_exclude_address_set = excludes;
|
||||
}
|
||||
|
||||
function prune_tun_cn_exclude_if_missing() {
|
||||
if (has_route_rule_set('cn-ip'))
|
||||
return;
|
||||
for (let ib in cfg.inbounds || []) {
|
||||
if (!ib || ib.type != 'tun')
|
||||
continue;
|
||||
let excludes = [];
|
||||
for (let item in ib.route_exclude_address_set || [])
|
||||
if (item != 'cn-ip')
|
||||
push(excludes, item);
|
||||
if (length(excludes))
|
||||
ib.route_exclude_address_set = excludes;
|
||||
else
|
||||
delete ib.route_exclude_address_set;
|
||||
}
|
||||
}
|
||||
|
||||
function matcher_rule(matcher, server_tag) {
|
||||
let r = { server: server_tag };
|
||||
if (starts_with(matcher, 'geosite:'))
|
||||
r.rule_set = s_sub(matcher, 8);
|
||||
else if (starts_with(matcher, 'rule_set:'))
|
||||
r.rule_set = s_sub(matcher, 9);
|
||||
else if (starts_with(matcher, 'domain:'))
|
||||
r.domain = s_sub(matcher, 7);
|
||||
else if (starts_with(matcher, 'domain-suffix:'))
|
||||
r.domain_suffix = s_sub(matcher, 14);
|
||||
else
|
||||
r.domain_suffix = matcher;
|
||||
return r;
|
||||
}
|
||||
|
||||
function apply_dns_from_uci() {
|
||||
cfg.dns = cfg.dns || {};
|
||||
/* strip mihomo-style DNS fields (enable/ipv6/listen/fake-ip-filter/nameserver...)
|
||||
* that leak in from YAML conversion; sing-box rejects them (Fatal). */
|
||||
let _mihomo_dns_fields = ['enable', 'ipv6', 'listen', 'fake-ip-filter', 'fake-ip-range',
|
||||
'enhanced-mode', 'nameserver', 'fallback', 'fallback-filter',
|
||||
'use-hosts', 'default-nameserver', 'proxy-server-nameserver',
|
||||
'direct-nameserver', 'nameserver-policy'];
|
||||
for (let _i = 0; _i < length(_mihomo_dns_fields); _i++)
|
||||
delete cfg.dns[_mihomo_dns_fields[_i]];
|
||||
/* strip mihomo-style experimental fields */
|
||||
let _mihomo_exp = ['sniff-tls-sni', 'sniff', 'sniffer'];
|
||||
for (let _me = 0; _me < length(_mihomo_exp); _me++)
|
||||
if (cfg.experimental && cfg.experimental[_mihomo_exp[_me]] != null)
|
||||
delete cfg.experimental[_mihomo_exp[_me]];
|
||||
/* strip non-sing-box root-level fields */
|
||||
let _mihomo_root = ['clash-for-android', 'cfw-bypass', 'sniffer', 'profile',
|
||||
'geodata-mode', 'geodata-loader', 'geox-url', 'geo-auto-update',
|
||||
'geo-update-interval', 'tun', 'ipv6', 'interface-name',
|
||||
'port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port', 'mode', 'allow-lan', 'log-level', 'external-controller', 'secret', 'bind-address', 'routing-mark', 'find-process-mode', 'tcp-concurrent', 'unified-delay',
|
||||
'keep-alive-interval', 'keep-alive-idle', 'disable-keep-alive'];
|
||||
/* strip subconverter note fields and non-standard keys */
|
||||
let _subconv_fields = ['_note', '_sub_url', 'hosts', 'script', 'enable', 'fake-ip-filter', 'fake-ip-range'];
|
||||
for (let _sf = 0; _sf < length(_subconv_fields); _sf++)
|
||||
if (cfg[_subconv_fields[_sf]] != null)
|
||||
delete cfg[_subconv_fields[_sf]];
|
||||
for (let _mr = 0; _mr < length(_mihomo_root); _mr++)
|
||||
if (cfg[_mihomo_root[_mr]] != null)
|
||||
delete cfg[_mihomo_root[_mr]];
|
||||
let bootstrap = uci_list('default_nameserver');
|
||||
if (!length(bootstrap))
|
||||
bootstrap = uci_list('defaul_nameserver');
|
||||
let resolver_uri = first_or(bootstrap, '223.5.5.5');
|
||||
let direct_uri = first_or(dns_servers_by_role('direct-nameserver'), first_or(dns_servers_by_role('proxy-server-nameserver'), 'https://doh.pub/dns-query'));
|
||||
let proxy_uri = first_or(dns_servers_by_role('nameserver'), first_or(dns_servers_by_role('fallback'), 'https://1.1.1.1/dns-query'));
|
||||
|
||||
let servers = [];
|
||||
push(servers, dns_server_obj(resolver_uri, 'dns_resolver', 'udp'));
|
||||
push(servers, dns_server_obj(direct_uri, 'dns_direct', 'udp'));
|
||||
push(servers, dns_server_obj(proxy_uri, 'dns_proxy', 'tls'));
|
||||
|
||||
let enhanced = uci_opt('enhanced_mode', 'fake-ip');
|
||||
if (enhanced == 'fake-ip') {
|
||||
let fake_range = uci_opt('fake_ip_range', '198.18.0.1/16');
|
||||
push(servers, {
|
||||
type: 'fakeip',
|
||||
tag: 'dns_fakeip',
|
||||
inet4_range: fake_range || '198.18.0.1/16',
|
||||
inet6_range: 'fc00::/18'
|
||||
});
|
||||
}
|
||||
|
||||
let rules = [];
|
||||
push(servers, { type: 'udp', tag: 'dns_foreign', server: '1.1.1.1' });
|
||||
if (enhanced == 'fake-ip') {
|
||||
push(rules, {
|
||||
rule_set: 'geolocation-!cn',
|
||||
server: 'dns_fakeip'
|
||||
});
|
||||
}
|
||||
|
||||
let clean_servers = [];
|
||||
for (let s in servers)
|
||||
if (s)
|
||||
push(clean_servers, s);
|
||||
cfg.dns.servers = clean_servers;
|
||||
cfg.dns.rules = rules;
|
||||
|
||||
/* Keep bootstrap direct; proxy unmatched queries in strict mode. */
|
||||
if (opt_bool(uci_opt('dns_leak_protect', '0'), false)) {
|
||||
cfg.dns.final = 'dns_proxy';
|
||||
if (!opt_bool(uci_opt('ipv6_proxy', '0'), false))
|
||||
unshift(cfg.dns.rules, { query_type: ['AAAA'], action: 'reject', method: 'drop' });
|
||||
} else
|
||||
cfg.dns.final = 'dns_direct';
|
||||
|
||||
let ecs = trim_s(uci_opt('dns_ecs', ''));
|
||||
if (s_len(ecs))
|
||||
cfg.dns.client_subnet = ecs;
|
||||
else
|
||||
delete cfg.dns.client_subnet;
|
||||
|
||||
if (opt_bool(uci_opt('singbox_independent_cache', '0'), false))
|
||||
cfg.dns.independent_cache = true;
|
||||
else
|
||||
delete cfg.dns.independent_cache;
|
||||
|
||||
/* sing-box 1.14 requires this (else Fatal); dns_resolver is DIRECT so first-start node lookups use direct DNS */
|
||||
cfg.route.default_domain_resolver = 'dns_resolver';
|
||||
}
|
||||
|
||||
let inbounds = cfg.inbounds || [];
|
||||
let normalized = [];
|
||||
let has_redirect = false;
|
||||
let has_tproxy = false;
|
||||
let has_mixed = false;
|
||||
let has_tun = false;
|
||||
let has_dns_in = false;
|
||||
let wants_tun = has_tun_device && (uci_opt('tcp_mode', '') == 'tun' || uci_opt('udp_mode', '') == 'tun');
|
||||
let tun_stack = uci_opt('stack', 'mixed') || 'mixed';
|
||||
|
||||
if (wants_tun) {
|
||||
add_remote_rule_set('cn-ip',
|
||||
'https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/cn.srs');
|
||||
}
|
||||
|
||||
for (let ib in inbounds) {
|
||||
if (!ib)
|
||||
continue;
|
||||
|
||||
if (ib.type == 'tun' || ib.tag == 'tun-in') {
|
||||
/* Keep tun inbound only when the selected proxy mode actually uses TUN. */
|
||||
if (wants_tun && !has_tun) {
|
||||
ib.type = 'tun';
|
||||
ib.tag = ib.tag || 'tun-in';
|
||||
if (!ib.address)
|
||||
ib.address = [ '172.19.0.1/30', 'fdfe:dcba:9876::1/126' ];
|
||||
ib.auto_route = true;
|
||||
ib.auto_redirect = true;
|
||||
ib.auto_redirect_input_mark = '0x2023';
|
||||
ib.auto_redirect_output_mark = '0x2024';
|
||||
ib.strict_route = true;
|
||||
ib.stack = tun_stack;
|
||||
ensure_tun_dnsmasq_exclude(ib);
|
||||
ensure_tun_cn_exclude(ib);
|
||||
push(normalized, ib);
|
||||
has_tun = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ib.tag == 'redirect-in' || ib.type == 'redirect') {
|
||||
if (has_redirect)
|
||||
continue;
|
||||
ib.type = 'redirect';
|
||||
ib.tag = 'redirect-in';
|
||||
ib.listen = '0.0.0.0';
|
||||
ib.listen_port = redir_port;
|
||||
has_redirect = true;
|
||||
push(normalized, ib);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ib.tag == 'tproxy-in' || ib.type == 'tproxy') {
|
||||
if (has_tproxy)
|
||||
continue;
|
||||
ib.type = 'tproxy';
|
||||
ib.tag = 'tproxy-in';
|
||||
ib.listen = '0.0.0.0';
|
||||
ib.listen_port = tproxy_port;
|
||||
ib.network = 'udp';
|
||||
has_tproxy = true;
|
||||
push(normalized, ib);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ib.tag == 'mixed-in' || ib.type == 'mixed') {
|
||||
if (has_mixed)
|
||||
continue;
|
||||
ib.type = 'mixed';
|
||||
ib.tag = 'mixed-in';
|
||||
ib.listen = '0.0.0.0';
|
||||
ib.listen_port = mixed_port;
|
||||
has_mixed = true;
|
||||
push(normalized, ib);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ib.tag == 'dns-in') {
|
||||
if (has_dns_in)
|
||||
continue;
|
||||
ib.type = 'direct';
|
||||
ib.tag = 'dns-in';
|
||||
ib.listen = '0.0.0.0';
|
||||
ib.listen_port = dns_port;
|
||||
has_dns_in = true;
|
||||
push(normalized, ib);
|
||||
continue;
|
||||
}
|
||||
|
||||
push(normalized, ib);
|
||||
}
|
||||
|
||||
if (!has_redirect) {
|
||||
push(normalized, {
|
||||
type: 'redirect',
|
||||
tag: 'redirect-in',
|
||||
listen: '0.0.0.0',
|
||||
listen_port: redir_port
|
||||
});
|
||||
}
|
||||
|
||||
if (!has_mixed) {
|
||||
push(normalized, {
|
||||
type: 'mixed',
|
||||
tag: 'mixed-in',
|
||||
listen: '0.0.0.0',
|
||||
listen_port: mixed_port
|
||||
});
|
||||
}
|
||||
|
||||
if (wants_tun && !has_tun) {
|
||||
let tun_ib = {
|
||||
type: 'tun',
|
||||
tag: 'tun-in',
|
||||
address: [ '172.19.0.1/30', 'fdfe:dcba:9876::1/126' ],
|
||||
auto_route: true,
|
||||
auto_redirect: true,
|
||||
auto_redirect_input_mark: '0x2023',
|
||||
auto_redirect_output_mark: '0x2024',
|
||||
strict_route: true,
|
||||
stack: tun_stack
|
||||
};
|
||||
ensure_tun_dnsmasq_exclude(tun_ib);
|
||||
ensure_tun_cn_exclude(tun_ib);
|
||||
push(normalized, tun_ib);
|
||||
}
|
||||
|
||||
if (!has_tproxy) {
|
||||
push(normalized, {
|
||||
type: 'tproxy',
|
||||
tag: 'tproxy-in',
|
||||
listen: '0.0.0.0',
|
||||
listen_port: tproxy_port,
|
||||
network: 'udp'
|
||||
});
|
||||
}
|
||||
|
||||
if (!has_dns_in) {
|
||||
push(normalized, {
|
||||
type: 'direct',
|
||||
tag: 'dns-in',
|
||||
listen: '0.0.0.0',
|
||||
listen_port: dns_port
|
||||
});
|
||||
}
|
||||
|
||||
cfg.inbounds = normalized;
|
||||
|
||||
for (let ob in (cfg.outbounds || [])) {
|
||||
if (!ob || type(ob) != 'object')
|
||||
continue;
|
||||
|
||||
let t = ob.type || '';
|
||||
if (t == 'selector' || t == 'urltest' || t == 'fallback' || t == 'load_balance' || t == 'dns' || t == 'block')
|
||||
continue;
|
||||
|
||||
/* Clash SS obfs is plugin=obfs + plugin-opts object; sing-box wants
|
||||
* plugin=obfs-local + "obfs=http;obfs-host=..." string. */
|
||||
if (ob.plugin == 'obfs')
|
||||
ob.plugin = 'obfs-local';
|
||||
if (ob.plugin_opts != null && type(ob.plugin_opts) == 'object') {
|
||||
let _parts = [];
|
||||
let _map = { mode: 'obfs', host: 'obfs-host', uri: 'obfs-uri' };
|
||||
for (let _k in ob.plugin_opts) {
|
||||
let _v = ob.plugin_opts[_k];
|
||||
if (type(_v) == 'object' || type(_v) == 'array') continue;
|
||||
push(_parts, (_map[_k] || _k) + '=' + _v);
|
||||
}
|
||||
ob.plugin_opts = join(';', _parts);
|
||||
}
|
||||
if (ob.plugin != null && ob.plugin != 'obfs-local' && ob.plugin != 'v2ray-plugin' && ob.plugin != 'shadow-tls') {
|
||||
delete ob.plugin;
|
||||
delete ob.plugin_opts;
|
||||
}
|
||||
|
||||
/* Force ipv4_only on DIRECT outbound: most devices are IPv4-only egress,
|
||||
* a CN domain resolving AAAA stalls on "network is unreachable". */
|
||||
if (ob.type == 'direct' && ob.tag == 'DIRECT')
|
||||
ob.domain_resolver = { server: 'dns_resolver', strategy: 'ipv4_only' };
|
||||
|
||||
if (wants_tun) {
|
||||
delete ob.routing_mark;
|
||||
if (ob.type == 'direct' && ob.tag == 'DIRECT' && default_interface)
|
||||
ob.bind_interface = ob.bind_interface || default_interface;
|
||||
} else if (ob.routing_mark == null)
|
||||
ob.routing_mark = routing_mark;
|
||||
}
|
||||
|
||||
/* Drop airline pseudo-nodes (Traffic:/Expire:/quota/官网/QQ/etc.) from selector/urltest lists.
|
||||
* They're real SS/Vmess (kept so the UI reads traffic/expiry) but don't forward; in a
|
||||
* selector/urltest they win and swallow foreign traffic ("CN ok, foreign out"). */
|
||||
let _is_pseudo_tag = function(t) {
|
||||
if (!t) return false;
|
||||
return match(t, /^Traffic[::]/) || match(t, /^Expire[::]/) ||
|
||||
match(t, /剩余流量|剩余[::]/) || match(t, /距离下次重置/) ||
|
||||
match(t, /到期(时间|日期)?[::]/) ||
|
||||
match(t, /官网[::]|网站[::]|套餐[::]?|客服[::]/) ||
|
||||
match(t, /QQ[群]?[::]/) || match(t, /Telegram|TG群|官方群/) ||
|
||||
match(t, /续费|订阅地址|流量重置/);
|
||||
};
|
||||
for (let ob in (cfg.outbounds || [])) {
|
||||
if (!ob || type(ob) != 'object') continue;
|
||||
let t = ob.type || '';
|
||||
if (t != 'selector' && t != 'urltest' && t != 'fallback' && t != 'load_balance') continue;
|
||||
if (type(ob.outbounds) != 'array') continue;
|
||||
let cleaned = [];
|
||||
for (let tag in ob.outbounds) if (!_is_pseudo_tag(tag)) push(cleaned, tag);
|
||||
ob.outbounds = cleaned;
|
||||
}
|
||||
|
||||
cfg.route = cfg.route || {};
|
||||
cfg.route.rules = cfg.route.rules || [];
|
||||
let has_dns_hijack = false;
|
||||
for (let rule in cfg.route.rules) {
|
||||
if (!rule || type(rule) != 'object')
|
||||
continue;
|
||||
if (rule.inbound == 'dns-in' && rule.action == 'hijack-dns') {
|
||||
has_dns_hijack = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has_dns_hijack) {
|
||||
unshift(cfg.route.rules, {
|
||||
inbound: 'dns-in',
|
||||
action: 'hijack-dns'
|
||||
});
|
||||
}
|
||||
cfg.route.auto_detect_interface = true;
|
||||
apply_dns_from_uci();
|
||||
|
||||
/* DNS leak protection: reject DoT/DoQ (853) to force DNS through the core; after hijack-dns so the dns inbound survives */
|
||||
if (opt_bool(uci_opt('dns_leak_protect', '0'), false)) {
|
||||
let _has_853 = false;
|
||||
for (let _r in cfg.route.rules) {
|
||||
if (_r && type(_r) == 'object' && _r.action == 'reject' && _r.port) {
|
||||
if (type(_r.port) == 'array') {
|
||||
for (let _p in _r.port) if (_p == 853) { _has_853 = true; break; }
|
||||
} else if (_r.port == 853) {
|
||||
_has_853 = true;
|
||||
}
|
||||
}
|
||||
if (_has_853) break;
|
||||
}
|
||||
if (!_has_853) {
|
||||
let _new_rules = [];
|
||||
let _inserted = false;
|
||||
for (let _ri = 0; _ri < length(cfg.route.rules); _ri++) {
|
||||
push(_new_rules, cfg.route.rules[_ri]);
|
||||
if (!_inserted && cfg.route.rules[_ri] && cfg.route.rules[_ri].action == 'hijack-dns') {
|
||||
push(_new_rules, { port: [853], action: 'reject' });
|
||||
_inserted = true;
|
||||
}
|
||||
}
|
||||
if (!_inserted) unshift(_new_rules, { port: [853], action: 'reject' });
|
||||
cfg.route.rules = _new_rules;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Custom routing rules (UCI: config addtype) -> route.rules, mirrors mihomo iprules.sh =====
|
||||
* User "domain/IP -> direct/proxy" rules from LuCI, inserted after the DNS funnel
|
||||
* (hijack-dns / 853-reject) and before other rules, so overrides win without bypassing it. */
|
||||
let _proxy_outbound_tag = function() {
|
||||
for (let ob in (cfg.outbounds || []))
|
||||
if (ob && ob.tag == '🚀 节点选择' && (ob.type == 'selector' || ob.type == 'urltest'))
|
||||
return ob.tag;
|
||||
for (let ob in (cfg.outbounds || []))
|
||||
if (ob && ob.type == 'selector' && s_len(ob.tag || '') && !_is_pseudo_tag(ob.tag))
|
||||
return ob.tag;
|
||||
return direct_outbound_tag();
|
||||
};
|
||||
let _load_addtype_rules = function() {
|
||||
let out = [];
|
||||
let dtag = direct_outbound_tag();
|
||||
let ptag = null;
|
||||
for (let s in uci_sections('addtype')) {
|
||||
let o = s.options || {};
|
||||
let addr = trim_s(o.ipaaddr || '');
|
||||
let rtype = trim_s(o.type || '');
|
||||
let pg = trim_s(o.pgroup || '');
|
||||
if (!s_len(addr) || !s_len(rtype) || !s_len(pg))
|
||||
continue;
|
||||
let r = {};
|
||||
if (rtype == 'DOMAIN')
|
||||
r.domain = addr;
|
||||
else if (rtype == 'DOMAIN-SUFFIX')
|
||||
r.domain_suffix = addr;
|
||||
else if (rtype == 'DOMAIN-KEYWORD')
|
||||
r.domain_keyword = addr;
|
||||
else if (rtype == 'IP-CIDR' || rtype == 'IP-CIDR6')
|
||||
r.ip_cidr = addr;
|
||||
else
|
||||
continue;
|
||||
if (pg == 'DIRECT')
|
||||
r.outbound = dtag;
|
||||
else {
|
||||
if (ptag == null)
|
||||
ptag = _proxy_outbound_tag();
|
||||
r.outbound = ptag;
|
||||
}
|
||||
push(out, r);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
let _addtype_rules = _load_addtype_rules();
|
||||
if (length(_addtype_rules)) {
|
||||
let _nr = [];
|
||||
let _ins = false;
|
||||
for (let _i = 0; _i < length(cfg.route.rules); _i++) {
|
||||
let _ru = cfg.route.rules[_i];
|
||||
let _dns_front = _ru && type(_ru) == 'object' &&
|
||||
(_ru.action == 'hijack-dns' || (_ru.action == 'reject' && _ru.port));
|
||||
if (!_ins && !_dns_front) {
|
||||
for (let _cr in _addtype_rules)
|
||||
push(_nr, _cr);
|
||||
_ins = true;
|
||||
}
|
||||
push(_nr, _ru);
|
||||
}
|
||||
if (!_ins)
|
||||
for (let _cr in _addtype_rules)
|
||||
push(_nr, _cr);
|
||||
cfg.route.rules = _nr;
|
||||
}
|
||||
|
||||
if (uci_opt('enhanced_mode', 'fake-ip') == 'fake-ip') {
|
||||
add_remote_rule_set('geolocation-!cn',
|
||||
'https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-!cn.srs');
|
||||
}
|
||||
|
||||
/* Local .srs -> local; remaining remote rule_sets must keep/get a download_detour.
|
||||
* CN first-start deadlock: no srs -> fetch rules -> via auto-select -> urltest dials
|
||||
* the proxy -> resolves its domain -> pulled into an unloaded rule_set by DNS rules -> deadline.
|
||||
* The srs URLs use a CN-reachable mirror, so download_detour
|
||||
* must be DIRECT (real direct-outbound tag if any, else 'DIRECT'). */
|
||||
let _pick_dl_detour = function() {
|
||||
if (cfg.outbounds) {
|
||||
for (let ob in cfg.outbounds) {
|
||||
if (ob && ob.type == 'direct' && ob.tag) return ob.tag;
|
||||
}
|
||||
}
|
||||
return 'DIRECT';
|
||||
};
|
||||
let _dl_detour = _pick_dl_detour();
|
||||
for (let rs in (cfg.route || {}).rule_set || []) {
|
||||
if (!rs) continue;
|
||||
if (rs.type != 'remote') { delete rs.download_detour; continue; }
|
||||
let path = local_rule_set_path(rs.tag || '');
|
||||
if (rs.tag && access(path, 'r')) {
|
||||
delete rs.url; delete rs.download_detour; rs.type = 'local'; rs.path = path;
|
||||
continue;
|
||||
}
|
||||
if (keep_remote_rule_set(rs) && rs.url) {
|
||||
rs.url = normalize_rule_set_url(rs.url);
|
||||
rs.download_detour = _dl_detour;
|
||||
continue;
|
||||
}
|
||||
/* No local cache -> skip this remote rule_set so a failed download doesn't block start.
|
||||
* Rule-sets are fetched to /usr/share/clashoo/ruleset/ once proxied. */
|
||||
continue;
|
||||
|
||||
}
|
||||
/* filter out remote rule_sets with no local cache */
|
||||
let _clean_rs = [];
|
||||
for (let _rsi = 0; _rsi < length(cfg.route.rule_set); _rsi++) {
|
||||
let _rs = cfg.route.rule_set[_rsi];
|
||||
if (!_rs) continue;
|
||||
if (_rs.type == 'remote' && _rs.url != null) {
|
||||
let _local_path = local_rule_set_path(_rs.tag || '');
|
||||
if (!_rs.tag || !access(_local_path, 'r'))
|
||||
if (keep_remote_rule_set(_rs) && _rs.url) {
|
||||
_rs.url = normalize_rule_set_url(_rs.url);
|
||||
_rs.download_detour = _dl_detour;
|
||||
push(_clean_rs, _rs);
|
||||
continue;
|
||||
} else {
|
||||
continue; /* no local cache -> drop from final list */
|
||||
}
|
||||
/* has local cache -> convert to local */
|
||||
delete _rs.url; delete _rs.download_detour; _rs.type = 'local'; _rs.path = _local_path;
|
||||
}
|
||||
push(_clean_rs, _rs);
|
||||
}
|
||||
cfg.route.rule_set = _clean_rs;
|
||||
prune_tun_cn_exclude_if_missing();
|
||||
|
||||
/* drop DNS/route rules referencing a removed rule_set, else sing-box FATAL: rule-set not found */
|
||||
let _existing_tags = {};
|
||||
for (let _rs in cfg.route.rule_set || []) if (_rs && _rs.tag) _existing_tags[_rs.tag] = true;
|
||||
let _rule_has_ref = function(_r) {
|
||||
if (!_r || type(_r) != 'object') return false;
|
||||
if (_r.rule_set) {
|
||||
if (type(_r.rule_set) == 'array') { for (let _t in _r.rule_set) if (!_existing_tags[_t]) return false; }
|
||||
else if (!_existing_tags[_r.rule_set]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
/* clean dns.rules */
|
||||
if (cfg.dns && type(cfg.dns.rules) == 'array') {
|
||||
let _clean_dns_rules = [];
|
||||
for (let _dr in cfg.dns.rules) if (_rule_has_ref(_dr)) push(_clean_dns_rules, _dr);
|
||||
cfg.dns.rules = _clean_dns_rules;
|
||||
}
|
||||
/* clean route.rules */
|
||||
if (cfg.route && type(cfg.route.rules) == 'array') {
|
||||
let _clean_rt_rules = [];
|
||||
for (let _rr in cfg.route.rules) if (_rule_has_ref(_rr)) push(_clean_rt_rules, _rr);
|
||||
cfg.route.rules = _clean_rt_rules;
|
||||
}
|
||||
cfg.experimental = cfg.experimental || {};
|
||||
cfg.experimental.clash_api = cfg.experimental.clash_api || {};
|
||||
cfg.experimental.clash_api.external_controller = '0.0.0.0:' + dash_port;
|
||||
cfg.experimental.clash_api.external_ui = '/etc/clashoo/dashboard';
|
||||
cfg.experimental.clash_api.secret = dash_secret;
|
||||
|
||||
cfg.experimental.cache_file = {
|
||||
enabled: true,
|
||||
store_fakeip: true
|
||||
};
|
||||
|
||||
/* OpenWrt already owns system time sync. Keep sing-box NTP disabled by
|
||||
* default to avoid noisy IPv6 UDP/123 failures on IPv4-only routers. */
|
||||
cfg.ntp = cfg.ntp || {};
|
||||
cfg.ntp.enabled = false;
|
||||
|
||||
cfg.log = cfg.log || {};
|
||||
cfg.log.output = '/var/log/clashoo/core.log';
|
||||
if (!cfg.log.level)
|
||||
cfg.log.level = 'info';
|
||||
|
||||
if (writefile(path, sprintf('%J', cfg)) === null) {
|
||||
print("write failed\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
print("normalized\n");
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"log": {
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
"dns": {
|
||||
"servers": [
|
||||
{ "type": "tcp", "tag": "dns_proxy", "server": "1.1.1.1", "domain_resolver": "dns_resolver" },
|
||||
{ "type": "https", "tag": "dns_direct", "server": "dns.alidns.com", "domain_resolver": "dns_resolver" },
|
||||
{ "type": "udp", "tag": "dns_resolver", "server": "223.5.5.5" },
|
||||
{ "type": "fakeip", "tag": "dns_fakeip", "inet4_range": "198.18.0.0/15", "inet6_range": "fc00::/18" }
|
||||
],
|
||||
"rules": [
|
||||
{ "rule_set": "geolocation-!cn", "query_type": ["A", "AAAA", "CNAME"], "server": "dns_fakeip" },
|
||||
{ "query_type": ["A", "AAAA", "CNAME"], "invert": true, "action": "reject" }
|
||||
],
|
||||
"final": "dns_direct"
|
||||
},
|
||||
"ntp": {
|
||||
"enabled": false,
|
||||
"server": "time.apple.com",
|
||||
"server_port": 123,
|
||||
"interval": "30m"
|
||||
},
|
||||
"inbounds": [
|
||||
{ "type": "mixed", "tag": "mixed-in", "listen": "0.0.0.0", "listen_port": 2080 },
|
||||
{ "type": "tun", "tag": "tun-in", "address": ["172.19.0.1/30"], "auto_route": true, "auto_redirect": true, "strict_route": true, "stack": "mixed", "route_exclude_address_set": ["cn-ip"] }
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "selector", "tag": "🚀 节点选择",
|
||||
"outbounds": ["🌍 全部节点","♻️ 自动选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","DIRECT"],
|
||||
"default": "🌍 全部节点"
|
||||
},
|
||||
{ "type": "selector", "tag": "▶️ YouTube", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🔍 Google", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🔮 OpenAI", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🧠 Claude", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🤖 Gemini", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🐦 Twitter", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "📦 GitHub", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "Ⓜ️ Microsoft", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🎵 TikTok", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "📺 Netflix", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🎬 流媒体", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "💳 PayPal", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "📲 电报消息", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🍏 苹果服务", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
|
||||
{ "type": "selector", "tag": "🎯 全球直连", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
|
||||
{ "type": "selector", "tag": "🏠 私有网络", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
|
||||
{ "type": "selector", "tag": "🌐 非中国", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
{ "type": "selector", "tag": "🐟 漏网之鱼", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
|
||||
|
||||
{ "type": "selector", "tag": "🇭🇰 香港节点", "outbounds": ["__NODES_HK__"] },
|
||||
{ "type": "selector", "tag": "🇯🇵 日本节点", "outbounds": ["__NODES_JP__"] },
|
||||
{ "type": "selector", "tag": "🇺🇲 美国节点", "outbounds": ["__NODES_US__"] },
|
||||
{ "type": "selector", "tag": "🇸🇬 新加坡节点","outbounds": ["__NODES_SG__"] },
|
||||
{ "type": "selector", "tag": "🇺🇳 集合节点", "outbounds": ["__NODES_OTHER__"] },
|
||||
{ "type": "selector", "tag": "🌍 全部节点", "outbounds": ["__NODES__"] },
|
||||
|
||||
{ "type": "urltest", "tag": "♻️ 香港自动", "outbounds": ["__NODES_HK__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
{ "type": "urltest", "tag": "♻️ 日本自动", "outbounds": ["__NODES_JP__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
{ "type": "urltest", "tag": "♻️ 美国自动", "outbounds": ["__NODES_US__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
{ "type": "urltest", "tag": "♻️ 新加坡自动", "outbounds": ["__NODES_SG__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
{ "type": "urltest", "tag": "♻️ 集合自动", "outbounds": ["__NODES_OTHER__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
{ "type": "urltest", "tag": "♻️ 自动选择", "outbounds": ["__NODES__"], "url": "https://www.gstatic.com/generate_204", "interval": "10m", "tolerance": 50, "interrupt_exist_connections": true },
|
||||
|
||||
{ "type": "direct", "tag": "DIRECT" }
|
||||
],
|
||||
"route": {
|
||||
"default_domain_resolver": "dns_resolver",
|
||||
"rule_set": [
|
||||
{ "tag": "openai", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/openai.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "anthropic", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/anthropic.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "google-gemini", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/google-gemini.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "youtube", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/youtube.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "google", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/google.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "twitter", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/twitter.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "github", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/github.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "gitlab", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/gitlab.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "microsoft", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/microsoft.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "tiktok", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/tiktok.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "netflix", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/netflix.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "hulu", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/hulu.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "disney", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/disney.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "hbo", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/hbo.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "amazon", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/amazon.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "bahamut", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/bahamut.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "paypal", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/paypal.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "apple", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/apple.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "geolocation-cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-cn.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/cn.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "geolocation-!cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-!cn.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "google-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/google.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "private-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/private.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "cn-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/cn.srs", "download_detour": "DIRECT" },
|
||||
{ "tag": "telegram-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/telegram.srs", "download_detour": "DIRECT" }
|
||||
],
|
||||
"rules": [
|
||||
{ "action": "sniff" },
|
||||
{ "clash_mode": "direct", "outbound": "DIRECT" },
|
||||
{ "clash_mode": "global", "outbound": "♻️ 自动选择" },
|
||||
{ "protocol": "dns", "action": "hijack-dns" },
|
||||
{ "rule_set": "openai", "outbound": "🔮 OpenAI" },
|
||||
{ "rule_set": "anthropic", "outbound": "🧠 Claude" },
|
||||
{ "rule_set": "google-gemini", "outbound": "🤖 Gemini" },
|
||||
{ "rule_set": "youtube", "outbound": "▶️ YouTube" },
|
||||
{ "rule_set": "google", "outbound": "🔍 Google" },
|
||||
{ "rule_set": "twitter", "outbound": "🐦 Twitter" },
|
||||
{ "rule_set": "tiktok", "outbound": "🎵 TikTok" },
|
||||
{ "rule_set": "netflix", "outbound": "📺 Netflix" },
|
||||
{ "rule_set": ["hulu", "disney", "hbo", "amazon", "bahamut"], "outbound": "🎬 流媒体" },
|
||||
{ "rule_set": "paypal", "outbound": "💳 PayPal" },
|
||||
{ "rule_set": "microsoft", "outbound": "Ⓜ️ Microsoft" },
|
||||
{ "rule_set": "apple", "outbound": "🍏 苹果服务" },
|
||||
{ "rule_set": ["github", "gitlab"], "outbound": "📦 GitHub" },
|
||||
{ "rule_set": "telegram-ip", "outbound": "📲 电报消息" },
|
||||
{ "rule_set": "private-ip", "outbound": "🏠 私有网络" },
|
||||
{ "rule_set": ["geolocation-cn", "cn"], "outbound": "🎯 全球直连" },
|
||||
{ "rule_set": "cn-ip", "outbound": "🎯 全球直连" },
|
||||
{ "domain_suffix": ["cn"], "outbound": "🎯 全球直连" },
|
||||
{ "rule_set": "geolocation-!cn", "outbound": "🌐 非中国" },
|
||||
{ "rule_set": "google-ip", "outbound": "🔍 Google" }
|
||||
],
|
||||
"auto_detect_interface": true,
|
||||
"final": "🐟 漏网之鱼"
|
||||
},
|
||||
"experimental": {
|
||||
"cache_file": { "enabled": true, "store_fakeip": true },
|
||||
"clash_api": {
|
||||
"external_controller": "0.0.0.0:9090",
|
||||
"external_ui": "dashboard",
|
||||
"secret": "clashoo",
|
||||
"external_ui_download_url": "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip",
|
||||
"external_ui_download_detour": "DIRECT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/ucode
|
||||
/* yaml2singbox.uc — convert mihomo/clash YAML proxies to sing-box outbounds
|
||||
* Usage: ucode yaml2singbox.uc <yaml-in> [template-json] [json-out]
|
||||
* Supports: ss, vmess, vless, trojan, hysteria2, tuic. Others are skipped.
|
||||
* "__NODES__" in the template selector outbounds is expanded to all node tags. */
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile, popen } from 'fs';
|
||||
|
||||
const TPL_DEFAULT = '/usr/share/clashoo/lib/templates/default.json';
|
||||
const SUPPORTED = {
|
||||
'ss': 'shadowsocks',
|
||||
'shadowsocks':'shadowsocks',
|
||||
'vmess': 'vmess',
|
||||
'vless': 'vless',
|
||||
'trojan': 'trojan',
|
||||
'hysteria2': 'hysteria2',
|
||||
'hy2': 'hysteria2',
|
||||
'tuic': 'tuic'
|
||||
};
|
||||
|
||||
function logerr(msg) {
|
||||
warn(sprintf("[yaml2singbox] %s\n", msg));
|
||||
}
|
||||
|
||||
function die(msg, code) {
|
||||
logerr(msg);
|
||||
exit(code || 1);
|
||||
}
|
||||
|
||||
/* ---------- YAML read (via yq) ---------- */
|
||||
function quote_sh(s) {
|
||||
/* single-quote for shell; escape embedded quotes */
|
||||
return "'" + replace(s, "'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
function read_yaml_as_json(path) {
|
||||
const cmd = sprintf("yq -o=json eval . %s 2>/dev/null", quote_sh(path));
|
||||
const h = popen(cmd, "r");
|
||||
if (!h)
|
||||
die(sprintf("popen failed: %s", cmd));
|
||||
let buf = "", chunk;
|
||||
while ((chunk = h.read(65536)))
|
||||
buf += chunk;
|
||||
h.close();
|
||||
if (!length(buf))
|
||||
die(sprintf("yq returned empty for %s (yaml loadable?)", path));
|
||||
const data = json(buf);
|
||||
if (data === null)
|
||||
die("json parse of yq output failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function pick(obj, ...keys) {
|
||||
for (let k in keys)
|
||||
if (obj[k] != null)
|
||||
return obj[k];
|
||||
return null;
|
||||
}
|
||||
|
||||
function tobool(v) {
|
||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toint(v) {
|
||||
if (v == null) return null;
|
||||
let n = +v;
|
||||
return (n === n) ? n : null; /* NaN check */
|
||||
}
|
||||
|
||||
function strip_null(obj) {
|
||||
if (type(obj) !== 'object') return obj;
|
||||
const out = {};
|
||||
for (let k in obj) {
|
||||
const v = obj[k];
|
||||
if (v == null) continue;
|
||||
if (type(v) === 'object') {
|
||||
const sv = strip_null(v);
|
||||
if (length(sv) > 0) out[k] = sv;
|
||||
} else if (type(v) === 'array') {
|
||||
const arr = map(v, (x) => (type(x) === 'object') ? strip_null(x) : x);
|
||||
if (length(arr) > 0) out[k] = arr;
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- TLS block assembly ---------- */
|
||||
function build_tls(p) {
|
||||
const enabled = tobool(p.tls) || (p.sni && length(p.sni) > 0) || (p.servername && length(p.servername) > 0);
|
||||
if (!enabled) return null;
|
||||
const tls = {
|
||||
enabled: true,
|
||||
server_name: pick(p, 'sni', 'servername'),
|
||||
insecure: tobool(p['skip-cert-verify']) === true ? true : null,
|
||||
alpn: p.alpn
|
||||
};
|
||||
const reality = p['reality-opts'];
|
||||
if (reality && type(reality) === 'object') {
|
||||
tls.reality = {
|
||||
enabled: true,
|
||||
public_key: reality['public-key'],
|
||||
short_id: reality['short-id']
|
||||
};
|
||||
}
|
||||
const client_fp = p['client-fingerprint'];
|
||||
if (client_fp)
|
||||
tls.utls = { enabled: true, fingerprint: client_fp };
|
||||
return strip_null(tls);
|
||||
}
|
||||
|
||||
/* ---------- transport (ws / grpc / http) ---------- */
|
||||
function build_transport(p) {
|
||||
const net = p.network;
|
||||
if (!net || net === 'tcp') return null;
|
||||
if (net === 'ws') {
|
||||
const opts = p['ws-opts'] || {};
|
||||
return strip_null({
|
||||
type: 'ws',
|
||||
path: opts.path,
|
||||
headers: opts.headers,
|
||||
max_early_data: toint(opts['max-early-data']),
|
||||
early_data_header_name: opts['early-data-header-name']
|
||||
});
|
||||
}
|
||||
if (net === 'grpc') {
|
||||
const opts = p['grpc-opts'] || {};
|
||||
return { type: 'grpc', service_name: opts['grpc-service-name'] };
|
||||
}
|
||||
if (net === 'http' || net === 'h2') {
|
||||
const opts = p['h2-opts'] || p['http-opts'] || {};
|
||||
return strip_null({
|
||||
type: 'http',
|
||||
host: opts.host || opts.Host,
|
||||
path: opts.path
|
||||
});
|
||||
}
|
||||
if (net === 'httpupgrade') {
|
||||
const opts = p['http-upgrade-opts'] || {};
|
||||
return strip_null({ type: 'httpupgrade', path: opts.path, host: opts.host });
|
||||
}
|
||||
logerr(sprintf("unknown transport network '%s' for node '%s', ignored", net, p.name));
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- per-protocol conversion ---------- */
|
||||
function convert_ss(p) {
|
||||
const plugin = p.plugin, popts = p['plugin-opts'] || {};
|
||||
let plugin_opts = null;
|
||||
if (plugin && type(popts) === 'object') {
|
||||
/* Field mapping: Clash simple-obfs -> sing-box obfs-local
|
||||
* Clash: mode=http;host=xxx → sing-box: obfs=http;obfs-host=xxx */
|
||||
const FIELD_MAP = {
|
||||
'mode': 'obfs',
|
||||
'host': 'obfs-host',
|
||||
'uri': 'obfs-uri'
|
||||
};
|
||||
const parts = [];
|
||||
for (let k in popts) {
|
||||
const v = popts[k];
|
||||
if (type(v) === 'object') continue;
|
||||
push(parts, (FIELD_MAP[k] || k) + '=' + v);
|
||||
}
|
||||
plugin_opts = join(';', parts);
|
||||
}
|
||||
/* Clash "obfs" maps to sing-box plugin "obfs-local" */
|
||||
let sb_plugin = null;
|
||||
if (plugin === 'obfs') sb_plugin = 'obfs-local';
|
||||
else if (plugin === 'v2ray-plugin') sb_plugin = 'v2ray-plugin';
|
||||
else if (plugin === 'shadow-tls') sb_plugin = 'shadow-tls';
|
||||
else if (plugin) sb_plugin = plugin;
|
||||
|
||||
return strip_null({
|
||||
type: 'shadowsocks',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
method: p.cipher,
|
||||
password: p.password,
|
||||
plugin: sb_plugin,
|
||||
plugin_opts: plugin_opts
|
||||
});
|
||||
}
|
||||
|
||||
function convert_vmess(p) {
|
||||
return strip_null({
|
||||
type: 'vmess',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
uuid: p.uuid,
|
||||
alter_id: toint(p.alterId) || 0,
|
||||
security: p.cipher || 'auto',
|
||||
tls: build_tls(p),
|
||||
transport: build_transport(p)
|
||||
});
|
||||
}
|
||||
|
||||
function convert_vless(p) {
|
||||
return strip_null({
|
||||
type: 'vless',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
uuid: p.uuid,
|
||||
flow: p.flow,
|
||||
tls: build_tls(p),
|
||||
transport: build_transport(p)
|
||||
});
|
||||
}
|
||||
|
||||
function convert_trojan(p) {
|
||||
return strip_null({
|
||||
type: 'trojan',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
password: p.password,
|
||||
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server },
|
||||
transport: build_transport(p)
|
||||
});
|
||||
}
|
||||
|
||||
function convert_hysteria2(p) {
|
||||
const obfs_pass = p['obfs-password'];
|
||||
return strip_null({
|
||||
type: 'hysteria2',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
password: pick(p, 'password', 'auth'),
|
||||
up_mbps: toint(p.up),
|
||||
down_mbps: toint(p.down),
|
||||
obfs: (p.obfs && obfs_pass) ? { type: p.obfs, password: obfs_pass } : null,
|
||||
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server }
|
||||
});
|
||||
}
|
||||
|
||||
function convert_tuic(p) {
|
||||
return strip_null({
|
||||
type: 'tuic',
|
||||
tag: p.name,
|
||||
server: p.server,
|
||||
server_port: toint(p.port),
|
||||
uuid: p.uuid,
|
||||
password: p.password,
|
||||
congestion_control: p['congestion-controller'] || p.congestion,
|
||||
udp_relay_mode: p['udp-relay-mode'],
|
||||
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server, alpn: p.alpn }
|
||||
});
|
||||
}
|
||||
|
||||
function convert_proxy(p) {
|
||||
const sb_type = SUPPORTED[p.type];
|
||||
if (!sb_type) {
|
||||
if (p.type && p.type !== 'select')
|
||||
logerr(sprintf("skip unsupported type '%s' for '%s'", p.type, p.name));
|
||||
return null;
|
||||
}
|
||||
if (!p.server || !p.port || !p.name) {
|
||||
logerr(sprintf("skip proxy missing server/port/name (type=%s)", p.type));
|
||||
return null;
|
||||
}
|
||||
switch (p.type) {
|
||||
case 'ss':
|
||||
case 'shadowsocks': return convert_ss(p);
|
||||
case 'vmess': return convert_vmess(p);
|
||||
case 'vless': return convert_vless(p);
|
||||
case 'trojan': return convert_trojan(p);
|
||||
case 'hysteria2':
|
||||
case 'hy2': return convert_hysteria2(p);
|
||||
case 'tuic': return convert_tuic(p);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- dedup (tag must be unique) ---------- */
|
||||
function dedupe_tags(nodes) {
|
||||
const seen = {};
|
||||
for (let n in nodes) {
|
||||
let base = n.tag, t = base, i = 2;
|
||||
while (seen[t]) {
|
||||
t = base + '_' + i;
|
||||
i++;
|
||||
}
|
||||
n.tag = t;
|
||||
seen[t] = true;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/* Drop airline pseudo-nodes (Traffic:/Expire:/quota/官网/QQ etc.) from selectors
|
||||
* and urltests. They're real outbounds (so the UI can read traffic/expiry) but
|
||||
* don't forward — in a selector/urltest they win (0ms) and swallow all traffic. */
|
||||
function is_pseudo_node_tag(tag) {
|
||||
if (!tag) return false;
|
||||
const t = '' + tag;
|
||||
const patterns = [
|
||||
/^Traffic[::]/i,
|
||||
/^Expire[::]/i,
|
||||
/剩余流量|剩余[::]/,
|
||||
/距离下次重置/,
|
||||
/到期(时间|日期)?[::]/,
|
||||
/官网[::]/,
|
||||
/网站[::]/,
|
||||
/套餐[::]?/,
|
||||
/客服[::]/,
|
||||
/QQ[群]?[::]/i,
|
||||
/Telegram|TG群|官方群/i,
|
||||
/续费|订阅地址|流量重置/,
|
||||
];
|
||||
for (let re in patterns) if (match(t, re)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------- detect region from tag -> 'HK'/'JP'/'US'/'SG'/'OTHER'/'' ---------- */
|
||||
function region_of(tag) {
|
||||
if (!tag) return '';
|
||||
const t = '' + tag;
|
||||
if (match(t, /港|🇭🇰|HK[^A-Za-z]|[^A-Za-z]HK|^HK$|[Hh]ong[Kk]/)) return 'HK';
|
||||
if (match(t, /日|🇯🇵|JP[^A-Za-z]|[^A-Za-z]JP|^JP$|[Jj]apan/)) return 'JP';
|
||||
if (match(t, /美|🇺🇸|US[^A-Za-z]|[^A-Za-z]US|^US$|[Uu]nited.?[Ss]tates|[Aa]merica/)) return 'US';
|
||||
if (match(t, /新加坡|🇸🇬|SG[^A-Za-z]|[^A-Za-z]SG|^SG$|[Ss]ingapore/)) return 'SG';
|
||||
if (match(t, /台湾|台|🇹🇼|TW[^A-Za-z]|[^A-Za-z]TW|^TW$|[Tt]aiwan|韩国|韩|🇰🇷|KR[^A-Za-z]|[^A-Za-z]KR|^KR$|[Kk]orea/)) return 'OTHER';
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ---------- expand __NODES__/__NODES_XX__ placeholders to real tags (dropping pseudo-nodes) ---------- */
|
||||
function expand_node_placeholder(outbounds, node_tags) {
|
||||
const real_tags = [];
|
||||
for (let t in node_tags) if (!is_pseudo_node_tag(t)) push(real_tags, t);
|
||||
|
||||
/* bucket by region */
|
||||
let tags_hk = [], tags_jp = [], tags_us = [], tags_sg = [], tags_other = [];
|
||||
for (let t in real_tags) {
|
||||
const r = region_of(t);
|
||||
if (r === 'HK') push(tags_hk, t);
|
||||
else if (r === 'JP') push(tags_jp, t);
|
||||
else if (r === 'US') push(tags_us, t);
|
||||
else if (r === 'SG') push(tags_sg, t);
|
||||
else if (r === 'OTHER') push(tags_other, t);
|
||||
}
|
||||
/* when a region has no nodes, fall back to all nodes (avoid empty outbounds) */
|
||||
if (!length(tags_hk)) tags_hk = real_tags;
|
||||
if (!length(tags_jp)) tags_jp = real_tags;
|
||||
if (!length(tags_us)) tags_us = real_tags;
|
||||
if (!length(tags_sg)) tags_sg = real_tags;
|
||||
if (!length(tags_other)) tags_other = real_tags;
|
||||
|
||||
for (let ob in outbounds) {
|
||||
if (ob.type !== 'selector' && ob.type !== 'urltest') continue;
|
||||
if (!ob.outbounds || type(ob.outbounds) !== 'array') continue;
|
||||
const expanded = [];
|
||||
for (let item in ob.outbounds) {
|
||||
let list = null;
|
||||
if (item === '__NODES__') list = real_tags;
|
||||
else if (item === '__NODES_HK__') list = tags_hk;
|
||||
else if (item === '__NODES_JP__') list = tags_jp;
|
||||
else if (item === '__NODES_US__') list = tags_us;
|
||||
else if (item === '__NODES_SG__') list = tags_sg;
|
||||
else if (item === '__NODES_OTHER__') list = tags_other;
|
||||
if (list !== null) { for (let t in list) push(expanded, t); }
|
||||
else { push(expanded, item); }
|
||||
}
|
||||
ob.outbounds = expanded;
|
||||
}
|
||||
return outbounds;
|
||||
}
|
||||
|
||||
/* ---------- proxy-providers ---------- */
|
||||
function resolve_providers(yaml) {
|
||||
if (type(yaml['proxy-providers']) !== 'object')
|
||||
return [];
|
||||
const all = [];
|
||||
for (let name in yaml['proxy-providers']) {
|
||||
const p = yaml['proxy-providers'][name];
|
||||
if (p.type !== 'http' || !p.url) {
|
||||
logerr(sprintf("provider '%s': skip (not http or no url)", name));
|
||||
continue;
|
||||
}
|
||||
const cmd = sprintf("wget -q -O- --timeout=20 %s 2>/dev/null | yq -o=json eval . 2>/dev/null", quote_sh(p.url));
|
||||
const h = popen(cmd, "r");
|
||||
if (!h) { logerr(sprintf("provider '%s': download failed", name)); continue; }
|
||||
let buf = "", chunk;
|
||||
while ((chunk = h.read(65536))) buf += chunk;
|
||||
h.close();
|
||||
if (!length(buf)) { logerr(sprintf("provider '%s': empty response", name)); continue; }
|
||||
const data = json(buf);
|
||||
if (!data || type(data.proxies) !== 'array') {
|
||||
logerr(sprintf("provider '%s': no proxies array in response", name));
|
||||
continue;
|
||||
}
|
||||
logerr(sprintf("provider '%s': %d proxies", name, length(data.proxies)));
|
||||
for (let px in data.proxies) push(all, px);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/* ---------- main ---------- */
|
||||
const yaml_path = ARGV[0];
|
||||
const tpl_path = ARGV[1] || TPL_DEFAULT;
|
||||
const out_path = ARGV[2];
|
||||
|
||||
if (!yaml_path)
|
||||
die("usage: ucode yaml2singbox.uc <yaml-in> [template-json] [json-out]");
|
||||
|
||||
const yaml = read_yaml_as_json(yaml_path);
|
||||
|
||||
let proxies = [];
|
||||
if (type(yaml.proxies) === 'array')
|
||||
proxies = yaml.proxies;
|
||||
else
|
||||
logerr("no inline proxies array, checking proxy-providers...");
|
||||
|
||||
if (type(yaml['proxy-providers']) === 'object') {
|
||||
const pproxies = resolve_providers(yaml);
|
||||
for (let p in pproxies) push(proxies, p);
|
||||
}
|
||||
|
||||
if (!length(proxies))
|
||||
die(sprintf("no proxies found in %s (inline or via providers)", yaml_path));
|
||||
|
||||
const tpl_raw = readfile(tpl_path);
|
||||
if (!tpl_raw)
|
||||
die(sprintf("cannot read template %s", tpl_path));
|
||||
const tpl = json(tpl_raw);
|
||||
if (!tpl || type(tpl.outbounds) !== 'array')
|
||||
die(sprintf("template %s has no outbounds[]", tpl_path));
|
||||
|
||||
/* convert proxies */
|
||||
const nodes = [];
|
||||
let skipped = 0;
|
||||
for (let p in proxies) {
|
||||
const o = convert_proxy(p);
|
||||
if (o) push(nodes, o); else skipped++;
|
||||
}
|
||||
dedupe_tags(nodes);
|
||||
|
||||
if (!length(nodes))
|
||||
die(sprintf("no usable nodes converted from %d proxies (skipped=%d)", length(proxies), skipped));
|
||||
|
||||
logerr(sprintf("converted=%d skipped=%d", length(nodes), skipped));
|
||||
|
||||
/* prepend converted nodes to template outbounds */
|
||||
const final_outbounds = [];
|
||||
for (let n in nodes) push(final_outbounds, n);
|
||||
for (let ob in tpl.outbounds) push(final_outbounds, ob);
|
||||
|
||||
/* expand __NODES__ placeholders */
|
||||
const node_tags = map(nodes, (n) => n.tag);
|
||||
expand_node_placeholder(final_outbounds, node_tags);
|
||||
|
||||
tpl.outbounds = final_outbounds;
|
||||
|
||||
const out = sprintf("%.J\n", tpl);
|
||||
if (out_path) {
|
||||
if (!writefile(out_path, out))
|
||||
die(sprintf("writefile failed: %s", out_path));
|
||||
logerr(sprintf("wrote %s (%d bytes, %d nodes)", out_path, length(out), length(nodes)));
|
||||
} else {
|
||||
print(out);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -f
|
||||
|
||||
[ "$(uci -q get clashoo.config.fake_ip_filter_migrated 2>/dev/null)" = "1" ] && exit 0
|
||||
|
||||
filters="$(uci -q get clashoo.config.fake_ip_filter 2>/dev/null)"
|
||||
has_legacy=0
|
||||
for filter in $filters; do
|
||||
[ "$filter" = "*.lan" ] && has_legacy=1
|
||||
done
|
||||
|
||||
if [ "$has_legacy" = "1" ]; then
|
||||
uci -q delete clashoo.config.fake_ip_filter
|
||||
added_lan=0
|
||||
added_local=0
|
||||
for filter in $filters; do
|
||||
case "$filter" in
|
||||
'*.lan'|'+.lan')
|
||||
if [ "$added_lan" = "0" ]; then
|
||||
uci -q add_list clashoo.config.fake_ip_filter='+.lan'
|
||||
added_lan=1
|
||||
fi
|
||||
;;
|
||||
'+.local')
|
||||
if [ "$added_local" = "0" ]; then
|
||||
uci -q add_list clashoo.config.fake_ip_filter='+.local'
|
||||
added_local=1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
uci -q add_list clashoo.config.fake_ip_filter="$filter"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$added_local" = "0" ]; then
|
||||
uci -q add_list clashoo.config.fake_ip_filter='+.local'
|
||||
fi
|
||||
fi
|
||||
|
||||
uci -q set clashoo.config.fake_ip_filter_migrated='1'
|
||||
uci -q commit clashoo
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ "$(uci -q get clashoo.config.acl_migrated 2>/dev/null)" = "1" ] && exit 0
|
||||
if uci -q show clashoo 2>/dev/null | grep -q '=lan_acl$'; then
|
||||
uci -q set clashoo.config.acl_migrated='1'
|
||||
uci -q commit clashoo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode="$(uci -q get clashoo.config.access_control 2>/dev/null)"
|
||||
|
||||
add_group() {
|
||||
local dns="$1" proxy="$2" list_key="${3:-}" section value
|
||||
|
||||
section="$(uci -q add clashoo lan_acl)" || exit 1
|
||||
uci -q set clashoo."$section".enabled='1'
|
||||
uci -q set clashoo."$section".dns="$dns"
|
||||
uci -q set clashoo."$section".proxy="$proxy"
|
||||
if [ -n "$list_key" ]; then
|
||||
for value in $(uci -q get clashoo.config."$list_key" 2>/dev/null); do
|
||||
uci -q add_list clashoo."$section".ip="$value"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
case "$mode" in
|
||||
1)
|
||||
add_group 1 1 proxy_lan_ips
|
||||
add_group 0 0
|
||||
;;
|
||||
2)
|
||||
add_group 0 0 reject_lan_ips
|
||||
add_group 1 1
|
||||
;;
|
||||
esac
|
||||
|
||||
uci -q set clashoo.config.acl_migrated='1'
|
||||
uci -q commit clashoo
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
# clashoo access check
|
||||
# 用法: access_check.sh <url> [mode]
|
||||
# mode = direct | proxy(默认 proxy)
|
||||
# direct: 经 openwrt 主干直连,不走任何代理
|
||||
# proxy: 走 clashoo 本地代理端口,验证核心出站
|
||||
|
||||
url="$1"
|
||||
mode="${2:-proxy}"
|
||||
[ -n "$url" ] || exit 1
|
||||
|
||||
attempts=1
|
||||
ok=0
|
||||
sum_ms=0
|
||||
last_code=000
|
||||
|
||||
url_host() {
|
||||
printf '%s' "$1" | sed -n 's#^[a-zA-Z][a-zA-Z0-9+.-]*://\([^/:?#]*\).*$#\1#p'
|
||||
}
|
||||
|
||||
url_port() {
|
||||
_u="$1"
|
||||
_p="$(printf '%s' "$_u" | sed -n 's#^[a-zA-Z][a-zA-Z0-9+.-]*://[^/:?#]*:\([0-9][0-9]*\).*$#\1#p')"
|
||||
[ -n "$_p" ] && { printf '%s' "$_p"; return; }
|
||||
case "$_u" in
|
||||
https://*) printf '443' ;;
|
||||
http://*) printf '80' ;;
|
||||
*) printf '443' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_with_timeout() {
|
||||
_secs="$1"
|
||||
shift
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$_secs" "$@" 2>/dev/null
|
||||
else
|
||||
"$@" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_real_ip() {
|
||||
_host="$1"
|
||||
[ -n "$_host" ] || return 0
|
||||
for _dns in 223.5.5.5 119.29.29.29 1.1.1.1; do
|
||||
_ip="$(run_with_timeout 1 nslookup "$_host" "$_dns" | awk '/^Address: /{print $2} /^Address [0-9]+: /{print $3}' | grep -E '^[0-9]+(\.[0-9]+){3}$' | tail -n1)"
|
||||
[ -n "$_ip" ] && { printf '%s' "$_ip"; return; }
|
||||
done
|
||||
}
|
||||
|
||||
set -- -4 -L --max-time 5 --connect-timeout 3 -s -o /dev/null -w '%{http_code} %{time_total}'
|
||||
if [ "$mode" = "direct" ]; then
|
||||
# 强制不走任何代理,避免误读 http_proxy 环境变量
|
||||
set -- "$@" --noproxy '*'
|
||||
host="$(url_host "$url")"
|
||||
port="$(url_port "$url")"
|
||||
real_ip="$(resolve_real_ip "$host")"
|
||||
[ -n "$host" ] && [ -n "$port" ] && [ -n "$real_ip" ] && set -- "$@" --resolve "${host}:${port}:${real_ip}"
|
||||
else
|
||||
proxy_port="$(uci get clashoo.config.mixed_port 2>/dev/null)"
|
||||
[ -z "$proxy_port" ] && proxy_port="$(uci get clashoo.config.http_port 2>/dev/null)"
|
||||
[ -z "$proxy_port" ] && proxy_port=7890
|
||||
set -- "$@" -x "http://127.0.0.1:${proxy_port}"
|
||||
fi
|
||||
set -- "$@" "$url"
|
||||
|
||||
i=1
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
out="$(curl "$@" 2>/dev/null || true)"
|
||||
code="$(printf '%s' "$out" | awk '{print $1}')"
|
||||
time_s="$(printf '%s' "$out" | awk '{print $2}')"
|
||||
[ -n "$code" ] || code=000
|
||||
[ -n "$time_s" ] || time_s=0
|
||||
ms="$(awk -v t="$time_s" 'BEGIN{printf "%d", t*1000}')"
|
||||
last_code="$code"
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ] || [ "$code" = "301" ] || [ "$code" = "302" ]; then
|
||||
ok=$((ok + 1))
|
||||
sum_ms=$((sum_ms + ms))
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
loss=$((attempts - ok))
|
||||
avg_ms=0
|
||||
if [ "$ok" -gt 0 ]; then
|
||||
avg_ms=$((sum_ms / ok))
|
||||
fi
|
||||
|
||||
echo "ok=$ok attempts=$attempts loss=$loss avg_ms=$avg_ms code=$last_code"
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
CACHE_DIR="/tmp/clashoo"
|
||||
CACHE_FILE="/tmp/clashoo_check_cache"
|
||||
LOCK_DIR="${CACHE_DIR}/access_check.lock"
|
||||
LOCK_PID_FILE="${LOCK_DIR}/pid"
|
||||
UPDATING_FLAG="${CACHE_DIR}/access_check_updating"
|
||||
TMP_FILE="${CACHE_FILE}.tmp.$$"
|
||||
DIAG_LOG="/tmp/clashoo_access_check.log"
|
||||
|
||||
take_lock() {
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || return 1
|
||||
printf '%s\n' "$$" > "$LOCK_PID_FILE" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
pid_is_cache_worker() {
|
||||
[ -r "/proc/$1/cmdline" ] || return 1
|
||||
tr '\0' ' ' <"/proc/$1/cmdline" 2>/dev/null | grep -q 'access_check_cache\.sh'
|
||||
}
|
||||
|
||||
lock_is_dead() {
|
||||
_pid="$(cat "$LOCK_PID_FILE" 2>/dev/null)"
|
||||
case "$_pid" in
|
||||
''|*[!0-9]*)
|
||||
[ -n "$(find "$LOCK_DIR" -maxdepth 0 -mmin +3 2>/dev/null)" ]
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
[ ! -d "/proc/$_pid" ] && return 0
|
||||
! pid_is_cache_worker "$_pid"
|
||||
}
|
||||
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if ! take_lock; then
|
||||
lock_is_dead || exit 0
|
||||
rm -rf "$LOCK_DIR" >/dev/null 2>&1
|
||||
take_lock || exit 0
|
||||
fi
|
||||
trap 'rm -rf "$LOCK_DIR" "$UPDATING_FLAG" "$TMP_FILE"' EXIT INT TERM
|
||||
: > "$UPDATING_FLAG"
|
||||
|
||||
safe_int() {
|
||||
case "${1:-}" in
|
||||
''|*[!0-9]*)
|
||||
printf '0'
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
safe_code() {
|
||||
case "${1:-}" in
|
||||
''|*[!0-9A-Za-z]*)
|
||||
printf '000'
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
parse_field() {
|
||||
_line="$1"
|
||||
_key="$2"
|
||||
printf '%s\n' "$_line" | sed -n "s/.*${_key}=\\([^ ]*\\).*/\\1/p"
|
||||
}
|
||||
|
||||
probe_json() {
|
||||
_line="$1"
|
||||
_ok="$(safe_int "$(parse_field "$_line" "ok")")"
|
||||
_attempts="$(safe_int "$(parse_field "$_line" "attempts")")"
|
||||
_loss="$(safe_int "$(parse_field "$_line" "loss")")"
|
||||
_avg_ms="$(safe_int "$(parse_field "$_line" "avg_ms")")"
|
||||
_code="$(safe_code "$(parse_field "$_line" "code")")"
|
||||
|
||||
_state="down"
|
||||
if [ "$_ok" -ge "$_attempts" ] && [ "$_attempts" -gt 0 ]; then
|
||||
if [ "$_avg_ms" -ge 2500 ]; then
|
||||
_state="high_latency"
|
||||
else
|
||||
_state="ok"
|
||||
fi
|
||||
elif [ "$_ok" -gt 0 ]; then
|
||||
_state="loss"
|
||||
fi
|
||||
|
||||
_ok_bool=false
|
||||
[ "$_ok" -gt 0 ] && _ok_bool=true
|
||||
|
||||
printf '{"ok":%s,"state":"%s","code":"%s","ok_count":%s,"attempts":%s,"loss":%s,"avg_ms":%s}' \
|
||||
"$_ok_bool" "$_state" "$_code" "$_ok" "$_attempts" "$_loss" "$_avg_ms"
|
||||
}
|
||||
|
||||
log_diag() {
|
||||
_line="$(date '+%Y-%m-%d %H:%M:%S') $1"
|
||||
printf '%s\n' "$_line" >>"$DIAG_LOG" 2>/dev/null
|
||||
_n="$(wc -l <"$DIAG_LOG" 2>/dev/null || echo 0)"
|
||||
if [ "${_n:-0}" -gt 200 ]; then
|
||||
tail -n 100 "$DIAG_LOG" >"${DIAG_LOG}.tmp" 2>/dev/null && mv "${DIAG_LOG}.tmp" "$DIAG_LOG" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
probe_run() {
|
||||
_url="$1"
|
||||
_mode="$2"
|
||||
# nice + ionice:探测 IO/CPU 都低优先级,避免抢占 LuCI dispatcher
|
||||
nice -n 19 /usr/share/clashoo/net/access_check.sh "$_url" "$_mode" 2>/dev/null || true
|
||||
}
|
||||
|
||||
proxy_port="$(uci -q get clashoo.config.mixed_port)"
|
||||
[ -z "$proxy_port" ] && proxy_port="$(uci -q get clashoo.config.http_port)"
|
||||
[ -z "$proxy_port" ] && proxy_port="7890"
|
||||
tcp_mode="$(uci -q get clashoo.config.tcp_mode)"
|
||||
[ -z "$tcp_mode" ] && tcp_mode="redirect"
|
||||
udp_mode="$(uci -q get clashoo.config.udp_mode)"
|
||||
[ -z "$udp_mode" ] && udp_mode="$tcp_mode"
|
||||
|
||||
proxy_listening() {
|
||||
# 检测 mixed-port 是否在 LISTEN(clashoo 未运行时立即标 proxy down,避免显示假绿)
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tln 2>/dev/null | awk -v p=":${proxy_port}" '$0 ~ "LISTEN" && index($4, p) {found=1; exit} END{exit !found}'
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -tln 2>/dev/null | awk -v p=":${proxy_port}" '$0 ~ "LISTEN" && index($4, p) {found=1; exit} END{exit !found}'
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# 并行探测,把 CPU 抢占窗口从串行 2s+ 压缩到最慢一路的耗时
|
||||
f_db="${TMP_FILE}.db"
|
||||
f_dy="${TMP_FILE}.dy"
|
||||
f_pb="${TMP_FILE}.pb"
|
||||
f_py="${TMP_FILE}.py"
|
||||
probe_run "https://www.douyin.com/generate_204" "direct" >"$f_db" &
|
||||
probe_run "https://www.youtube.com/generate_204" "direct" >"$f_dy" &
|
||||
if proxy_listening; then
|
||||
proxy_skipped=0
|
||||
probe_run "https://www.douyin.com/generate_204" "proxy" >"$f_pb" &
|
||||
probe_run "https://www.youtube.com/generate_204" "proxy" >"$f_py" &
|
||||
else
|
||||
# 代理端口未监听(clashoo 已停止),跳过探测,直接标 down
|
||||
proxy_skipped=1
|
||||
echo "ok=0 attempts=1 loss=1 avg_ms=0 code=skip" >"$f_pb"
|
||||
echo "ok=0 attempts=1 loss=1 avg_ms=0 code=skip" >"$f_py"
|
||||
fi
|
||||
wait
|
||||
direct_bytedance="$(cat "$f_db" 2>/dev/null)"
|
||||
direct_youtube="$(cat "$f_dy" 2>/dev/null)"
|
||||
proxy_bytedance="$(cat "$f_pb" 2>/dev/null)"
|
||||
proxy_youtube="$(cat "$f_py" 2>/dev/null)"
|
||||
rm -f "$f_db" "$f_dy" "$f_pb" "$f_py"
|
||||
|
||||
updated_at="$(date +%s)"
|
||||
|
||||
probe_all_down() {
|
||||
for _p in "$direct_bytedance" "$direct_youtube" "$proxy_bytedance" "$proxy_youtube"; do
|
||||
case "$_p" in
|
||||
''|*"ok=0 "*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "$(uci -q get clashoo.config.access_check_debug)" = "1" ] || probe_all_down; then
|
||||
log_diag "port=${proxy_port} proxy_skipped=${proxy_skipped} direct_bd=[${direct_bytedance}] direct_yt=[${direct_youtube}] proxy_bd=[${proxy_bytedance}] proxy_yt=[${proxy_youtube}]"
|
||||
fi
|
||||
|
||||
cat >"$TMP_FILE" <<EOF
|
||||
{
|
||||
"proxy_port": "${proxy_port}",
|
||||
"tcp_mode": "${tcp_mode}",
|
||||
"udp_mode": "${udp_mode}",
|
||||
"updated_at": ${updated_at},
|
||||
"proxy_skipped": ${proxy_skipped},
|
||||
"stale": false,
|
||||
"updating": false,
|
||||
"direct": {
|
||||
"bytedance": $(probe_json "$direct_bytedance"),
|
||||
"youtube": $(probe_json "$direct_youtube")
|
||||
},
|
||||
"proxy": {
|
||||
"bytedance": $(probe_json "$proxy_bytedance"),
|
||||
"youtube": $(probe_json "$proxy_youtube")
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
mv -f "$TMP_FILE" "$CACHE_FILE"
|
||||
exit 0
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
RUNDIR="/tmp/clashoo"
|
||||
PID_FILE="${RUNDIR}/access_check_daemon.pid"
|
||||
LOCK_DIR="${RUNDIR}/access_check_daemon.lock"
|
||||
# 周期:默认 30s,ACCESS_CHECK_INTERVAL 环境变量可固定覆盖
|
||||
INTERVAL_FIXED="${ACCESS_CHECK_INTERVAL:-}"
|
||||
|
||||
mkdir -p "$RUNDIR"
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
old_pid="$(cat "$PID_FILE" 2>/dev/null)"
|
||||
if [ -n "$old_pid" ] && [ -d "/proc/$old_pid" ] && \
|
||||
tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null | grep -q 'access_check_daemon\.sh'; then
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$LOCK_DIR" "$PID_FILE" >/dev/null 2>&1
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$LOCK_DIR" "$PID_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 0' INT TERM
|
||||
|
||||
echo "$$" >"$PID_FILE"
|
||||
|
||||
while :; do
|
||||
[ "$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ] || break
|
||||
/usr/share/clashoo/net/access_check_cache.sh >/dev/null 2>&1 || true
|
||||
if [ -n "$INTERVAL_FIXED" ]; then
|
||||
sleep "$INTERVAL_FIXED"
|
||||
else
|
||||
sleep 30
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/bin/sh
|
||||
|
||||
APPLY=0
|
||||
TIMEOUT="${CLASHOO_DNS_TEST_TIMEOUT:-2}"
|
||||
START_TS="$(date +%s 2>/dev/null || echo 0)"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
json_pair() {
|
||||
printf '"%s":"%s"' "$1" "$(json_escape "$2")"
|
||||
}
|
||||
|
||||
is_ipv4() {
|
||||
echo "$1" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'
|
||||
}
|
||||
|
||||
append_unique() {
|
||||
list="$1"
|
||||
item="$2"
|
||||
[ -n "$item" ] || { printf '%s' "$list"; return; }
|
||||
case " $list " in
|
||||
*" $item "*) printf '%s' "$list" ;;
|
||||
*) printf '%s %s' "$list" "$item" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
current_default_nameservers() {
|
||||
uci -q get clashoo.config.default_nameserver 2>/dev/null || true
|
||||
}
|
||||
|
||||
current_dns_servers() {
|
||||
uci -q show clashoo 2>/dev/null | awk -F= '
|
||||
function clean(v) {
|
||||
gsub(/^'\''|'\''$/, "", v);
|
||||
return v;
|
||||
}
|
||||
/^clashoo\.@dnsservers\[[0-9]+\]\./ {
|
||||
line=$1;
|
||||
val=clean($2);
|
||||
sub(/^clashoo\.@dnsservers\[/, "", line);
|
||||
idx=line;
|
||||
sub(/\].*$/, "", idx);
|
||||
opt=line;
|
||||
sub(/^[0-9]+\]\./, "", opt);
|
||||
data[idx, opt]=val;
|
||||
seen[idx]=1;
|
||||
}
|
||||
END {
|
||||
for (idx in seen) {
|
||||
if (data[idx, "enabled"] == "0") continue;
|
||||
role=data[idx, "ser_type"];
|
||||
addr=data[idx, "ser_address"];
|
||||
proto=data[idx, "protocol"];
|
||||
port=data[idx, "ser_port"];
|
||||
if (addr == "") continue;
|
||||
if (proto != "" && proto != "none" && addr !~ /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//) {
|
||||
addr=proto addr;
|
||||
if (port != "" && addr !~ /:[0-9]+$/) addr=addr ":" port;
|
||||
}
|
||||
print role "|" addr;
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
servers_for_role() {
|
||||
role="$1"
|
||||
current_dns_servers | awk -F'|' -v role="$role" '$1 == role { print $2 }'
|
||||
}
|
||||
|
||||
candidate_host_port() {
|
||||
uri="$1"
|
||||
default_port="$2"
|
||||
hostport="${uri#*://}"
|
||||
hostport="${hostport%%/*}"
|
||||
host="${hostport%%:*}"
|
||||
port="${hostport##*:}"
|
||||
[ "$port" != "$hostport" ] || port="$default_port"
|
||||
printf '%s %s\n' "$host" "$port"
|
||||
}
|
||||
|
||||
probe_tls() {
|
||||
uri="$1"
|
||||
set -- $(candidate_host_port "$uri" 853)
|
||||
host="$1"
|
||||
port="$2"
|
||||
command -v nc >/dev/null 2>&1 || return 1
|
||||
nc -w "$TIMEOUT" "$host" "$port" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
probe_ip_dns() {
|
||||
ip="$1"
|
||||
domain="$2"
|
||||
command -v nslookup >/dev/null 2>&1 || return 1
|
||||
nslookup "$domain" "$ip" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
probe_latency_ms() {
|
||||
candidate="$1"
|
||||
domain="$2"
|
||||
if [ "${candidate#https://}" != "$candidate" ]; then
|
||||
if printf '%s' "$candidate" | grep -q '?'; then
|
||||
q="$candidate&name=$domain&type=A"
|
||||
else
|
||||
q="$candidate?name=$domain&type=A"
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
t="$(curl -fsS --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \
|
||||
-H 'accept: application/dns-json' -o /dev/null -w '%{time_total}' "$q" 2>/dev/null)" || return 1
|
||||
printf '%s' "$t" | awk '{ms=$1*1000; if (ms<1) ms=1; printf "%d", ms}'
|
||||
elif [ "${candidate#tls://}" != "$candidate" ]; then
|
||||
probe_tls "$candidate" || return 1
|
||||
printf '900'
|
||||
elif [ "${candidate#udp://}" != "$candidate" ]; then
|
||||
probe_ip_dns "${candidate#udp://}" "$domain" || return 1
|
||||
printf '950'
|
||||
elif [ "${candidate#tcp://}" != "$candidate" ]; then
|
||||
probe_ip_dns "${candidate#tcp://}" "$domain" || return 1
|
||||
printf '950'
|
||||
else
|
||||
is_ipv4 "$candidate" && probe_ip_dns "$candidate" "$domain" || return 1
|
||||
printf '950'
|
||||
fi
|
||||
}
|
||||
|
||||
select_best() {
|
||||
list="$1"
|
||||
domain="$2"
|
||||
fallback="$3"
|
||||
best=""
|
||||
best_score=999999
|
||||
order=0
|
||||
for candidate in $list; do
|
||||
order=$((order + 1))
|
||||
ms="$(probe_latency_ms "$candidate" "$domain")" || { FAILED_COUNT=$((FAILED_COUNT + 1)); continue; }
|
||||
score=$((ms * 100 + order))
|
||||
if [ "$score" -lt "$best_score" ]; then
|
||||
best="$candidate"
|
||||
best_score="$score"
|
||||
fi
|
||||
done
|
||||
[ -n "$best" ] || best="$fallback"
|
||||
printf '%s' "$best"
|
||||
}
|
||||
|
||||
add_dnsserver() {
|
||||
role="$1"
|
||||
addr="$2"
|
||||
proto="$3"
|
||||
port="$4"
|
||||
sec="$(uci -q add clashoo dnsservers)" || return 1
|
||||
[ -n "$sec" ] || return 1
|
||||
uci -q set clashoo."$sec".enabled='1' || return 1
|
||||
uci -q set clashoo."$sec".ser_type="$role" || return 1
|
||||
uci -q set clashoo."$sec".ser_address="$addr" || return 1
|
||||
uci -q set clashoo."$sec".protocol="$proto" || return 1
|
||||
if [ -n "$port" ]; then
|
||||
uci -q set clashoo."$sec".ser_port="$port" || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
set_default_nameserver() {
|
||||
uci -q delete clashoo.config.default_nameserver >/dev/null 2>&1 || true
|
||||
uci -q add_list clashoo.config.default_nameserver="$1" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
apply_result() {
|
||||
uci -q set clashoo.config.enable_dns='1' || { uci -q revert clashoo; return 1; }
|
||||
set_default_nameserver "$BOOTSTRAP" || { uci -q revert clashoo; return 1; }
|
||||
while uci -q delete clashoo.@dnsservers[0] >/dev/null 2>&1; do :; done
|
||||
add_dnsserver 'nameserver' "$NAMESERVER" 'none' '' || { uci -q revert clashoo; return 1; }
|
||||
add_dnsserver 'direct-nameserver' "$BOOTSTRAP" 'udp://' '' || { uci -q revert clashoo; return 1; }
|
||||
add_dnsserver 'proxy-server-nameserver' "$PROXY_NS" 'none' '' || { uci -q revert clashoo; return 1; }
|
||||
add_dnsserver 'fallback' "$FALLBACK_NS" 'none' '' || { uci -q revert clashoo; return 1; }
|
||||
uci -q commit clashoo || { uci -q revert clashoo; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
is_domestic_dns() {
|
||||
printf '%s' "$1" | grep -qE 'alidns|doh\.pub|dnspod|223\.5\.5\.5|223\.6\.6\.6|119\.29\.29\.29|119\.28\.28\.28|114\.114|180\.184|onedns'
|
||||
}
|
||||
is_overseas_dns() {
|
||||
printf '%s' "$1" | grep -qE 'cloudflare|1\.1\.1\.1|1\.0\.0\.1|one\.one\.one\.one|dns\.google|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9|quad9|opendns|208\.67\.'
|
||||
}
|
||||
|
||||
FAILED_COUNT=0
|
||||
BOOTSTRAP_CANDIDATES="223.5.5.5 119.29.29.29 180.184.1.1"
|
||||
DOMESTIC_CANDIDATES="https://dns.alidns.com/dns-query https://doh.pub/dns-query udp://223.5.5.5 udp://119.29.29.29"
|
||||
FALLBACK_CANDIDATES="https://cloudflare-dns.com/dns-query https://dns.google/dns-query tls://1.1.1.1:853"
|
||||
|
||||
for ns in $(current_default_nameservers); do
|
||||
is_ipv4 "$ns" && BOOTSTRAP_CANDIDATES="$(append_unique "$BOOTSTRAP_CANDIDATES" "$ns")"
|
||||
done
|
||||
for ns in $(servers_for_role 'nameserver') $(servers_for_role 'direct-nameserver'); do
|
||||
is_domestic_dns "$ns" && DOMESTIC_CANDIDATES="$(append_unique "$DOMESTIC_CANDIDATES" "$ns")"
|
||||
done
|
||||
for ns in $(servers_for_role 'fallback'); do
|
||||
is_overseas_dns "$ns" && FALLBACK_CANDIDATES="$(append_unique "$FALLBACK_CANDIDATES" "$ns")"
|
||||
done
|
||||
|
||||
BOOTSTRAP="$(select_best "$BOOTSTRAP_CANDIDATES" www.baidu.com 223.5.5.5)"
|
||||
NAMESERVER="$(select_best "$DOMESTIC_CANDIDATES" www.baidu.com https://dns.alidns.com/dns-query)"
|
||||
PROXY_NS="tls://1.1.1.1:853"
|
||||
FALLBACK_NS="$(select_best "$FALLBACK_CANDIDATES" www.google.com https://cloudflare-dns.com/dns-query)"
|
||||
|
||||
APPLIED=false
|
||||
if [ "$APPLY" = "1" ]; then
|
||||
if apply_result; then
|
||||
APPLIED=true
|
||||
else
|
||||
printf '{"success":false,"error":"apply_failed","message":"DNS 自动配置写入失败"}\n'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
END_TS="$(date +%s 2>/dev/null || echo "$START_TS")"
|
||||
ELAPSED_MS=$(( (END_TS - START_TS) * 1000 ))
|
||||
|
||||
printf '{'
|
||||
printf '"success":true,'
|
||||
printf '"applied":%s,' "$APPLIED"
|
||||
printf '"restarted":false,'
|
||||
json_pair bootstrap "$BOOTSTRAP"; printf ','
|
||||
json_pair nameserver "$NAMESERVER"; printf ','
|
||||
json_pair direct_nameserver "$BOOTSTRAP"; printf ','
|
||||
json_pair proxy_nameserver "$PROXY_NS"; printf ','
|
||||
json_pair fallback "$FALLBACK_NS"; printf ','
|
||||
printf '"failed_count":%s,' "$FAILED_COUNT"
|
||||
printf '"elapsed_ms":%s' "$ELAPSED_MS"
|
||||
printf '}\n'
|
||||
Executable
+898
@@ -0,0 +1,898 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
NFT_DIR="/var/run/clash"
|
||||
SETS_RULES="${NFT_DIR}/fw4_sets.nft"
|
||||
DSTNAT_RULES="${NFT_DIR}/fw4_dstnat.nft"
|
||||
MANGLE_RULES="${NFT_DIR}/fw4_mangle.nft"
|
||||
OUTPUT_RULES="${NFT_DIR}/fw4_output.nft"
|
||||
BUILTIN_NFT_DIR="/usr/share/clashoo/nftables"
|
||||
GEOIP_CN_NFT="${BUILTIN_NFT_DIR}/geoip_cn.nft"
|
||||
GEOIP6_CN_NFT="${BUILTIN_NFT_DIR}/geoip6_cn.nft"
|
||||
LOCAL_OUTPUT_TABLE="clashoo_local"
|
||||
QUIC_BLOCK_TABLE="clashoo_quic"
|
||||
# PROXY_FWMARK: inbound TPROXY mark, ip rule -> table PROXY_ROUTE_TABLE -> lo.
|
||||
# CORE_ROUTING_MARK: mihomo outbound SO_MARK (= routing-mark in config).
|
||||
# Must differ: otherwise mihomo egress is pulled to lo -> unreachable.
|
||||
# When changing CORE_ROUTING_MARK, also update yum_change.sh routing_mark_dec.
|
||||
PROXY_FWMARK="0x162"
|
||||
PROXY_ROUTE_TABLE="0x162"
|
||||
CORE_ROUTING_MARK="0x1a0a" # = 6666
|
||||
ACL_BYPASS_FWMARK="0x163"
|
||||
SINGBOX_BYPASS_FWMARK="0x2024"
|
||||
ACL_BYPASS_PREF="8998"
|
||||
DNSMASQ_BYPASS_PREF="8999"
|
||||
|
||||
uci_get() {
|
||||
uci -q get "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
bool_enabled() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
acl_bool() {
|
||||
[ -n "$1" ] || return 0
|
||||
bool_enabled "$1"
|
||||
}
|
||||
|
||||
tun_available() {
|
||||
ip tuntap add mode tun name cotuntest >/dev/null 2>&1 || return 1
|
||||
ip link del cotuntest >/dev/null 2>&1 || true
|
||||
return 0
|
||||
}
|
||||
|
||||
config_redir_port() {
|
||||
uci_get clashoo.config.redir_port
|
||||
}
|
||||
|
||||
config_tproxy_port() {
|
||||
local port
|
||||
port="$(uci_get clashoo.config.tproxy_port)"
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s\n' "$port"
|
||||
else
|
||||
config_redir_port
|
||||
fi
|
||||
}
|
||||
|
||||
config_tcp_mode() {
|
||||
uci_get clashoo.config.tcp_mode
|
||||
}
|
||||
|
||||
config_udp_mode() {
|
||||
uci_get clashoo.config.udp_mode
|
||||
}
|
||||
|
||||
config_access_control() {
|
||||
[ "$(uci_get clashoo.config.acl_migrated)" = "1" ] && {
|
||||
printf '0\n'
|
||||
return
|
||||
}
|
||||
uci_get clashoo.config.access_control
|
||||
}
|
||||
|
||||
acl_sections() {
|
||||
uci -q show clashoo 2>/dev/null | sed -n 's/^clashoo\.\([^.=]*\)=lan_acl$/\1/p'
|
||||
}
|
||||
|
||||
acl_list() {
|
||||
uci_get "$1"
|
||||
}
|
||||
|
||||
acl_sections_catchall_last() {
|
||||
local section
|
||||
for section in $(acl_sections); do
|
||||
[ -z "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] || printf '%s\n' "$section"
|
||||
done
|
||||
for section in $(acl_sections); do
|
||||
[ -n "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] || printf '%s\n' "$section"
|
||||
done
|
||||
}
|
||||
|
||||
grouped_acl_enabled() {
|
||||
local section
|
||||
[ "$(uci_get clashoo.config.acl_migrated)" = "1" ] || return 1
|
||||
for section in $(acl_sections); do
|
||||
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
acl_has_catchall() {
|
||||
local section
|
||||
for section in $(acl_sections); do
|
||||
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
|
||||
[ -z "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
lan_acl_enabled() {
|
||||
grouped_acl_enabled && return 0
|
||||
case "$(config_access_control)" in
|
||||
1|2) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
config_enable_dns() {
|
||||
uci_get clashoo.config.enable_dns
|
||||
}
|
||||
|
||||
config_dns_port() {
|
||||
local port=""
|
||||
if [ "$(uci_get clashoo.config.core_type)" != "singbox" ]; then
|
||||
port="$(sed -n 's/^[[:space:]]*listen:.*:[[:space:]]*['"'"'"]*\([0-9]\{1,\}\)['"'"'"]*[[:space:]]*$/\1/p' /etc/clashoo/config.yaml 2>/dev/null | head -n1)"
|
||||
fi
|
||||
[ -n "$port" ] || port="$(uci_get clashoo.config.listen_port)"
|
||||
printf '%s' "$port" | grep -Eq '^[0-9]+$' || port=1053
|
||||
printf '%s\n' "$port"
|
||||
}
|
||||
|
||||
lan_dns_split_enabled() {
|
||||
lan_acl_enabled || return 1
|
||||
bool_enabled "$(config_enable_dns)" || return 1
|
||||
bool_enabled "$(config_ipv4_dns_hijack)" || bool_enabled "$(config_ipv6_dns_hijack)"
|
||||
}
|
||||
|
||||
tun_acl_enabled() {
|
||||
lan_acl_enabled || return 1
|
||||
[ "$(config_tcp_mode)" = "tun" ] || [ "$(config_udp_mode)" = "tun" ]
|
||||
}
|
||||
|
||||
config_ipv4_dns_hijack() {
|
||||
uci_get clashoo.config.ipv4_dns_hijack
|
||||
}
|
||||
|
||||
config_ipv6_dns_hijack() {
|
||||
uci_get clashoo.config.ipv6_dns_hijack
|
||||
}
|
||||
|
||||
config_ipv4_proxy() {
|
||||
uci_get clashoo.config.ipv4_proxy
|
||||
}
|
||||
|
||||
config_ipv6_proxy() {
|
||||
uci_get clashoo.config.ipv6_proxy
|
||||
}
|
||||
|
||||
singbox_tun_active() {
|
||||
[ "$(uci_get clashoo.config.core_type)" = "singbox" ] || return 1
|
||||
[ "$(config_tcp_mode)" = "tun" ] || [ "$(config_udp_mode)" = "tun" ]
|
||||
}
|
||||
|
||||
config_bypass_china() {
|
||||
uci_get clashoo.config.bypass_china
|
||||
}
|
||||
|
||||
config_bypass_china_ipv6() {
|
||||
local value
|
||||
value="$(uci_get clashoo.config.bypass_china_ipv6)"
|
||||
if [ -n "$value" ]; then
|
||||
printf '%s\n' "$value"
|
||||
else
|
||||
config_bypass_china
|
||||
fi
|
||||
}
|
||||
|
||||
config_block_quic() {
|
||||
uci_get clashoo.config.block_quic
|
||||
}
|
||||
|
||||
config_bypass_port_mode() {
|
||||
uci_get clashoo.config.bypass_port_mode
|
||||
}
|
||||
|
||||
config_bypass_port_custom() {
|
||||
uci_get clashoo.config.bypass_port_custom
|
||||
}
|
||||
|
||||
config_legacy_bypass_port() {
|
||||
uci_list clashoo.config.bypass_port
|
||||
}
|
||||
|
||||
config_proxy_tcp_dport() {
|
||||
local mode custom legacy value
|
||||
mode="$(config_bypass_port_mode)"
|
||||
custom="$(config_bypass_port_custom)"
|
||||
legacy="$(uci_get clashoo.config.proxy_tcp_dport)"
|
||||
[ -z "$legacy" ] && legacy="$(config_legacy_bypass_port)"
|
||||
|
||||
case "$mode" in
|
||||
all)
|
||||
# 空值在 render_port_match 中表示该协议的全部端口
|
||||
printf '%s\n' ''
|
||||
;;
|
||||
common)
|
||||
printf '%s\n' '22,53,80,443,8080,8443'
|
||||
;;
|
||||
custom)
|
||||
value="$custom"
|
||||
[ -z "$value" ] && value="$legacy"
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$legacy"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
config_proxy_udp_dport() {
|
||||
local mode custom legacy value
|
||||
mode="$(config_bypass_port_mode)"
|
||||
custom="$(config_bypass_port_custom)"
|
||||
legacy="$(uci_get clashoo.config.proxy_udp_dport)"
|
||||
[ -z "$legacy" ] && legacy="$(config_legacy_bypass_port)"
|
||||
|
||||
case "$mode" in
|
||||
all)
|
||||
printf '%s\n' ''
|
||||
;;
|
||||
common)
|
||||
printf '%s\n' '22,53,80,443,8080,8443'
|
||||
;;
|
||||
custom)
|
||||
value="$custom"
|
||||
[ -z "$value" ] && value="$legacy"
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$legacy"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
config_bypass_dscp() {
|
||||
uci_list clashoo.config.bypass_dscp
|
||||
}
|
||||
|
||||
config_bypass_fwmark() {
|
||||
uci_list clashoo.config.bypass_fwmark
|
||||
}
|
||||
|
||||
config_fake_ip_range6() {
|
||||
local value
|
||||
value="$(uci_get clashoo.config.fake_ip_range6)"
|
||||
[ -n "$value" ] || value="fc00::/18"
|
||||
[ "$(uci_get clashoo.config.enhanced_mode)" = "redir-host" ] && return 0
|
||||
bool_enabled "$(uci_get clashoo.config.enable_ipv6)" || return 0
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
config_fake_ip_range() {
|
||||
local value
|
||||
value="$(uci_get clashoo.config.fake_ip_range)"
|
||||
[ -n "$value" ] && {
|
||||
printf '%s\n' "$value"
|
||||
return
|
||||
}
|
||||
printf '198.18.0.1/16\n'
|
||||
}
|
||||
|
||||
uci_list() {
|
||||
local key="$1"
|
||||
uci -q show "$key" 2>/dev/null | sed -n "s/^${key}=//p" | sed "s/'//g"
|
||||
}
|
||||
|
||||
ensure_firewall_include() {
|
||||
local name="$1"
|
||||
local path="$2"
|
||||
local chain="${3:-}"
|
||||
local position="${4:-chain-pre}"
|
||||
|
||||
uci -q batch <<-EOF >/dev/null
|
||||
set firewall.${name}=include
|
||||
set firewall.${name}.type='nftables'
|
||||
set firewall.${name}.path='${path}'
|
||||
set firewall.${name}.position='${position}'
|
||||
$( [ -n "$chain" ] && printf "set firewall.%s.chain='%s'\n" "$name" "$chain" )
|
||||
commit firewall
|
||||
EOF
|
||||
}
|
||||
|
||||
remove_firewall_include() {
|
||||
local name="$1"
|
||||
uci -q delete firewall."${name}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
render_common_returns() {
|
||||
local fake6
|
||||
fake6="$(config_fake_ip_range6)"
|
||||
printf '%s\n' 'meta nfproto ipv4 ip daddr { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return'
|
||||
if [ -n "$fake6" ]; then
|
||||
printf 'meta nfproto ipv6 ip6 daddr { ::1/128, fc00::/7, fe80::/10, ff00::/8 } ip6 daddr != %s return\n' "$fake6"
|
||||
else
|
||||
printf '%s\n' 'meta nfproto ipv6 ip6 daddr { ::1/128, fc00::/7, fe80::/10, ff00::/8 } return'
|
||||
fi
|
||||
}
|
||||
|
||||
render_ip_elements() {
|
||||
local list="$1"
|
||||
local first=1 entry
|
||||
for entry in $list; do
|
||||
if [ "$first" -eq 0 ]; then
|
||||
printf ', '
|
||||
fi
|
||||
printf '%s' "$entry"
|
||||
first=0
|
||||
done
|
||||
}
|
||||
|
||||
render_token_elements() {
|
||||
printf '%s\n' "$1" | tr ',\t' ' ' | awk '
|
||||
BEGIN { first = 1 }
|
||||
{
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if ($i == "")
|
||||
continue
|
||||
if (!first)
|
||||
printf ", "
|
||||
printf "%s", $i
|
||||
first = 0
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
detect_coexist_fwmarks() {
|
||||
# Merge co-installed proxy plugin fwmarks into bypass to avoid intercepting
|
||||
# their traffic. Only when init.d exists (passwall:0x1 passwall2:0xff
|
||||
# nikki:tproxy 0x80/tun 0x81 mask 0xff).
|
||||
local marks=""
|
||||
[ -x /etc/init.d/passwall ] && marks="$marks 0x1"
|
||||
[ -x /etc/init.d/passwall2 ] && marks="$marks 0xff"
|
||||
if [ -x /etc/init.d/nikki ]; then
|
||||
local nm
|
||||
nm="$(uci -q get nikki.routing.tproxy_fw_mark) $(uci -q get nikki.routing.tun_fw_mark)"
|
||||
[ -z "$(echo "$nm" | tr -d ' ')" ] && nm="0x80 0x81"
|
||||
marks="$marks $nm"
|
||||
fi
|
||||
printf '%s\n' "$marks"
|
||||
}
|
||||
|
||||
merge_fwmark_tokens() {
|
||||
# Always bypass PROXY_FWMARK (inbound TPROXY) and CORE_ROUTING_MARK (core egress)
|
||||
# so nft never traps them. Also merge co-installed plugin fwmarks for coexistence.
|
||||
local coexist
|
||||
coexist="$(detect_coexist_fwmarks)"
|
||||
printf '%s %s %s %s\n' "$1" "$PROXY_FWMARK" "$CORE_ROUTING_MARK" "$coexist" | tr ',\t' ' ' | awk '
|
||||
BEGIN { first = 1 }
|
||||
{
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if ($i == "" || seen[$i]++)
|
||||
continue
|
||||
if (!first)
|
||||
printf ", "
|
||||
printf "%s", $i
|
||||
first = 0
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
render_port_match() {
|
||||
local proto="$1"
|
||||
local ports="$2"
|
||||
local port_elements
|
||||
|
||||
port_elements="$(render_token_elements "$ports")"
|
||||
if [ -n "$port_elements" ]; then
|
||||
printf 'meta l4proto %s %s dport { %s }' "$proto" "$proto" "$port_elements"
|
||||
else
|
||||
printf 'meta l4proto %s' "$proto"
|
||||
fi
|
||||
}
|
||||
|
||||
render_acl_dns_rules() {
|
||||
local redirect_port="$1" section ipv4 ipv6 mac ipv4_elements ipv6_elements mac_elements action
|
||||
for section in $(acl_sections_catchall_last); do
|
||||
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
|
||||
if acl_bool "$(uci_get "clashoo.${section}.dns")"; then
|
||||
action="counter redirect to :${redirect_port}"
|
||||
elif singbox_tun_active; then
|
||||
action="meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
|
||||
else
|
||||
action="counter return"
|
||||
fi
|
||||
ipv4="$(acl_list "clashoo.${section}.ip")"
|
||||
ipv6="$(acl_list "clashoo.${section}.ip6")"
|
||||
mac="$(acl_list "clashoo.${section}.mac")"
|
||||
ipv4_elements="$(render_ip_elements "$ipv4")"
|
||||
ipv6_elements="$(render_ip_elements "$ipv6")"
|
||||
mac_elements="$(render_ip_elements "$mac")"
|
||||
|
||||
if [ -z "$ipv4_elements$ipv6_elements$mac_elements" ]; then
|
||||
bool_enabled "$(config_ipv4_dns_hijack)" && printf 'meta nfproto ipv4 meta l4proto { tcp, udp } th dport 53 %s\n' "$action"
|
||||
bool_enabled "$(config_ipv6_dns_hijack)" && printf 'meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 %s\n' "$action"
|
||||
continue
|
||||
fi
|
||||
if [ -n "$ipv4_elements" ] && bool_enabled "$(config_ipv4_dns_hijack)"; then
|
||||
printf 'meta nfproto ipv4 ip saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$ipv4_elements" "$action"
|
||||
fi
|
||||
if [ -n "$ipv6_elements" ] && bool_enabled "$(config_ipv6_dns_hijack)"; then
|
||||
printf 'meta nfproto ipv6 ip6 saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$ipv6_elements" "$action"
|
||||
fi
|
||||
if [ -n "$mac_elements" ]; then
|
||||
bool_enabled "$(config_ipv4_dns_hijack)" && printf 'meta nfproto ipv4 ether saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$mac_elements" "$action"
|
||||
bool_enabled "$(config_ipv6_dns_hijack)" && printf 'meta nfproto ipv6 ether saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$mac_elements" "$action"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
render_acl_proxy_rules() {
|
||||
local mode="$1" proto="$2" port_match="$3" target_port="$4"
|
||||
local section ipv4 ipv6 mac ipv4_elements ipv6_elements mac_elements enabled action4 action6
|
||||
|
||||
for section in $(acl_sections_catchall_last); do
|
||||
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
|
||||
enabled="$(uci_get "clashoo.${section}.proxy")"
|
||||
case "$mode" in
|
||||
redirect)
|
||||
if acl_bool "$enabled"; then
|
||||
if singbox_tun_active; then
|
||||
action4="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} counter redirect to :${target_port}"
|
||||
else
|
||||
action4="${port_match} counter redirect to :${target_port}"
|
||||
fi
|
||||
action6="$action4"
|
||||
elif singbox_tun_active; then
|
||||
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
|
||||
action6="$action4"
|
||||
else
|
||||
action4="meta l4proto ${proto} counter return"
|
||||
action6="$action4"
|
||||
fi
|
||||
;;
|
||||
tproxy)
|
||||
if acl_bool "$enabled"; then
|
||||
if singbox_tun_active; then
|
||||
action4="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} tproxy ip to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
|
||||
action6="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} tproxy ip6 to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
|
||||
else
|
||||
action4="${port_match} tproxy ip to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
|
||||
action6="${port_match} tproxy ip6 to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
|
||||
fi
|
||||
elif singbox_tun_active; then
|
||||
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
|
||||
action6="$action4"
|
||||
else
|
||||
action4="meta l4proto ${proto} counter return"
|
||||
action6="$action4"
|
||||
fi
|
||||
;;
|
||||
tun)
|
||||
if acl_bool "$enabled"; then
|
||||
action4="meta l4proto ${proto} counter return"
|
||||
elif singbox_tun_active; then
|
||||
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter accept"
|
||||
else
|
||||
action4="meta l4proto ${proto} meta mark set ${ACL_BYPASS_FWMARK} counter accept"
|
||||
fi
|
||||
action6="$action4"
|
||||
;;
|
||||
esac
|
||||
|
||||
ipv4="$(acl_list "clashoo.${section}.ip")"
|
||||
ipv6="$(acl_list "clashoo.${section}.ip6")"
|
||||
mac="$(acl_list "clashoo.${section}.mac")"
|
||||
ipv4_elements="$(render_ip_elements "$ipv4")"
|
||||
ipv6_elements="$(render_ip_elements "$ipv6")"
|
||||
mac_elements="$(render_ip_elements "$mac")"
|
||||
|
||||
if [ -z "$ipv4_elements$ipv6_elements$mac_elements" ]; then
|
||||
bool_enabled "$(config_ipv4_proxy)" && printf 'meta nfproto ipv4 %s\n' "$action4"
|
||||
bool_enabled "$(config_ipv6_proxy)" && printf 'meta nfproto ipv6 %s\n' "$action6"
|
||||
continue
|
||||
fi
|
||||
if [ -n "$ipv4_elements" ] && bool_enabled "$(config_ipv4_proxy)"; then
|
||||
printf 'meta nfproto ipv4 ip saddr { %s } %s\n' "$ipv4_elements" "$action4"
|
||||
fi
|
||||
if [ -n "$ipv6_elements" ] && bool_enabled "$(config_ipv6_proxy)"; then
|
||||
printf 'meta nfproto ipv6 ip6 saddr { %s } %s\n' "$ipv6_elements" "$action6"
|
||||
fi
|
||||
if [ -n "$mac_elements" ]; then
|
||||
bool_enabled "$(config_ipv4_proxy)" && printf 'meta nfproto ipv4 ether saddr { %s } %s\n' "$mac_elements" "$action4"
|
||||
bool_enabled "$(config_ipv6_proxy)" && printf 'meta nfproto ipv6 ether saddr { %s } %s\n' "$mac_elements" "$action6"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Split proxied clients to the core DNS while ACL-bypassed clients use dnsmasq.
|
||||
# Gated on UCI IPv4/IPv6 toggles.
|
||||
# return 0: no output when both off, avoid set -e false-positive.
|
||||
render_dns_hijack() {
|
||||
local access_control="$1" redirect_port=53
|
||||
if lan_dns_split_enabled; then
|
||||
redirect_port="$(config_dns_port)"
|
||||
fi
|
||||
if grouped_acl_enabled; then
|
||||
render_acl_dns_rules "$redirect_port"
|
||||
acl_has_catchall && return 0
|
||||
else
|
||||
[ "$access_control" = "1" ] && printf '%s\n' 'ip saddr != @clash_proxy_lan return'
|
||||
[ "$access_control" = "2" ] && printf '%s\n' 'ip saddr @clash_reject_lan return'
|
||||
fi
|
||||
bool_enabled "$(config_ipv4_dns_hijack)" && \
|
||||
printf '%s\n' "meta nfproto ipv4 meta l4proto { tcp, udp } th dport 53 counter redirect to :${redirect_port}"
|
||||
bool_enabled "$(config_ipv6_dns_hijack)" && \
|
||||
printf '%s\n' "meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 counter redirect to :${redirect_port}"
|
||||
return 0
|
||||
}
|
||||
|
||||
apply_local_output_rule() {
|
||||
local redir_port fake_ip_range tcp_mode bypass_fwmark bypass_china
|
||||
local fwmark_elements fwmark_rule china_set china_rule dnsmasq_rule dnsmasq_uid
|
||||
redir_port="$(config_redir_port)"
|
||||
fake_ip_range="$(config_fake_ip_range)"
|
||||
tcp_mode="$(config_tcp_mode)"
|
||||
bypass_fwmark="$(config_bypass_fwmark)"
|
||||
bypass_china="$(config_bypass_china)"
|
||||
|
||||
# Keep local-output redirect usable when tun mode is selected but tun
|
||||
# device is unavailable on the system.
|
||||
if [ "$tcp_mode" = "tun" ] && ! tun_available; then
|
||||
tcp_mode="redirect"
|
||||
fi
|
||||
|
||||
nft delete table ip ${LOCAL_OUTPUT_TABLE} >/dev/null 2>&1 || true
|
||||
|
||||
# Only apply local output redirect when tcp_mode is redirect
|
||||
[ "$tcp_mode" != "redirect" ] && return 0
|
||||
|
||||
# bypass mihomo's own marks (prevent self-hijack -> dead loop)
|
||||
fwmark_rule=""
|
||||
fwmark_elements="$(merge_fwmark_tokens "$bypass_fwmark")"
|
||||
[ -n "$fwmark_elements" ] && fwmark_rule="meta mark { ${fwmark_elements} } return"
|
||||
|
||||
# CN IP bypass (uses clashoo_china set from geoip_cn.nft)
|
||||
china_set=""
|
||||
china_rule=""
|
||||
if bool_enabled "$bypass_china" && [ -s "$GEOIP_CN_NFT" ]; then
|
||||
china_set="$(cat "$GEOIP_CN_NFT")"
|
||||
china_rule="ip daddr @clashoo_china return"
|
||||
fi
|
||||
|
||||
dnsmasq_rule=""
|
||||
if lan_dns_split_enabled; then
|
||||
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
|
||||
printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$' && dnsmasq_rule="meta skuid ${dnsmasq_uid} return"
|
||||
fi
|
||||
|
||||
nft -f - <<EOF
|
||||
table ip ${LOCAL_OUTPUT_TABLE} {
|
||||
set clashoo_localnetwork {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
|
||||
169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16,
|
||||
224.0.0.0/4, 240.0.0.0/4 }
|
||||
}
|
||||
${china_set}
|
||||
chain output {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
${dnsmasq_rule}
|
||||
${fwmark_rule}
|
||||
ip daddr @clashoo_localnetwork return
|
||||
${china_rule}
|
||||
ip daddr ${fake_ip_range} tcp dport != 53 redirect to :${redir_port}
|
||||
meta l4proto tcp redirect to :${redir_port}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
remove_local_output_rule() {
|
||||
nft delete table ip ${LOCAL_OUTPUT_TABLE} >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remove_acl_bypass_rules() {
|
||||
local dnsmasq_uid
|
||||
ip rule del pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
|
||||
ip -6 rule del pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
|
||||
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
|
||||
if printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$'; then
|
||||
ip rule del pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
|
||||
ip -6 rule del pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
apply_acl_bypass_rules() {
|
||||
local dnsmasq_uid
|
||||
remove_acl_bypass_rules
|
||||
tun_acl_enabled || return 0
|
||||
ip rule add pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
|
||||
ip -6 rule add pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
|
||||
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
|
||||
if printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$'; then
|
||||
ip rule add pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
|
||||
ip -6 rule add pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
apply_block_quic_rule() {
|
||||
local fake_ip_range china_set china_rule scope
|
||||
nft delete table inet ${QUIC_BLOCK_TABLE} >/dev/null 2>&1 || true
|
||||
bool_enabled "$(config_block_quic)" || return 0
|
||||
|
||||
fake_ip_range="$(config_fake_ip_range)"
|
||||
china_set=""
|
||||
scope="ip daddr ${fake_ip_range}"
|
||||
if [ "$(uci_get clashoo.config.enhanced_mode)" != "fake-ip" ]; then
|
||||
if bool_enabled "$(config_bypass_china)" && [ -s "$GEOIP_CN_NFT" ]; then
|
||||
china_set="$(cat "$GEOIP_CN_NFT")"
|
||||
china_rule="ip daddr @clashoo_china return"
|
||||
fi
|
||||
scope=""
|
||||
fi
|
||||
|
||||
nft -f - <<EOF
|
||||
table inet ${QUIC_BLOCK_TABLE} {
|
||||
${china_set}
|
||||
chain prerouting {
|
||||
type filter hook prerouting priority mangle - 1; policy accept;
|
||||
${china_rule:-}
|
||||
${scope} udp dport 443 counter reject with icmpx type port-unreachable
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority -10; policy accept;
|
||||
${china_rule:-}
|
||||
${scope} udp dport 443 counter reject with icmpx type port-unreachable
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
remove_block_quic_rule() {
|
||||
nft delete table inet ${QUIC_BLOCK_TABLE} >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
write_empty_set() {
|
||||
local set_name="$1"
|
||||
local set_type="$2"
|
||||
|
||||
printf 'set %s {\n\ttype %s;\n\tflags interval;\n\tauto-merge;\n}\n\n' "$set_name" "$set_type"
|
||||
}
|
||||
|
||||
append_set_from_file_or_empty() {
|
||||
local file_path="$1"
|
||||
local set_name="$2"
|
||||
local set_type="$3"
|
||||
|
||||
if [ -s "$file_path" ]; then
|
||||
cat "$file_path"
|
||||
printf '\n'
|
||||
else
|
||||
write_empty_set "$set_name" "$set_type"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_rules() {
|
||||
local redir_port tproxy_port tcp_mode udp_mode access_control fake_ip_range proxy_lan_ips reject_lan_ips
|
||||
local proxy_tcp_dport proxy_udp_dport bypass_dscp bypass_fwmark bypass_china bypass_china_ipv6
|
||||
local acl_catchall=0
|
||||
redir_port="$(config_redir_port)"
|
||||
tproxy_port="$(config_tproxy_port)"
|
||||
tcp_mode="$(config_tcp_mode)"
|
||||
udp_mode="$(config_udp_mode)"
|
||||
access_control="$(config_access_control)"
|
||||
grouped_acl_enabled && acl_has_catchall && acl_catchall=1
|
||||
bypass_china="$(config_bypass_china)"
|
||||
bypass_china_ipv6="$(config_bypass_china_ipv6)"
|
||||
proxy_tcp_dport="$(config_proxy_tcp_dport)"
|
||||
proxy_udp_dport="$(config_proxy_udp_dport)"
|
||||
bypass_dscp="$(config_bypass_dscp)"
|
||||
bypass_fwmark="$(config_bypass_fwmark)"
|
||||
fake_ip_range="$(config_fake_ip_range)"
|
||||
proxy_lan_ips="$(uci_list clashoo.config.proxy_lan_ips)"
|
||||
reject_lan_ips="$(uci_list clashoo.config.reject_lan_ips)"
|
||||
|
||||
# When tun device is unavailable, fall back to non-tun transparent modes
|
||||
# so routing rules still take effect for sing-box redirect/tproxy inbounds.
|
||||
if [ "$tcp_mode" = "tun" ] || [ "$udp_mode" = "tun" ]; then
|
||||
if ! tun_available; then
|
||||
[ "$tcp_mode" = "tun" ] && tcp_mode="redirect"
|
||||
[ "$udp_mode" = "tun" ] && udp_mode="tproxy"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$NFT_DIR"
|
||||
|
||||
# Build optional elements lines (nftables rejects empty elements = {})
|
||||
local proxy_elements reject_elements dscp_elements fwmark_elements
|
||||
proxy_elements="$(render_ip_elements "$proxy_lan_ips")"
|
||||
reject_elements="$(render_ip_elements "$reject_lan_ips")"
|
||||
dscp_elements="$(render_token_elements "$bypass_dscp")"
|
||||
fwmark_elements="$(merge_fwmark_tokens "$bypass_fwmark")"
|
||||
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
|
||||
udp_match="$(render_port_match udp "$proxy_udp_dport")"
|
||||
|
||||
{
|
||||
printf 'set clashoo_localnetwork {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
|
||||
printf '\telements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 }\n}\n\n'
|
||||
|
||||
append_set_from_file_or_empty "$GEOIP_CN_NFT" clashoo_china ipv4_addr
|
||||
append_set_from_file_or_empty "$GEOIP6_CN_NFT" clashoo_china6 ipv6_addr
|
||||
|
||||
printf 'set clash_proxy_lan {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
|
||||
[ -n "$proxy_elements" ] && printf '\telements = { %s }\n' "$proxy_elements"
|
||||
printf '}\n\n'
|
||||
|
||||
printf 'set clash_reject_lan {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
|
||||
[ -n "$reject_elements" ] && printf '\telements = { %s }\n' "$reject_elements"
|
||||
printf '}\n\n'
|
||||
|
||||
if grouped_acl_enabled && [ "$tcp_mode" = "tun" ]; then
|
||||
printf 'chain clashoo_acl_tun_tcp {\n'
|
||||
render_acl_proxy_rules tun tcp "$tcp_match" "$tproxy_port"
|
||||
printf '}\n\n'
|
||||
fi
|
||||
if grouped_acl_enabled && [ "$udp_mode" = "tun" ]; then
|
||||
printf 'chain clashoo_acl_tun_udp {\n'
|
||||
render_acl_proxy_rules tun udp "$udp_match" "$tproxy_port"
|
||||
printf '}\n'
|
||||
fi
|
||||
} > "$SETS_RULES"
|
||||
|
||||
: > "$OUTPUT_RULES"
|
||||
|
||||
|
||||
# DNS hijack rules go atop DSTNAT_RULES: must run for all TCP modes
|
||||
# (redirect/tproxy/tun) and BEFORE proxy redirect rules so port 53 is not stolen.
|
||||
render_dns_hijack "$access_control" > "$DSTNAT_RULES"
|
||||
|
||||
|
||||
# TCP rules: redirect mode appends proxy redirect; tproxy/tun skip this chain
|
||||
case "$tcp_mode" in
|
||||
redirect)
|
||||
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
|
||||
cat >> "$DSTNAT_RULES" <<EOF
|
||||
$( render_common_returns )
|
||||
$( bool_enabled "$bypass_china_ipv6" && printf '%s\n' 'ip6 daddr @clashoo_china6 return' )
|
||||
$( bool_enabled "$bypass_china" && printf '%s\n' 'ip daddr @clashoo_china return' )
|
||||
$( [ -n "$dscp_elements" ] && printf '%s\n' "ip dscp { ${dscp_elements} } return" )
|
||||
$( [ -n "$dscp_elements" ] && printf '%s\n' "ip6 dscp { ${dscp_elements} } return" )
|
||||
$( [ -n "$fwmark_elements" ] && printf '%s\n' "meta mark { ${fwmark_elements} } return" )
|
||||
$( ! bool_enabled "$(config_ipv4_proxy)" && printf '%s\n' 'meta nfproto ipv4 return' )
|
||||
$( ! bool_enabled "$(config_ipv6_proxy)" && printf '%s\n' 'meta nfproto ipv6 return' )
|
||||
$( grouped_acl_enabled && render_acl_proxy_rules redirect tcp "$tcp_match" "$redir_port" )
|
||||
$( [ "$access_control" = "1" ] && printf '%s\n' 'ip saddr != @clash_proxy_lan return' )
|
||||
$( [ "$access_control" = "2" ] && printf '%s\n' 'ip saddr @clash_reject_lan return' )
|
||||
$( [ "$acl_catchall" -eq 0 ] && { singbox_tun_active && printf '%s ct mark set %s redirect to :%s\n' "$tcp_match" "$SINGBOX_BYPASS_FWMARK" "$redir_port" || printf '%s redirect to :%s\n' "$tcp_match" "$redir_port"; } )
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
# UDP rules: tproxy via mangle. TUN uses mangle only for LAN ACL bypass.
|
||||
# Also handle TCP tproxy mode here (both TCP+UDP in mangle).
|
||||
local need_mangle=0 tun_acl=0
|
||||
[ "$tcp_mode" = "tproxy" ] && need_mangle=1
|
||||
[ "$udp_mode" = "tproxy" ] && need_mangle=1
|
||||
if tun_acl_enabled "$access_control"; then
|
||||
need_mangle=1
|
||||
tun_acl=1
|
||||
fi
|
||||
|
||||
if [ "$need_mangle" -eq 1 ]; then
|
||||
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
|
||||
udp_match="$(render_port_match udp "$proxy_udp_dport")"
|
||||
{
|
||||
render_common_returns
|
||||
if bool_enabled "$bypass_china_ipv6"; then
|
||||
printf 'meta nfproto ipv6 ip6 daddr @clashoo_china6 return\n'
|
||||
fi
|
||||
if bool_enabled "$bypass_china"; then
|
||||
printf 'ip daddr @clashoo_china return\n'
|
||||
fi
|
||||
if [ -n "$dscp_elements" ]; then
|
||||
printf 'ip dscp { %s } return\n' "$dscp_elements"
|
||||
printf 'ip6 dscp { %s } return\n' "$dscp_elements"
|
||||
fi
|
||||
if [ -n "$fwmark_elements" ]; then
|
||||
printf 'meta mark { %s } return\n' "$fwmark_elements"
|
||||
fi
|
||||
bool_enabled "$(config_ipv4_proxy)" || printf 'meta nfproto ipv4 return\n'
|
||||
bool_enabled "$(config_ipv6_proxy)" || printf 'meta nfproto ipv6 return\n'
|
||||
if grouped_acl_enabled; then
|
||||
[ "$tcp_mode" = "tproxy" ] && render_acl_proxy_rules tproxy tcp "$tcp_match" "$tproxy_port"
|
||||
[ "$tcp_mode" = "tun" ] && printf 'meta l4proto tcp jump clashoo_acl_tun_tcp\n'
|
||||
[ "$udp_mode" = "tproxy" ] && render_acl_proxy_rules tproxy udp "$udp_match" "$tproxy_port"
|
||||
[ "$udp_mode" = "tun" ] && printf 'meta l4proto udp jump clashoo_acl_tun_udp\n'
|
||||
elif [ "$tun_acl" -eq 1 ]; then
|
||||
[ "$access_control" = "1" ] && printf 'ip saddr != @clash_proxy_lan meta mark set %s return\n' "$ACL_BYPASS_FWMARK"
|
||||
[ "$access_control" = "2" ] && printf 'ip saddr @clash_reject_lan meta mark set %s return\n' "$ACL_BYPASS_FWMARK"
|
||||
else
|
||||
[ "$access_control" = "1" ] && printf 'ip saddr != @clash_proxy_lan return\n'
|
||||
[ "$access_control" = "2" ] && printf 'ip saddr @clash_reject_lan return\n'
|
||||
fi
|
||||
if bool_enabled "$(config_block_quic)"; then
|
||||
printf 'meta l4proto udp udp dport 443 return\n'
|
||||
fi
|
||||
if [ "$tcp_mode" = "tproxy" ] && [ "$acl_catchall" -eq 0 ]; then
|
||||
if singbox_tun_active; then
|
||||
printf '%s ct mark set %s tproxy to :%s meta mark set %s accept\n' "$tcp_match" "$SINGBOX_BYPASS_FWMARK" "$tproxy_port" "$PROXY_FWMARK"
|
||||
else
|
||||
printf '%s tproxy to :%s meta mark set %s accept\n' "$tcp_match" "$tproxy_port" "$PROXY_FWMARK"
|
||||
fi
|
||||
fi
|
||||
if [ "$udp_mode" = "tproxy" ] && [ "$acl_catchall" -eq 0 ]; then
|
||||
if singbox_tun_active; then
|
||||
printf '%s ct mark set %s tproxy to :%s meta mark set %s accept\n' "$udp_match" "$SINGBOX_BYPASS_FWMARK" "$tproxy_port" "$PROXY_FWMARK"
|
||||
else
|
||||
printf '%s tproxy to :%s meta mark set %s accept\n' "$udp_match" "$tproxy_port" "$PROXY_FWMARK"
|
||||
fi
|
||||
fi
|
||||
} > "$MANGLE_RULES"
|
||||
else
|
||||
: > "$MANGLE_RULES"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_rules() {
|
||||
generate_rules
|
||||
ensure_firewall_include clash_fw4_sets "$SETS_RULES" '' table-pre
|
||||
ensure_firewall_include clash_fw4_dstnat "$DSTNAT_RULES" dstnat
|
||||
remove_firewall_include clash_fw4_output
|
||||
if [ -s "$MANGLE_RULES" ]; then
|
||||
_route_table_dec="$((PROXY_ROUTE_TABLE))"
|
||||
ensure_firewall_include clash_fw4_mangle "$MANGLE_RULES" mangle_prerouting
|
||||
ip rule show 2>/dev/null | grep -q "fwmark ${PROXY_FWMARK}.*lookup ${_route_table_dec}" ||
|
||||
ip rule add fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip route show table "$PROXY_ROUTE_TABLE" 2>/dev/null | grep -q 'local 0.0.0.0/0 dev lo' ||
|
||||
ip route add local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
if bool_enabled "$(config_ipv6_proxy)"; then
|
||||
ip -6 rule show 2>/dev/null | grep -q "fwmark ${PROXY_FWMARK}.*lookup ${_route_table_dec}" ||
|
||||
ip -6 rule add fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 route show table "$PROXY_ROUTE_TABLE" 2>/dev/null | grep -q 'local default dev lo' ||
|
||||
ip -6 route add local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
else
|
||||
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
remove_firewall_include clash_fw4_mangle
|
||||
ip rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip route del local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
/etc/init.d/firewall restart >/dev/null 2>&1 || /etc/init.d/firewall reload >/dev/null 2>&1 || true
|
||||
apply_acl_bypass_rules
|
||||
apply_local_output_rule
|
||||
apply_block_quic_rule
|
||||
}
|
||||
|
||||
remove_rules() {
|
||||
remove_acl_bypass_rules
|
||||
remove_local_output_rule
|
||||
remove_block_quic_rule
|
||||
remove_firewall_include clash_fw4_sets
|
||||
remove_firewall_include clash_fw4_dstnat
|
||||
remove_firewall_include clash_fw4_output
|
||||
remove_firewall_include clash_fw4_mangle
|
||||
remove_firewall_include clash_fw4_forward
|
||||
uci commit firewall >/dev/null 2>&1 || true
|
||||
rm -f "$SETS_RULES" "$DSTNAT_RULES" "$OUTPUT_RULES" "$MANGLE_RULES"
|
||||
ip rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip route del local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
|
||||
/etc/init.d/firewall restart >/dev/null 2>&1 || /etc/init.d/firewall reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
apply)
|
||||
apply_rules
|
||||
;;
|
||||
remove)
|
||||
remove_rules
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {apply|remove}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,43 @@
|
||||
# clashoo 运行日志格式化
|
||||
# 输出: MM-DD HH:MM:SS msg
|
||||
|
||||
# mihomo 原生行: time="YYYY-MM-DDTHH:MM:SS..." level=... msg="..."
|
||||
/^time="[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ {
|
||||
# extract YYYY-MM-DD and HH:MM:SS
|
||||
ts_date = substr($0, 12, 5) # MM-DD
|
||||
ts_time = substr($0, 18, 8) # HH:MM:SS
|
||||
utc_h = substr(ts_time, 1, 2) + 0
|
||||
cst_h = (utc_h + 8) % 24
|
||||
ts = sprintf("%s %02d:%s", ts_date, cst_h, substr(ts_time, 4))
|
||||
|
||||
prefix = ""
|
||||
if (match($0, /level=warning /)) prefix = " [warn]"
|
||||
else if (match($0, /level=error /)) prefix = " [err]"
|
||||
else if (match($0, /level=fatal /)) prefix = " [fatal]"
|
||||
|
||||
i = index($0, "msg=\"")
|
||||
if (i > 0) {
|
||||
rest = substr($0, i + 5)
|
||||
sub(/"[[:space:]]*$/, "", rest)
|
||||
print ts prefix " " rest
|
||||
next
|
||||
}
|
||||
print ts " " $0
|
||||
next
|
||||
}
|
||||
|
||||
# log_msg 行: " YYYY-MM-DD HH:MM:SS - msg" → "MM-DD HH:MM:SS msg"
|
||||
/^[[:space:]]+[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][[:space:]]+[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ {
|
||||
gsub(/^[[:space:]]+/, "") # trim leading space
|
||||
ts_date = substr($0, 6, 5) # MM-DD
|
||||
ts_time = substr($0, 12, 8) # HH:MM:SS
|
||||
# remove "YYYY-MM-DD HH:MM:SS - " prefix
|
||||
sub(/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][[:space:]]+[0-9][0-9]:[0-9][0-9]:[0-9][0-9][[:space:]]*-[[:space:]]*/, "")
|
||||
print ts_date " " ts_time " " $0
|
||||
next
|
||||
}
|
||||
|
||||
# 空行丢弃
|
||||
NF == 0 { next }
|
||||
|
||||
{ print }
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
|
||||
ACTION="$1"
|
||||
LOG_FILE="/tmp/clash_update.txt"
|
||||
|
||||
log_line() {
|
||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
run_init_async() {
|
||||
local action="$1"
|
||||
mkdir -p /tmp/lock >/dev/null 2>&1
|
||||
(flock /tmp/lock/clashoo_rpc_async.lock /etc/init.d/clashoo "$action" >/dev/null 2>&1 </dev/null &)
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
start)
|
||||
run_init_async start
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
run_init_async stop
|
||||
exit 0
|
||||
;;
|
||||
restart)
|
||||
# Fire-and-forget restart used by LuCI RPC to avoid blocking UI apply flow.
|
||||
run_init_async restart
|
||||
exit 0
|
||||
;;
|
||||
update_china_ip)
|
||||
log_line "[china-ip] task started"
|
||||
nohup /usr/share/clashoo/update/update_china_ip.sh >>"$LOG_FILE" 2>&1 </dev/null &
|
||||
exit 0
|
||||
;;
|
||||
update_geoip)
|
||||
log_line "GeoIP 更新任务已触发"
|
||||
(exec 1000>&-; nohup /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1 </dev/null &)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 {start|stop|restart|update_china_ip|update_geoip}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
. /lib/functions.sh
|
||||
|
||||
CURL_GROUP_CACHE="/usr/share/clashbackup/clash_gorup.json"
|
||||
CURL_NOW_CACHE="/usr/share/clashbackup/clash_now.json"
|
||||
CURL_CACHE="/usr/share/clashbackup/clash_curl.json"
|
||||
HISTORY_PATH="/usr/share/clashbackup/history"
|
||||
SECRET=$(uci get clashoo.config.dash_pass 2>/dev/null)
|
||||
LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null)
|
||||
PORT=$(uci get clashoo.config.dash_port 2>/dev/null)
|
||||
|
||||
curl -m 5 --retry 2 -w %{http_code}"\n" -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X GET http://"$LAN_IP":"$PORT"/proxies > "$CURL_CACHE" 2>/dev/null
|
||||
if [ "$(sed -n '$p' "$CURL_CACHE" 2>/dev/null)" = "200" ]; then
|
||||
if [ ! -d /usr/share/clashbackup ];then
|
||||
mkdir -p /usr/share/clashbackup 2>/dev/null
|
||||
fi
|
||||
cat "$CURL_CACHE" |jsonfilter -e '@["proxies"][@.type="Selector"]["name"]' > "$CURL_GROUP_CACHE" 2>/dev/null
|
||||
cat "$CURL_CACHE" |jsonfilter -e '@["proxies"][@.type="Selector"]["now"]' > "$CURL_NOW_CACHE" 2>/dev/null
|
||||
awk 'NR==FNR{a[i]=$0;i++}NR>FNR{print a[j]"#*#"$0;j++}' "$CURL_GROUP_CACHE" "$CURL_NOW_CACHE" > "$HISTORY_PATH" 2>/dev/null
|
||||
fi
|
||||
rm -rf /usr/share/clashbackup/clash_*.json 2>/dev/null
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
#!/bin/sh
|
||||
|
||||
REAL_LOG="/usr/share/clashoo/clashoo_real.txt"
|
||||
UPDATE_LOG="/tmp/clash_update.txt"
|
||||
LIST_FILE="/usr/share/clashbackup/confit_list.conf"
|
||||
SUB_DIR="/usr/share/clashoo/config/sub"
|
||||
TMP_PREFIX="/tmp/clash_sub_$$"
|
||||
DEFAULT_SUB_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
subtype="$(uci -q get clashoo.config.subcri 2>/dev/null)"
|
||||
config_name_raw="$(uci -q get clashoo.config.config_name 2>/dev/null)"
|
||||
lang="$(uci -q get luci.main.lang 2>/dev/null)"
|
||||
|
||||
log_text() {
|
||||
if [ "$lang" = "en" ]; then
|
||||
echo "$1" >"$REAL_LOG"
|
||||
else
|
||||
echo "$2" >"$REAL_LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
log_update() {
|
||||
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
|
||||
}
|
||||
|
||||
sanitize_name() {
|
||||
local name
|
||||
name="$1"
|
||||
name="$(printf '%s' "$name" | tr 'A-Z' 'a-z')"
|
||||
name="$(printf '%s' "$name" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
|
||||
name="$(printf '%s' "$name" | tr ' /' '--')"
|
||||
name="$(printf '%s' "$name" | sed -e 's/[^a-z0-9._-]/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
|
||||
printf '%s' "$name"
|
||||
}
|
||||
|
||||
sanitize_custom_name() {
|
||||
local name
|
||||
name="$1"
|
||||
name="$(printf '%s' "$name" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
|
||||
name="$(printf '%s' "$name" | tr ' /' '--')"
|
||||
name="$(printf '%s' "$name" | sed -e 's/[\\]//g' -e 's/\.\.+/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
|
||||
printf '%s' "$name"
|
||||
}
|
||||
|
||||
url_decode() {
|
||||
printf '%s' "$1" | sed 's/%/\\x/g' | xargs -0 printf '%b' 2>/dev/null || printf '%s' "$1"
|
||||
}
|
||||
|
||||
url_to_name() {
|
||||
local url host qname decoded
|
||||
url="$1"
|
||||
|
||||
qname="$(printf '%s' "$url" | sed -n 's/.*[?&]filename=\([^&#]*\).*/\1/p')"
|
||||
[ -n "$qname" ] || qname="$(printf '%s' "$url" | sed -n 's/.*[?&]name=\([^&#]*\).*/\1/p')"
|
||||
if [ -n "$qname" ]; then
|
||||
# URL decode then strip non-filename chars, keep CJK and alphanumeric
|
||||
decoded="$(url_decode "$qname")"
|
||||
decoded="$(printf '%s' "$decoded" | tr -d '\r\n' | sed -e 's/[[:space:]]/-/g' -e 's/[/\\:*?"<>|]//g' -e 's/\.yaml$//' -e 's/\.yml$//')"
|
||||
[ -n "$decoded" ] && printf '%s' "$decoded" && return
|
||||
fi
|
||||
qname="$(sanitize_name "$qname")"
|
||||
if [ -n "$qname" ]; then
|
||||
printf '%s' "$qname"
|
||||
return
|
||||
fi
|
||||
|
||||
host="$(printf '%s' "$url" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's/:.*$//' -e 's#\..*$##')"
|
||||
host="$(sanitize_name "$host")"
|
||||
[ -n "$host" ] || host="sub"
|
||||
printf '%s' "$host"
|
||||
}
|
||||
|
||||
next_available_name() {
|
||||
local base try idx
|
||||
base="$1"
|
||||
base="$(printf '%s' "$base" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
|
||||
base="$(printf '%s' "$base" | tr ' /' '--')"
|
||||
base="$(printf '%s' "$base" | sed -e 's/[\\]//g' -e 's/\.\.+/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
|
||||
[ -n "$base" ] || base="sub"
|
||||
|
||||
if [ ! -f "$SUB_DIR/${base}.yaml" ]; then
|
||||
printf '%s' "$base"
|
||||
return
|
||||
fi
|
||||
|
||||
idx=2
|
||||
while :; do
|
||||
try="${base}-${idx}"
|
||||
if [ ! -f "$SUB_DIR/${try}.yaml" ]; then
|
||||
printf '%s' "$try"
|
||||
return
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
}
|
||||
|
||||
get_subscription_urls() {
|
||||
uci -q show clashoo.config 2>/dev/null | awk -F"'" '
|
||||
/^clashoo.config.subscribe_url=/ {
|
||||
if (NF >= 3) {
|
||||
for (i = 2; i <= NF; i += 2) {
|
||||
if (length($i) > 0) print $i
|
||||
}
|
||||
} else {
|
||||
sub(/^clashoo.config.subscribe_url=/, "", $0)
|
||||
if (length($0) > 0) print $0
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
ensure_system_dns() {
|
||||
local test_host
|
||||
test_host="github.com"
|
||||
if nslookup "$test_host" 127.0.0.1 >/dev/null 2>&1 || nslookup "$test_host" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
uci delete dhcp.@dnsmasq[0].server >/dev/null 2>&1
|
||||
uci set dhcp.@dnsmasq[0].noresolv='0' >/dev/null 2>&1
|
||||
uci del_list dhcp.@dnsmasq[0].server='127.0.0.1#' >/dev/null 2>&1
|
||||
uci del_list dhcp.@dnsmasq[0].server='127.0.0.1#5300' >/dev/null 2>&1
|
||||
uci add_list dhcp.@dnsmasq[0].server='119.29.29.29' >/dev/null 2>&1
|
||||
uci add_list dhcp.@dnsmasq[0].server='223.5.5.5' >/dev/null 2>&1
|
||||
uci commit dhcp >/dev/null 2>&1
|
||||
/etc/init.d/dnsmasq restart >/dev/null 2>&1
|
||||
sleep 2
|
||||
}
|
||||
|
||||
extract_host() {
|
||||
printf '%s' "$1" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's#:.*$##' -e 's#.*@##'
|
||||
}
|
||||
|
||||
resolve_via() {
|
||||
local host dns
|
||||
host="$1"
|
||||
dns="$2"
|
||||
nslookup "$host" "$dns" 2>/dev/null | awk '
|
||||
/^Address/ {
|
||||
ip = $NF
|
||||
if (ip ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ &&
|
||||
ip !~ /^127\./ &&
|
||||
ip !~ /^0\./ &&
|
||||
ip !~ /^198\.18\./ &&
|
||||
ip != "8.8.8.8" && ip != "8.8.4.4" &&
|
||||
ip != "1.1.1.1" && ip != "1.0.0.1" &&
|
||||
ip != "223.5.5.5" && ip != "223.6.6.6" &&
|
||||
ip != "119.29.29.29" && ip != "114.114.114.114") {
|
||||
print ip
|
||||
exit
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
curl_subscription() {
|
||||
local url tmp hdr err ua host ip http_code rc
|
||||
url="$1"
|
||||
tmp="$2"
|
||||
hdr="$3"
|
||||
err="$4"
|
||||
ua="$5"
|
||||
host="$6"
|
||||
ip="$7"
|
||||
|
||||
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
|
||||
|
||||
set --
|
||||
if [ -n "$host" ] && [ -n "$ip" ]; then
|
||||
set -- --resolve "$host:443:$ip" --resolve "$host:80:$ip"
|
||||
fi
|
||||
http_code="$(curl -sSL --connect-timeout 15 --max-time 60 \
|
||||
--speed-time 30 --speed-limit 1 --retry 2 \
|
||||
-H "User-Agent: ${ua}" -D "$hdr" -o "$tmp" \
|
||||
"$@" \
|
||||
-w '%{http_code}' "$url" 2>"$err")"
|
||||
rc=$?
|
||||
printf '%s\n' "$http_code"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
download_subscription() {
|
||||
local url target tmp hdr err rc http_code info_line ua err_msg
|
||||
local host ip dns
|
||||
url="$1"
|
||||
target="$2"
|
||||
tmp="${TMP_PREFIX}.yaml"
|
||||
hdr="${TMP_PREFIX}.hdr"
|
||||
err="${TMP_PREFIX}.err"
|
||||
|
||||
ua="$(uci -q get clashoo.config.sub_ua 2>/dev/null)"
|
||||
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "" "")"
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -ne 0 ] || [ "$http_code" = "000" ]; then
|
||||
host="$(extract_host "$url")"
|
||||
case "$host" in
|
||||
''|[-.]*|*[!A-Za-z0-9.-]*) host='' ;;
|
||||
esac
|
||||
if [ -n "$host" ]; then
|
||||
for dns in 223.5.5.5 119.29.29.29 1.1.1.1 8.8.8.8; do
|
||||
ip="$(resolve_via "$host" "$dns")"
|
||||
[ -n "$ip" ] || continue
|
||||
log_update "DNS 回退:${host} -> ${ip} (@${dns})"
|
||||
http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "$host" "$ip")"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] && [ "$http_code" = "200" ] && break
|
||||
done
|
||||
fi
|
||||
fi
|
||||
else
|
||||
wget -q --timeout=20 \
|
||||
--user-agent="$ua" "$url" -O "$tmp" 2>"$err"
|
||||
rc=$?
|
||||
http_code=""
|
||||
fi
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
err_msg="$(grep -a 'curl:' "$err" 2>/dev/null | tail -1)"
|
||||
[ -z "$err_msg" ] && err_msg="$(tail -1 "$err" 2>/dev/null)"
|
||||
log_update "下载失败:$(basename "$target") rc=${rc} ${err_msg}"
|
||||
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$http_code" ] && [ "$http_code" != "200" ]; then
|
||||
log_update "下载失败:$(basename "$target") HTTP ${http_code}"
|
||||
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! grep -Eq '^(proxies|proxy-providers):' "$tmp" 2>/dev/null; then
|
||||
log_update "校验失败:$(basename "$target") 内容不含 proxies/proxy-providers"
|
||||
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
|
||||
info_line="$(grep -i 'subscription-userinfo:' "$hdr" 2>/dev/null | head -1 | \
|
||||
sed 's/^[Ss]ubscription-[Uu]serinfo:[[:space:]]*//' | tr -d '\r')"
|
||||
[ -n "$info_line" ] && printf '%s\n' "$info_line" > "${target}.info" || \
|
||||
rm -f "${target}.info" >/dev/null 2>&1
|
||||
|
||||
rm -f "$hdr" "$err" >/dev/null 2>&1
|
||||
mv "$tmp" "$target" >/dev/null 2>&1 || {
|
||||
rm -f "$tmp" >/dev/null 2>&1
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$ua" >"${target}.ua"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
upsert_meta() {
|
||||
local filename url typ tmpf
|
||||
filename="$1"
|
||||
url="$2"
|
||||
typ="$3"
|
||||
tmpf="${TMP_PREFIX}.list"
|
||||
|
||||
[ -f "$LIST_FILE" ] || touch "$LIST_FILE"
|
||||
awk -F '#' -v n="$filename" '$1 != n { print $0 }' "$LIST_FILE" >"$tmpf"
|
||||
printf '%s#%s#%s\n' "$filename" "$url" "$typ" >>"$tmpf"
|
||||
mv "$tmpf" "$LIST_FILE"
|
||||
}
|
||||
|
||||
cleanup_tmp() {
|
||||
rm -f "${TMP_PREFIX}.yaml" "${TMP_PREFIX}.urls" "${TMP_PREFIX}.list" \
|
||||
"${TMP_PREFIX}.hdr" "${TMP_PREFIX}.err" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT INT TERM
|
||||
|
||||
[ "$subtype" = "clash" ] || [ "$subtype" = "meta" ] || subtype="clash"
|
||||
|
||||
mkdir -p "$SUB_DIR" /usr/share/clashbackup >/dev/null 2>&1
|
||||
[ -f "$LIST_FILE" ] || touch "$LIST_FILE"
|
||||
|
||||
URLS_FILE="${TMP_PREFIX}.urls"
|
||||
get_subscription_urls | sed '/^[[:space:]]*$/d' | awk '!seen[$0]++' >"$URLS_FILE"
|
||||
|
||||
url_count="$(wc -l <"$URLS_FILE" 2>/dev/null | tr -d ' ')"
|
||||
if [ -z "$url_count" ] || [ "$url_count" -eq 0 ]; then
|
||||
log_update "未找到订阅链接"
|
||||
log_text "No subscription URL found" "未找到订阅链接"
|
||||
sleep 2
|
||||
log_text "Clash for OpenWRT" "Clash for OpenWRT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_system_dns
|
||||
log_update "开始下载订阅(共 ${url_count} 条)"
|
||||
log_text "Downloading subscription..." "开始下载订阅..."
|
||||
|
||||
base_name="$(sanitize_custom_name "$config_name_raw")"
|
||||
timestamp="$(date +%Y%m%d)"
|
||||
|
||||
success=0
|
||||
failed=0
|
||||
idx=0
|
||||
first_file=""
|
||||
|
||||
while IFS= read -r url; do
|
||||
[ -n "$url" ] || continue
|
||||
idx=$((idx + 1))
|
||||
|
||||
if [ -n "$base_name" ]; then
|
||||
if [ "$url_count" -gt 1 ]; then
|
||||
name_candidate="$(sanitize_name "${base_name}-${idx}")"
|
||||
file_base="$(next_available_name "$name_candidate")"
|
||||
else
|
||||
file_base="$base_name"
|
||||
fi
|
||||
else
|
||||
name_candidate="$(url_to_name "$url")-${timestamp}"
|
||||
if [ "$url_count" -gt 1 ]; then
|
||||
name_candidate="${name_candidate}-${idx}"
|
||||
fi
|
||||
file_base="$(next_available_name "$name_candidate")"
|
||||
fi
|
||||
|
||||
target_file="$SUB_DIR/${file_base}.yaml"
|
||||
if download_subscription "$url" "$target_file"; then
|
||||
upsert_meta "${file_base}.yaml" "$url" "$subtype"
|
||||
log_update "订阅下载成功:${file_base}.yaml"
|
||||
[ -n "$first_file" ] || first_file="$target_file"
|
||||
success=$((success + 1))
|
||||
else
|
||||
log_update "订阅下载失败:${file_base}.yaml"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done <"$URLS_FILE"
|
||||
|
||||
if [ "$success" -gt 0 ]; then
|
||||
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
|
||||
if [ -z "$use_config" ] || [ ! -f "$use_config" ]; then
|
||||
uci set clashoo.config.use_config="$first_file"
|
||||
uci set clashoo.config.config_type='1'
|
||||
uci commit clashoo
|
||||
fi
|
||||
log_text "Subscription download completed: ${success} success, ${failed} failed" "订阅下载完成:成功 ${success} 个,失败 ${failed} 个"
|
||||
log_update "订阅下载完成:成功 ${success} 个,失败 ${failed} 个"
|
||||
ret=0
|
||||
else
|
||||
log_text "All subscription downloads failed" "订阅下载失败"
|
||||
log_update "订阅下载失败:全部链接失败"
|
||||
ret=1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
log_text "Clash for OpenWRT" "Clash for OpenWRT"
|
||||
exit "$ret"
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
#!/bin/sh
|
||||
# Shared DNS helpers for ClashOO runtime generators. Keep POSIX sh compatible.
|
||||
|
||||
dns_trim() {
|
||||
printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
dns_has_scheme() {
|
||||
printf '%s' "$1" | grep -Eq '^[A-Za-z][A-Za-z0-9+.-]*://'
|
||||
}
|
||||
|
||||
dns_norm_protocol() {
|
||||
case "$(dns_trim "${1:-}")" in
|
||||
''|'none') printf '' ;;
|
||||
udp|'udp://') printf 'udp://' ;;
|
||||
tcp|'tcp://') printf 'tcp://' ;;
|
||||
dot|tls|'tls://') printf 'tls://' ;;
|
||||
doh|https|'https://') printf 'https://' ;;
|
||||
doq|quic|'quic://') printf 'quic://' ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
dns_normalize_server() {
|
||||
local address protocol port prefix
|
||||
address=$(dns_trim "${1:-}")
|
||||
protocol=$(dns_trim "${2:-}")
|
||||
port=$(dns_trim "${3:-}")
|
||||
|
||||
[ -n "$address" ] || return 0
|
||||
|
||||
if dns_has_scheme "$address"; then
|
||||
printf '%s' "$address"
|
||||
return 0
|
||||
fi
|
||||
|
||||
prefix=$(dns_norm_protocol "$protocol")
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s%s:%s' "$prefix" "$address" "$port"
|
||||
else
|
||||
printf '%s%s' "$prefix" "$address"
|
||||
fi
|
||||
}
|
||||
|
||||
dns_yaml_sq() {
|
||||
printf "%s" "$1" | sed "s/'/''/g"
|
||||
}
|
||||
|
||||
dns_yaml_list_item() {
|
||||
local value
|
||||
value=$(dns_yaml_sq "$1")
|
||||
printf " - '%s'\n" "$value"
|
||||
}
|
||||
|
||||
# Route DNS upstreams by rules and preserve the original setting.
|
||||
dns_mihomo_apply_respect_rules() {
|
||||
local cfg="$1" enabled="${2:-0}" tmp="${1}.$$"
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
awk -v enabled="$enabled" '
|
||||
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
|
||||
function spaces(n, out,i) {
|
||||
out = ""
|
||||
for (i = 0; i < n; i++)
|
||||
out = out " "
|
||||
return out
|
||||
}
|
||||
function trim_value(s) {
|
||||
sub(/^[[:space:]]*/, "", s)
|
||||
sub(/[[:space:]]*$/, "", s)
|
||||
return s
|
||||
}
|
||||
function flush_dns( i, sp) {
|
||||
if (!in_dns)
|
||||
return
|
||||
sp = spaces(child_indent > 0 ? child_indent : dns_indent + 2)
|
||||
print dns_lines[1]
|
||||
if (enabled == "1") {
|
||||
print sp "# >>> clashoo:dns_leak_protect_respect_rules"
|
||||
print sp "# original-respect-rules: " (original_value != "" ? original_value : "absent")
|
||||
print sp "respect-rules: true"
|
||||
print sp "# <<< clashoo:dns_leak_protect_respect_rules"
|
||||
} else if (original_value != "" && original_value != "absent") {
|
||||
print sp "respect-rules: " original_value
|
||||
}
|
||||
for (i = 2; i <= dns_n; i++)
|
||||
print dns_lines[i]
|
||||
in_dns = 0
|
||||
dns_n = 0
|
||||
}
|
||||
|
||||
/^dns:[[:space:]]*$/ {
|
||||
flush_dns()
|
||||
in_dns = 1
|
||||
dns_indent = indent_len($0)
|
||||
child_indent = -1
|
||||
dns_n = 1
|
||||
dns_lines[dns_n] = $0
|
||||
original_value = ""
|
||||
in_marker = 0
|
||||
next
|
||||
}
|
||||
in_dns {
|
||||
cur_indent = indent_len($0)
|
||||
if ($0 ~ /^[^[:space:]#][^:]*:/ && cur_indent <= dns_indent) {
|
||||
flush_dns()
|
||||
print
|
||||
next
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_respect_rules[[:space:]]*$/) {
|
||||
in_marker = 1
|
||||
if (child_indent < 0) child_indent = cur_indent
|
||||
next
|
||||
}
|
||||
if (in_marker) {
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*original-respect-rules:[[:space:]]*/) {
|
||||
value = $0
|
||||
sub(/^[[:space:]]*#[[:space:]]*original-respect-rules:[[:space:]]*/, "", value)
|
||||
original_value = trim_value(value)
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_respect_rules[[:space:]]*$/)
|
||||
in_marker = 0
|
||||
next
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*respect-rules:[[:space:]]*/) {
|
||||
if (original_value == "") {
|
||||
value = $0
|
||||
sub(/^[[:space:]]*respect-rules:[[:space:]]*/, "", value)
|
||||
original_value = trim_value(value)
|
||||
}
|
||||
if (child_indent < 0) child_indent = cur_indent
|
||||
next
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && cur_indent > dns_indent && child_indent < 0)
|
||||
child_indent = cur_indent
|
||||
dns_lines[++dns_n] = $0
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { flush_dns() }
|
||||
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
}
|
||||
|
||||
# Use fallback DNS for queries; keep bootstrap DNS unchanged.
|
||||
dns_mihomo_apply_leak_nameservers() {
|
||||
local cfg="$1" enabled="${2:-0}" state="${1}.dns-leak-nameserver"
|
||||
local selected="${1}.dns-leak-selected" current="${1}.dns-leak-current" tmp="${1}.$$"
|
||||
[ -f "$cfg" ] || return 0
|
||||
command -v yq >/dev/null 2>&1 || return 0
|
||||
|
||||
if [ "$enabled" = "1" ]; then
|
||||
yq e -r '.dns.fallback[]' "$cfg" > "$selected" 2>/dev/null || true
|
||||
if [ ! -s "$selected" ]; then
|
||||
printf '%s\n' 'https://1.1.1.1/dns-query' 'https://8.8.8.8/dns-query' > "$selected"
|
||||
fi
|
||||
yq e -r '.dns.nameserver[]' "$cfg" > "$current" 2>/dev/null || true
|
||||
|
||||
if [ ! -f "$state" ] ||
|
||||
! grep -q 'clashoo:dns_leak_protect_respect_rules' "$cfg" ||
|
||||
! cmp -s "$current" "$selected"; then
|
||||
awk '
|
||||
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
|
||||
/^dns:[[:space:]]*$/ { in_dns = 1; dns_indent = indent_len($0); next }
|
||||
in_dns && /^[^[:space:]#][^:]*:/ { exit }
|
||||
in_dns && /^[[:space:]]*nameserver:/ {
|
||||
print
|
||||
rest = $0
|
||||
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
|
||||
sub(/[[:space:]]*(#.*)?$/, "", rest)
|
||||
if (rest == "") { in_ns = 1; ns_indent = indent_len($0) }
|
||||
next
|
||||
}
|
||||
in_ns {
|
||||
cur_indent = indent_len($0)
|
||||
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) {
|
||||
print
|
||||
next
|
||||
}
|
||||
exit
|
||||
}
|
||||
' "$cfg" > "$state"
|
||||
[ -s "$state" ] || printf '__ABSENT__\n' > "$state"
|
||||
fi
|
||||
|
||||
awk -v selected="$selected" '
|
||||
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
|
||||
function spaces(n, out,i) {
|
||||
out = ""
|
||||
for (i = 0; i < n; i++) out = out " "
|
||||
return out
|
||||
}
|
||||
function print_selected(sp, line) {
|
||||
print sp "nameserver:"
|
||||
while ((getline line < selected) > 0)
|
||||
if (line != "") print sp " - " line
|
||||
close(selected)
|
||||
inserted = 1
|
||||
}
|
||||
/^dns:[[:space:]]*$/ {
|
||||
in_dns = 1
|
||||
dns_indent = indent_len($0)
|
||||
child_indent = -1
|
||||
print
|
||||
next
|
||||
}
|
||||
in_dns && /^[^[:space:]#][^:]*:/ {
|
||||
if (!inserted) print_selected(spaces(child_indent > 0 ? child_indent : dns_indent + 2))
|
||||
in_dns = 0
|
||||
print
|
||||
next
|
||||
}
|
||||
in_dns && /^[[:space:]]*[^#[:space:]][^:]*:/ && child_indent < 0 {
|
||||
child_indent = indent_len($0)
|
||||
}
|
||||
in_dns && /^[[:space:]]*nameserver:/ {
|
||||
ns_indent = indent_len($0)
|
||||
rest = $0
|
||||
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
|
||||
sub(/[[:space:]]*(#.*)?$/, "", rest)
|
||||
print_selected(spaces(ns_indent))
|
||||
if (rest == "") skip_ns = 1
|
||||
next
|
||||
}
|
||||
skip_ns {
|
||||
cur_indent = indent_len($0)
|
||||
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) next
|
||||
skip_ns = 0
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (in_dns && !inserted)
|
||||
print_selected(spaces(child_indent > 0 ? child_indent : dns_indent + 2))
|
||||
}
|
||||
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
elif [ -f "$state" ]; then
|
||||
awk -v state="$state" '
|
||||
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
|
||||
function print_original( line) {
|
||||
if ((getline line < state) > 0 && line != "__ABSENT__") {
|
||||
print line
|
||||
while ((getline line < state) > 0) print line
|
||||
}
|
||||
close(state)
|
||||
restored = 1
|
||||
}
|
||||
/^dns:[[:space:]]*$/ { in_dns = 1; print; next }
|
||||
in_dns && /^[^[:space:]#][^:]*:/ {
|
||||
if (!restored) print_original()
|
||||
in_dns = 0
|
||||
print
|
||||
next
|
||||
}
|
||||
in_dns && /^[[:space:]]*nameserver:/ {
|
||||
ns_indent = indent_len($0)
|
||||
rest = $0
|
||||
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
|
||||
sub(/[[:space:]]*(#.*)?$/, "", rest)
|
||||
print_original()
|
||||
if (rest == "") skip_ns = 1
|
||||
next
|
||||
}
|
||||
skip_ns {
|
||||
cur_indent = indent_len($0)
|
||||
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) next
|
||||
skip_ns = 0
|
||||
}
|
||||
{ print }
|
||||
END { if (in_dns && !restored) print_original() }
|
||||
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
rm -f "$state"
|
||||
fi
|
||||
rm -f "$selected" "$current" "$tmp" 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
# handle mihomo dns block:
|
||||
# - strip previous clashoo:dns_leak_protect injection markers (incl. the old
|
||||
# geosite:gfw block written by older versions)
|
||||
# - rewrite the ipv6: line from ipv6_value (skip when empty)
|
||||
# No longer injects fallback-filter.geosite:gfw — mihomo deprecated it and a
|
||||
# GeoSite.dat missing the gfw category froze startup (issue #25). See flush_dns.
|
||||
# (The fallback-filter parsing vars below are now vestigial; clean up separately.)
|
||||
dns_mihomo_apply_leak_dns_block() {
|
||||
local cfg="$1" enabled="${2:-0}" ipv6_value="${3:-}" tmp="${1}.$$"
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
awk -v enabled="$enabled" -v ipv6_value="$ipv6_value" '
|
||||
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
|
||||
function spaces(n, out,i) {
|
||||
out = ""
|
||||
for (i = 0; i < n; i++)
|
||||
out = out " "
|
||||
return out
|
||||
}
|
||||
function flush_dns( i, sp, ff_sp) {
|
||||
if (!in_dns)
|
||||
return
|
||||
if (in_ff) ff_end = dns_n
|
||||
sp = spaces(child_indent > 0 ? child_indent : dns_indent + 2)
|
||||
ff_sp = (ff_child_indent > 0 ? spaces(ff_child_indent) : sp " ")
|
||||
|
||||
print dns_lines[1]
|
||||
if (ipv6_value != "")
|
||||
print sp "ipv6: " ipv6_value
|
||||
# NOTE: clashoo used to inject `fallback-filter.geosite: [gfw]` here, but
|
||||
# mihomo deprecated fallback-filter.geosite ("replace with nameserver-policy,
|
||||
# will be removed") AND it hard-fails preflight + freezes startup (~90s) when
|
||||
# the downloaded GeoSite.dat lacks the gfw category (issue #25). Stop
|
||||
# injecting it. DNS-leak protection still works via respect-rules / leak
|
||||
# nameservers / DST-PORT,853,REJECT handled elsewhere. The marker strip below
|
||||
# still removes any gfw block injected by older versions, so disabling /
|
||||
# upgrading cleans it up.
|
||||
for (i = 2; i <= dns_n; i++)
|
||||
print dns_lines[i]
|
||||
in_dns = 0
|
||||
dns_n = 0
|
||||
}
|
||||
|
||||
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect[[:space:]]*$/ {
|
||||
in_marker = 1
|
||||
next
|
||||
}
|
||||
in_marker {
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect[[:space:]]*$/)
|
||||
in_marker = 0
|
||||
next
|
||||
}
|
||||
|
||||
/^dns:[[:space:]]*$/ {
|
||||
flush_dns()
|
||||
in_dns = 1
|
||||
dns_indent = indent_len($0)
|
||||
child_indent = -1
|
||||
dns_n = 1
|
||||
dns_lines[dns_n] = $0
|
||||
has_ff = 0
|
||||
in_ff = 0
|
||||
in_ff_geosite = 0
|
||||
ff_indent = -1
|
||||
ff_child_indent = -1
|
||||
ff_geosite_indent = -1
|
||||
ff_has_gfw = 0
|
||||
ff_end = -1
|
||||
next
|
||||
}
|
||||
in_dns {
|
||||
cur_indent = indent_len($0)
|
||||
if ($0 ~ /^[^[:space:]#][^:]*:/ && cur_indent <= dns_indent) {
|
||||
flush_dns()
|
||||
print
|
||||
next
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && cur_indent > dns_indent && child_indent < 0)
|
||||
child_indent = cur_indent
|
||||
if (in_ff && cur_indent <= ff_indent) {
|
||||
ff_end = dns_n
|
||||
in_ff = 0
|
||||
in_ff_geosite = 0
|
||||
}
|
||||
if (in_ff_geosite && cur_indent <= ff_geosite_indent)
|
||||
in_ff_geosite = 0
|
||||
if ($0 ~ /^[[:space:]]*fallback-filter:[[:space:]]*$/) {
|
||||
has_ff = 1
|
||||
in_ff = 1
|
||||
ff_indent = cur_indent
|
||||
ff_child_indent = -1
|
||||
} else if (in_ff && $0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && ff_child_indent < 0) {
|
||||
ff_child_indent = cur_indent
|
||||
}
|
||||
if (in_ff && $0 ~ /^[[:space:]]*geosite:[[:space:]]*$/) {
|
||||
in_ff_geosite = 1
|
||||
ff_geosite_indent = cur_indent
|
||||
} else if (in_ff_geosite && $0 ~ /^[[:space:]]*-[[:space:]]*gfw([[:space:]]*(#.*)?)?$/) {
|
||||
ff_has_gfw = 1
|
||||
}
|
||||
if ($0 ~ /^[[:space:]]*ipv6:[[:space:]]*/)
|
||||
next
|
||||
dns_lines[++dns_n] = $0
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { flush_dns() }
|
||||
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
}
|
||||
|
||||
# handle DST-PORT,853,REJECT injection into rules block
|
||||
# only remove Clashoo marker block, never user original rules
|
||||
dns_mihomo_apply_leak_rule() {
|
||||
local cfg="$1" enabled="${2:-0}" tmp="${1}.$$" has_user_853
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
has_user_853=$(awk '
|
||||
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/ {
|
||||
in_marker = 1
|
||||
next
|
||||
}
|
||||
in_marker {
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/)
|
||||
in_marker = 0
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*-[[:space:]]*DST-PORT,853,REJECT([[:space:]]*(#.*)?)?$/ { found = 1 }
|
||||
END { print found ? 1 : 0 }
|
||||
' "$cfg")
|
||||
|
||||
awk -v enabled="$enabled" -v has_user_853="$has_user_853" '
|
||||
BEGIN { inserted = 0; saw_rules = 0 }
|
||||
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/ {
|
||||
in_marker = 1
|
||||
next
|
||||
}
|
||||
in_marker {
|
||||
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/)
|
||||
in_marker = 0
|
||||
next
|
||||
}
|
||||
/^rules:[[:space:]]*$/ {
|
||||
saw_rules = 1
|
||||
print
|
||||
if (enabled == "1" && has_user_853 != "1") {
|
||||
print " # >>> clashoo:dns_leak_protect_rule"
|
||||
print " - DST-PORT,853,REJECT"
|
||||
print " # <<< clashoo:dns_leak_protect_rule"
|
||||
inserted = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (enabled == "1" && has_user_853 != "1" && !inserted && !saw_rules) {
|
||||
print ""
|
||||
print "rules:"
|
||||
print " # >>> clashoo:dns_leak_protect_rule"
|
||||
print " - DST-PORT,853,REJECT"
|
||||
print " # <<< clashoo:dns_leak_protect_rule"
|
||||
}
|
||||
}
|
||||
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
}
|
||||
|
||||
dns_mihomo_apply_leak_protect() {
|
||||
local cfg="$1" enabled="${2:-0}" ipv6_value="${3:-}"
|
||||
[ -f "$cfg" ] || return 0
|
||||
dns_mihomo_apply_leak_nameservers "$cfg" "$enabled"
|
||||
dns_mihomo_apply_respect_rules "$cfg" "$enabled"
|
||||
dns_mihomo_apply_leak_dns_block "$cfg" "$enabled" "$ipv6_value"
|
||||
dns_mihomo_apply_leak_rule "$cfg" "$enabled"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
. as $document |
|
||||
strenv(CLASHOO_DNS_OLD_PORT) as $old_port |
|
||||
strenv(CLASHOO_DNS_HOST_PATTERN) as $host_pattern |
|
||||
(strenv(CLASHOO_DNS_REPLACEMENT) | split(" ") | map(select(length > 0))) as $replacement |
|
||||
("(?i)^(?:[a-z][a-z0-9+.-]*://)?(?:" + $host_pattern + "):" + $old_port + "(?:$|[/#?])") as $old_loopback |
|
||||
(
|
||||
$document |
|
||||
select((.dns."proxy-server-nameserver" | type) == "!!seq") |
|
||||
([.dns."proxy-server-nameserver"[] | select(type == "!!str" and test($old_loopback))] | length) as $match_count |
|
||||
select($match_count > 0) |
|
||||
.dns."proxy-server-nameserver" =
|
||||
([
|
||||
.dns."proxy-server-nameserver"[] |
|
||||
select((type == "!!str" and test($old_loopback)) | not) |
|
||||
select(. as $server | ($replacement | contains([$server])) | not)
|
||||
] + $replacement)
|
||||
) // $document
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#!/bin/sh
|
||||
. /lib/functions.sh
|
||||
|
||||
|
||||
TMP_PREFIX="/tmp/clashoo_iprules.$$"
|
||||
CUSTOM_RULE_FILE="${TMP_PREFIX}.ipadd.conf"
|
||||
RULE="${TMP_PREFIX}.rules_conf.yaml"
|
||||
CLASH="${TMP_PREFIX}.conf.yaml"
|
||||
CONFIG_YAML="/etc/clashoo/config.yaml"
|
||||
CLASH_CONFIG="${TMP_PREFIX}.config.yaml"
|
||||
trap 'rm -f "$CUSTOM_RULE_FILE" "$RULE" "$CLASH" "$CLASH_CONFIG"' EXIT
|
||||
|
||||
[ -f "$CONFIG_YAML" ] || exit 0
|
||||
|
||||
. /usr/share/clashoo/runtime/primary_group.sh
|
||||
|
||||
|
||||
# __PROXY__ placeholder -> primary proxy group (cached by RPC);
|
||||
# fallback to GLOBAL (built-in mihomo group)
|
||||
CACHED_PRIMARY_GROUP="$(uci -q get clashoo.config.primary_proxy_group)"
|
||||
PRIMARY_GROUP="$(clashoo_resolve_primary_group "$CONFIG_YAML" "$CACHED_PRIMARY_GROUP")"
|
||||
[ -n "$PRIMARY_GROUP" ] || PRIMARY_GROUP="GLOBAL"
|
||||
|
||||
append=$(uci get clashoo.config.append_rules 2>/dev/null)
|
||||
if [ "${append:-0}" -eq 1 ];then
|
||||
|
||||
if [ -f $CLASH_CONFIG ];then
|
||||
rm -rf $CLASH_CONFIG 2>/dev/null
|
||||
fi
|
||||
|
||||
cp $CONFIG_YAML $CLASH_CONFIG 2>/dev/null
|
||||
if [ ! -z "$(grep "^Rule:" "$CLASH_CONFIG")" ]; then
|
||||
sed -i "/^Rule:/i\#RULESTART#" $CLASH_CONFIG 2>/dev/null
|
||||
elif [ ! -z "$(grep "^rules:" "$CLASH_CONFIG")" ]; then
|
||||
sed -i "/^rules:/i\#RULESTART#" $CLASH_CONFIG 2>/dev/null
|
||||
fi
|
||||
sed -i -e "\$a#RULEEND#" $CLASH_CONFIG 2>/dev/null
|
||||
|
||||
awk '/#RULESTART#/,/#RULEEND#/{print}' "$CLASH_CONFIG" 2>/dev/null |sed "s/\'//g" 2>/dev/null |sed 's/\"//g' 2>/dev/null |sed 's/\t/ /g' 2>/dev/null |grep '^ \{0,\}- '|awk -F '- ' '{print "- "$2}' | sed 's/^ \{0,\}//' 2>/dev/null |sed 's/ \{0,\}$//' 2>/dev/null >$RULE 2>&1
|
||||
|
||||
sed -i '/#RULESTART#/,/#RULEEND#/d' "$CLASH_CONFIG" 2>/dev/null
|
||||
|
||||
sed -i -e "\$a " $CLASH_CONFIG 2>/dev/null
|
||||
sed -i "1i\rules:" $RULE 2>/dev/null
|
||||
cat $CLASH_CONFIG $RULE >$CLASH 2>/dev/null
|
||||
mv $CLASH $CONFIG_YAML 2>/dev/null
|
||||
rm -f $RULE $CLASH_CONFIG 2>/dev/null
|
||||
fi
|
||||
|
||||
# ===== custom routing rules (UCI: config addtype), decoupled =====
|
||||
# fields: type(DOMAIN|SUFFIX|KEYWORD|IP-CIDR), ipaaddr, pgroup, res(no-resolve)
|
||||
# pgroup: DIRECT or __PROXY__ (resolved at injection)
|
||||
rm -f "$CUSTOM_RULE_FILE" 2>/dev/null
|
||||
|
||||
ipadd()
|
||||
{
|
||||
local section="$1"
|
||||
config_get "pgroup" "$section" "pgroup" ""
|
||||
config_get "ipaaddr" "$section" "ipaaddr" ""
|
||||
config_get "type" "$section" "type" ""
|
||||
config_get "res" "$section" "res" ""
|
||||
|
||||
[ -z "$type" ] && return
|
||||
[ -z "$ipaaddr" ] && return
|
||||
[ -z "$pgroup" ] && return
|
||||
|
||||
[ "$pgroup" = "__PROXY__" ] && pgroup="$PRIMARY_GROUP"
|
||||
|
||||
# quote whole rule (group name may contain spaces/emoji)
|
||||
# so mixed indent with subscription rules does not break YAML parsing
|
||||
if [ "${res}" = "1" ];then
|
||||
echo "${RULE_INDENT}- \"$type,$ipaaddr,$pgroup,no-resolve\"" >> "$CUSTOM_RULE_FILE"
|
||||
else
|
||||
echo "${RULE_INDENT}- \"$type,$ipaaddr,$pgroup\"" >> "$CUSTOM_RULE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# detect existing rules indent for alignment (default 2)
|
||||
RULE_INDENT="$(awk '/^[[:space:]]*rules:/{f=1;next} f&&/^[[:space:]]*-/{match($0,/^[[:space:]]*/);print substr($0,1,RLENGTH);exit}' "$CONFIG_YAML" 2>/dev/null)"
|
||||
[ -n "$RULE_INDENT" ] || RULE_INDENT=" "
|
||||
|
||||
config_load "clashoo"
|
||||
config_foreach ipadd "addtype"
|
||||
|
||||
# clean previous injection block (idempotent)
|
||||
sed -i '/#CUSTOMRULESTART#/,/#CUSTOMRULEEND#/d' "$CONFIG_YAML" 2>/dev/null
|
||||
|
||||
if [ -f "$CUSTOM_RULE_FILE" ];then
|
||||
sed -i -e "\$a#CUSTOMRULEEND#" "$CUSTOM_RULE_FILE" 2>/dev/null
|
||||
if [ ! -z "$(grep "^ \{0,\}rules:" "$CONFIG_YAML")" ]; then
|
||||
# 插在 rules: 段最前面,优先于订阅自带规则命中(纠错)
|
||||
sed -i '/^ \{0,\}rules:/a\#CUSTOMRULESTART#' "$CONFIG_YAML" 2>/dev/null
|
||||
else
|
||||
echo "rules:" >> "$CONFIG_YAML" 2>/dev/null
|
||||
echo "#CUSTOMRULESTART#" >> "$CONFIG_YAML" 2>/dev/null
|
||||
fi
|
||||
sed -i "/#CUSTOMRULESTART#/r${CUSTOM_RULE_FILE}" "$CONFIG_YAML" 2>/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/ucode
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
const uci = cursor();
|
||||
|
||||
function b(v) {
|
||||
if (v == null || v == '' || v == '0' || v == 'false') return false;
|
||||
if (v == '1' || v == 'true') return true;
|
||||
return v;
|
||||
}
|
||||
function i(v, d) { let n = v != null ? int(v) : null; return n != null ? n : d; }
|
||||
function s(v, d) { let t = trim(v || ''); return length(t) ? t : d; }
|
||||
function a(k) { return uci.get('clashoo', 'config', k); }
|
||||
function ab(k) { return b(a(k)); }
|
||||
|
||||
const cfg = {};
|
||||
|
||||
/* ── 端口与基本设置 ─────────────────────────────────────── */
|
||||
let v;
|
||||
v = i(a('http_port')); if (v != null) cfg['port'] = v;
|
||||
v = i(a('socks_port')); if (v != null) cfg['socks-port'] = v;
|
||||
v = i(a('redir_port')); if (v != null) cfg['redir-port'] = v;
|
||||
v = i(a('mixed_port')); if (v != null) cfg['mixed-port'] = v;
|
||||
v = i(a('tproxy_port')); if (v != null) cfg['tproxy-port'] = v;
|
||||
|
||||
v = a('bind_addr');
|
||||
if (!v || v == '*') v = null;
|
||||
if (v) cfg['bind-address'] = v;
|
||||
|
||||
v = a('p_mode');
|
||||
if (v == 'script') v = 'rule';
|
||||
if (v) cfg['mode'] = v;
|
||||
|
||||
cfg['log-level'] = s(a('level'), 'info');
|
||||
cfg['allow-lan'] = ab('allow_lan');
|
||||
cfg['ipv6'] = ab('enable_ipv6');
|
||||
cfg['routing-mark'] = 6666;
|
||||
cfg['external-controller'] = '0.0.0.0:' + i(a('dash_port'), 9090);
|
||||
cfg['external-ui'] = './dashboard';
|
||||
cfg['secret'] = s(a('dash_pass'), '');
|
||||
|
||||
v = a('interf');
|
||||
if (v && v != '0') cfg['interface-name'] = v;
|
||||
|
||||
/* ── TUN ───────────────────────────────────────────────── */
|
||||
let tun_mode = i(a('tun_mode'), 0);
|
||||
let tcp_mode = s(a('tcp_mode'), 'redirect');
|
||||
let udp_mode = s(a('udp_mode'), 'tproxy');
|
||||
let tun_enabled = (tun_mode == 1 || tcp_mode == 'tun' || udp_mode == 'tun');
|
||||
let acl_enabled = false;
|
||||
uci.foreach('clashoo', 'lan_acl', function(sec) {
|
||||
if (b(sec.enabled) != false) acl_enabled = true;
|
||||
});
|
||||
if (!ab('acl_migrated')) {
|
||||
let access_control = i(a('access_control'), 0);
|
||||
acl_enabled = access_control == 1 || access_control == 2;
|
||||
}
|
||||
let tun_acl = tun_enabled && acl_enabled;
|
||||
|
||||
cfg['tun'] = {
|
||||
enable: tun_enabled,
|
||||
stack: s(a('stack'), 'gvisor'),
|
||||
'auto-route': true,
|
||||
'auto-redirect': !tun_acl,
|
||||
'auto-detect-interface': true,
|
||||
};
|
||||
let tun_mtu = a('tun_mtu');
|
||||
if (tun_mtu != null) cfg['tun']['mtu'] = int(tun_mtu);
|
||||
if (ab('tun_gso')) {
|
||||
cfg['tun']['gso'] = true;
|
||||
cfg['tun']['gso-max-size'] = i(a('tun_gso_max_size'), 65536);
|
||||
}
|
||||
if (ab('tun_dns_hijack')) {
|
||||
cfg['tun']['dns-hijack'] = ['any:53', 'tcp://any:53'];
|
||||
}
|
||||
if (!tun_enabled) {
|
||||
cfg['tun']['enable'] = false;
|
||||
}
|
||||
|
||||
/* ── DNS ───────────────────────────────────────────────── */
|
||||
let dns_port = i(a('listen_port'), 1053);
|
||||
cfg['dns'] = {
|
||||
enable: b(a('enable_dns')) != false,
|
||||
listen: '0.0.0.0:' + dns_port,
|
||||
'enhanced-mode': s(a('enhanced_mode'), 'fake-ip'),
|
||||
'fake-ip-range': s(a('fake_ip_range'), '198.18.0.1/16'),
|
||||
'fake-ip-filter': [],
|
||||
ipv6: ab('enable_ipv6'),
|
||||
};
|
||||
if (cfg['dns']['enhanced-mode'] == 'fake-ip' && ab('enable_ipv6'))
|
||||
cfg['dns']['fake-ip-range6'] = s(a('fake_ip_range6'), 'fc00::/18');
|
||||
/* fake-ip-filter-mode: blacklist (default) | whitelist | rule */
|
||||
let filter_mode = s(a('fake_ip_filter_mode'), 'blacklist');
|
||||
if (filter_mode != 'blacklist') cfg['dns']['fake-ip-filter-mode'] = filter_mode;
|
||||
|
||||
/* map geosite:cn to bundled cn.mrs rule-set so mihomo skips the 10MB geosite.dat */
|
||||
let need_cn_rs = false;
|
||||
let keep_fakeip = length(s(getenv('CLASHOO_KEEP_FAKEIP_FILTER'), ''));
|
||||
if (keep_fakeip) {
|
||||
delete cfg['dns']['fake-ip-filter'];
|
||||
} else {
|
||||
let push_filter = function(f) {
|
||||
f = trim(f || '');
|
||||
if (!length(f)) return;
|
||||
if (filter_mode == 'rule') {
|
||||
if (match(f, /,(fake-ip|real-ip)$/)) { push(cfg['dns']['fake-ip-filter'], f); return; }
|
||||
if (index(f, ',') >= 0) { push(cfg['dns']['fake-ip-filter'], f + ',real-ip'); return; }
|
||||
if (f == 'geosite:cn') { f = 'RULE-SET,cn_domain,real-ip'; need_cn_rs = true; }
|
||||
else if (match(f, /^geosite:/)) { f = 'GEOSITE,' + substr(f, 8) + ',real-ip'; }
|
||||
else if (match(f, /^rule-set:/)) {
|
||||
let rs = substr(f, 9);
|
||||
if (rs == 'cn_domain') need_cn_rs = true;
|
||||
f = 'RULE-SET,' + rs + ',real-ip';
|
||||
}
|
||||
else if (match(f, /^\+\./)) { f = 'DOMAIN-SUFFIX,' + substr(f, 2) + ',real-ip'; }
|
||||
else if (match(f, /\*/)) { f = 'DOMAIN-WILDCARD,' + f + ',real-ip'; }
|
||||
else { f = 'DOMAIN,' + f + ',real-ip'; }
|
||||
} else {
|
||||
if (f == 'geosite:cn') { f = 'rule-set:cn_domain'; need_cn_rs = true; }
|
||||
else if (f == 'rule-set:cn_domain') need_cn_rs = true;
|
||||
}
|
||||
push(cfg['dns']['fake-ip-filter'], f);
|
||||
};
|
||||
let filters = a('fake_ip_filter');
|
||||
if (type(filters) == 'array') { for (let f in filters) push_filter(f); }
|
||||
else if (filters != null) push_filter(filters);
|
||||
}
|
||||
|
||||
/* fallback-filter(默认 geoip:false,防止冷启动依赖 MMDB) */
|
||||
cfg['dns']['fallback-filter'] = { geoip: ab('fallback_filter_geoip') };
|
||||
|
||||
let dns_present = {};
|
||||
for (let f in split(s(getenv('CLASHOO_DNS_PRESENT'), ''), ','))
|
||||
if (length(trim(f))) dns_present[trim(f)] = true;
|
||||
let dns_force = {};
|
||||
for (let f in split(s(getenv('CLASHOO_DNS_FORCE'), ''), ','))
|
||||
if (length(trim(f))) dns_force[trim(f)] = true;
|
||||
let dns_inherit_mode = s(getenv('CLASHOO_DNS_INHERIT_MODE'), '');
|
||||
let inherited_nameserver = json(s(getenv('CLASHOO_DNS_INHERIT_NAMESERVER'), '[]'));
|
||||
if (type(inherited_nameserver) != 'array') {
|
||||
if (inherited_nameserver != null && inherited_nameserver != '')
|
||||
inherited_nameserver = [inherited_nameserver];
|
||||
else
|
||||
inherited_nameserver = [];
|
||||
}
|
||||
let dns_role_fields = ['nameserver', 'proxy-server-nameserver', 'direct-nameserver',
|
||||
'default-nameserver', '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;
|
||||
if (length(keys(dns_force)) || !had_user_dns_roles)
|
||||
cfg['dns']['x-clashoo-managed-dns'] = true;
|
||||
|
||||
function dns_scheme(protocol) {
|
||||
switch (trim(protocol || '')) {
|
||||
case '':
|
||||
case 'none': return '';
|
||||
case 'udp': case 'udp://': return 'udp://';
|
||||
case 'tcp': case 'tcp://': return 'tcp://';
|
||||
case 'dot': case 'tls': case 'tls://': return 'tls://';
|
||||
case 'doh': case 'https': case 'https://': return 'https://';
|
||||
case 'doq': case 'quic': case 'quic://': return 'quic://';
|
||||
default: return protocol;
|
||||
}
|
||||
}
|
||||
|
||||
function dns_server(address, protocol, port) {
|
||||
let addr = trim(address || '');
|
||||
if (!length(addr)) return null;
|
||||
if (match(addr, /^[A-Za-z][A-Za-z0-9+.-]*:\/\//)) return addr;
|
||||
let prefix = dns_scheme(protocol);
|
||||
return length(trim(port || '')) ? prefix + addr + ':' + trim(port) : prefix + addr;
|
||||
}
|
||||
|
||||
let dns_roles = {};
|
||||
uci.foreach('clashoo', 'dnsservers', function(sec) {
|
||||
if (b(sec.enabled) == false) return;
|
||||
let role = s(sec.ser_type, 'nameserver');
|
||||
if (role == 'fallback' && cfg['dns']['enhanced-mode'] == 'fake-ip') return;
|
||||
let srv = dns_server(sec.ser_address, sec.protocol, sec.ser_port);
|
||||
if (!srv) return;
|
||||
if (!dns_roles[role]) dns_roles[role] = [];
|
||||
push(dns_roles[role], srv);
|
||||
});
|
||||
for (let role in keys(dns_roles)) {
|
||||
if (role == 'proxy-server-nameserver' && (dns_inherit_mode == 'inherit' || dns_inherit_mode == 'skip') &&
|
||||
!dns_present[role] && !dns_force[role]) continue;
|
||||
if (!dns_present[role] || dns_force[role]) cfg['dns'][role] = dns_roles[role];
|
||||
}
|
||||
|
||||
if (dns_inherit_mode == 'inherit' && length(inherited_nameserver) &&
|
||||
!dns_present['proxy-server-nameserver'] && !dns_force['proxy-server-nameserver'])
|
||||
cfg['dns']['proxy-server-nameserver'] = inherited_nameserver;
|
||||
|
||||
let bootstrap = a('default_nameserver');
|
||||
if (bootstrap == null) bootstrap = a('defaul_nameserver');
|
||||
if (type(bootstrap) != 'array') bootstrap = bootstrap != null ? [bootstrap] : [];
|
||||
if (length(bootstrap) && (!dns_present['default-nameserver'] || dns_force['default-nameserver']))
|
||||
cfg['dns']['default-nameserver'] = bootstrap;
|
||||
|
||||
let 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 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 (length(keys(policies)) && (!dns_present['nameserver-policy'] || dns_force['nameserver-policy']))
|
||||
cfg['dns']['nameserver-policy'] = policies;
|
||||
|
||||
let respect_rules = a('dns_respect_rules') == null ? true : ab('dns_respect_rules');
|
||||
if (dns_force['respect-rules'])
|
||||
cfg['dns']['respect-rules'] = respect_rules;
|
||||
else if (respect_rules && length(cfg['dns']['proxy-server-nameserver'] || [])
|
||||
&& !dns_present['respect-rules'] && !dns_present['prefer-h3'])
|
||||
cfg['dns']['respect-rules'] = true;
|
||||
|
||||
/* profile */
|
||||
let store_selected = ab('selection_cache');
|
||||
/* fake-ip cache defaults ON to survive restarts; opt out with '0' */
|
||||
let store_fake = a('fake_ip_cache') == null ? true : ab('fake_ip_cache');
|
||||
if (store_selected || store_fake) {
|
||||
cfg['profile'] = {};
|
||||
if (store_selected) cfg['profile']['store-selected'] = true;
|
||||
if (store_fake) cfg['profile']['store-fake-ip'] = true;
|
||||
}
|
||||
|
||||
/* bundled cn.mrs rule-provider, referenced by fake-ip-filter rule-set:cn_domain */
|
||||
if (need_cn_rs) {
|
||||
if (!cfg['rule-providers']) cfg['rule-providers'] = {};
|
||||
cfg['rule-providers']['cn_domain'] = {
|
||||
type: 'file',
|
||||
path: './ruleset/cn.mrs',
|
||||
format: 'mrs',
|
||||
behavior: 'domain',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── authentication ────────────────────────────────── */
|
||||
if (ab('authentication')) {
|
||||
cfg['authentication'] = [];
|
||||
uci.foreach('clashoo', 'authentication', function(sec) {
|
||||
if (b(sec.enabled) == false) return;
|
||||
let user = trim(sec.username || '');
|
||||
let pass = trim(sec.password || '');
|
||||
if (user) push(cfg['authentication'], user + ':' + pass);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── hosts ────────────────────────────────────────── */
|
||||
cfg['hosts'] = {};
|
||||
uci.foreach('clashoo', 'hosts', function(sec) {
|
||||
if (b(sec.enabled) == false) return;
|
||||
let domain = trim(sec.adress || '');
|
||||
let ip = trim(sec.ip || '');
|
||||
if (domain && ip) cfg['hosts'][domain] = ip;
|
||||
});
|
||||
if (length(keys(cfg['hosts'])) == 0) delete cfg['hosts'];
|
||||
|
||||
/* ── sniffer ──────────────────────────────────────── */
|
||||
if (ab('sniffer_streaming') && !length(s(getenv('CLASHOO_KEEP_SNIFFER'), ''))) {
|
||||
cfg['sniffer'] = {
|
||||
enable: true,
|
||||
'force-dns-mapping': true,
|
||||
'parse-pure-ip': true,
|
||||
sniff: {
|
||||
HTTP: { ports: [80, 8080], 'override-destination': true },
|
||||
TLS: { ports: [443, 8443], 'override-destination': true },
|
||||
QUIC: { ports: [443, 8443], 'override-destination': true },
|
||||
},
|
||||
'force-domain': [
|
||||
'+.youtube.com',
|
||||
'+.googlevideo.com',
|
||||
'+.netflix.com',
|
||||
'+.nflxvideo.net',
|
||||
'+.disneyplus.com',
|
||||
'+.hulu.com',
|
||||
'+.hbomax.com',
|
||||
],
|
||||
'skip-domain': [
|
||||
'+.lan',
|
||||
'+.local',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Smart kernel injection ───────────────────────── */
|
||||
if (ab('smart_auto_switch')) {
|
||||
/* Smart 策略注入:通过 proxy-groups 覆盖实现 */
|
||||
}
|
||||
|
||||
/* ── ECS ──────────────────────────────────────────── */
|
||||
let ecs = s(a('dns_ecs'));
|
||||
if (ecs) cfg['dns']['edns-client-subnet'] = ecs;
|
||||
|
||||
/* ── cache_file / experimental ──────────────────── */
|
||||
cfg['experimental'] = {
|
||||
'cache_file': { enabled: true },
|
||||
'clash_api': {
|
||||
'external-controller': '0.0.0.0:' + i(a('dash_port'), 9090),
|
||||
'external-ui': './dashboard',
|
||||
'secret': s(a('dash_pass'), ''),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/* ── 输出 JSON (不含 null/空对象) ─────────────────── */
|
||||
function clean(obj) {
|
||||
if (type(obj) == 'array') {
|
||||
let r = [];
|
||||
for (let e in obj) {
|
||||
e = clean(e);
|
||||
if (e != null) push(r, e);
|
||||
}
|
||||
return length(r) ? r : null;
|
||||
}
|
||||
if (type(obj) == 'object') {
|
||||
let r = {};
|
||||
for (let k in keys(obj)) {
|
||||
let v = clean(obj[k]);
|
||||
if (v != null) r[k] = v;
|
||||
}
|
||||
return length(keys(r)) ? r : null;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
print(clean(cfg));
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
|
||||
clashoo_resolve_primary_group() {
|
||||
local config_yaml="$1"
|
||||
local cached_group="$2"
|
||||
local group_rows
|
||||
local resolved_group
|
||||
|
||||
if [ "$cached_group" = "GLOBAL" ]; then
|
||||
printf '%s\n' "GLOBAL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$config_yaml" ] || ! command -v yq >/dev/null 2>&1; then
|
||||
printf '%s\n' "GLOBAL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
group_rows="$(yq -M e -r '(."proxy-groups" // [])[] | [.name, .type] | @tsv' "$config_yaml" 2>/dev/null)" || {
|
||||
printf '%s\n' "GLOBAL"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ -n "$cached_group" ] && printf '%s\n' "$group_rows" |
|
||||
awk -F '\t' -v wanted="$cached_group" '$1 == wanted { found = 1; exit } END { exit !found }'
|
||||
then
|
||||
printf '%s\n' "$cached_group"
|
||||
return 0
|
||||
fi
|
||||
|
||||
resolved_group="$(printf '%s\n' "$group_rows" | awk -F '\t' '
|
||||
tolower($2) == "select" &&
|
||||
(index($1, "节点选择") || index($1, "代理") || index($1, "🚀") || tolower($1) ~ /proxy/) {
|
||||
print $1
|
||||
exit
|
||||
}
|
||||
')"
|
||||
|
||||
if [ -z "$resolved_group" ]; then
|
||||
resolved_group="$(printf '%s\n' "$group_rows" | awk -F '\t' '
|
||||
tolower($2) == "select" {
|
||||
print $1
|
||||
exit
|
||||
}
|
||||
')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${resolved_group:-GLOBAL}"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# If every configured rule points to a proxy without UDP support, mihomo falls
|
||||
# through to DIRECT. Reject only that otherwise-unmatched QUIC traffic so the
|
||||
# client can retry over TCP; rules with a usable UDP target still win first.
|
||||
with(
|
||||
select(has("rules")) |
|
||||
select((.rules | type) == "!!seq") |
|
||||
select(([.rules[] | select(. == "AND,((NETWORK,UDP),(DST-PORT,443)),REJECT")] | length) == 0);
|
||||
.rules += ["AND,((NETWORK,UDP),(DST-PORT,443)),REJECT"]
|
||||
)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ -f /usr/share/clashbackup/history ];then
|
||||
|
||||
HISTORY_PATH="/usr/share/clashbackup/history"
|
||||
SECRET=$(uci get clashoo.config.dash_pass 2>/dev/null)
|
||||
LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null)
|
||||
PORT=$(uci get clashoo.config.dash_port 2>/dev/null)
|
||||
urlencode() {
|
||||
if [ "$#" != 1 ]; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$1" | hexdump -v -e '1/1 "%u "' | awk '
|
||||
{
|
||||
for (i = 1; i <= NF; i++) {
|
||||
b = $i + 0
|
||||
if ((b >= 48 && b <= 57) || (b >= 65 && b <= 90) ||
|
||||
(b >= 97 && b <= 122) || b == 45 || b == 46 ||
|
||||
b == 95 || b == 126)
|
||||
printf "%c", b
|
||||
else
|
||||
printf "%%%02X", b
|
||||
}
|
||||
}'
|
||||
}
|
||||
cat $HISTORY_PATH |while read line
|
||||
do
|
||||
if [ -z "$(echo $line |grep "#*#")" ]; then
|
||||
continue
|
||||
else
|
||||
GORUP_NAME=$(urlencode "$(echo $line |awk -F '#*#' '{print $1}')")
|
||||
NOW_NAME=$(echo $line |awk -F '#*#' '{print $3}')
|
||||
curl -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X PUT -d '{"name":"'"$NOW_NAME"'"}' http://"$LAN_IP":"$PORT"/proxies/"$GORUP_NAME" >/dev/null 2>&1
|
||||
fi
|
||||
done >/dev/null 2>&1
|
||||
curl -m 5 --retry 2 -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X DELETE http://"$LAN_IP":"$PORT"/connections >/dev/null 2>&1
|
||||
fi
|
||||
@@ -0,0 +1,28 @@
|
||||
# Preserve every explicit user choice while restoring missing per-protocol defaults.
|
||||
with(
|
||||
.sniffer |
|
||||
select((has("enable") | not) or (.enable == true)) |
|
||||
select((has("override-destination") | not) or (."override-destination" == true));
|
||||
with(
|
||||
select(has("force-dns-mapping") | not);
|
||||
."force-dns-mapping" = true
|
||||
) |
|
||||
with(
|
||||
select(has("parse-pure-ip") | not);
|
||||
."parse-pure-ip" = true
|
||||
) |
|
||||
with(
|
||||
.sniff | select(has("TLS"));
|
||||
with(
|
||||
.TLS | select(type == "!!map" and (has("override-destination") | not));
|
||||
."override-destination" = true
|
||||
)
|
||||
) |
|
||||
with(
|
||||
.sniff | select(has("QUIC"));
|
||||
with(
|
||||
.QUIC | select(type == "!!map" and (has("override-destination") | not));
|
||||
."override-destination" = true
|
||||
)
|
||||
)
|
||||
)
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
time.windows.com
|
||||
time.nist.gov
|
||||
time.apple.com
|
||||
time.asia.apple.com
|
||||
cn.ntp.org.cn
|
||||
edu.ntp.org.cn
|
||||
hk.ntp.org.cn
|
||||
tw.ntp.org.cn
|
||||
us.ntp.org.cn
|
||||
sgp.ntp.org.cn
|
||||
kr.ntp.org.cn
|
||||
jp.ntp.org.cn
|
||||
de.ntp.org.cn
|
||||
ina.ntp.org.cn
|
||||
0.openwrt.pool.ntp.org
|
||||
1.openwrt.pool.ntp.org
|
||||
2.openwrt.pool.ntp.org
|
||||
3.openwrt.pool.ntp.org
|
||||
ntp.aliyun.com
|
||||
ntp1.aliyun.com
|
||||
ntp2.aliyun.com
|
||||
ntp3.aliyun.com
|
||||
ntp4.aliyun.com
|
||||
ntp5.aliyun.com
|
||||
ntp6.aliyun.com
|
||||
ntp7.aliyun.com
|
||||
time1.aliyun.com
|
||||
time2.aliyun.com
|
||||
time3.aliyun.com
|
||||
time4.aliyun.com
|
||||
time5.aliyun.com
|
||||
time6.aliyun.com
|
||||
time7.aliyun.com
|
||||
s1c.time.edu.cn
|
||||
s2m.time.edu.cn
|
||||
s1b.time.edu.cn
|
||||
s1e.time.edu.cn
|
||||
s2a.time.edu.cn
|
||||
s2b.time.edu.cn
|
||||
time1.apple.com
|
||||
time2.apple.com
|
||||
time3.apple.com
|
||||
time4.apple.com
|
||||
time5.apple.com
|
||||
time6.apple.com
|
||||
time7.apple.com
|
||||
time1.google.com
|
||||
time2.google.com
|
||||
time3.google.com
|
||||
time4.google.com
|
||||
.music.163.com
|
||||
.music.126.net
|
||||
man.netease.com
|
||||
api.iplay.163.com
|
||||
ac.dun.163yun.com
|
||||
mr.da.netease.com
|
||||
crash.163.com
|
||||
imap.163.com
|
||||
msftconnecttest.com
|
||||
msftncsi.com
|
||||
captive.qq.com
|
||||
ssl.ptlogin2.qq.com
|
||||
login.qq.com
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
#!/bin/sh
|
||||
|
||||
LOG_FILE="/tmp/clashoo_component_update.log"
|
||||
RUN_FILE="/var/run/clashoo_component_update"
|
||||
STATE_FILE="/tmp/clashoo_component_update_state"
|
||||
TMP_DIR="/tmp/clashoo-component-update"
|
||||
APK_UPGRADE_FLAG="/var/run/clashoo_package_upgrade"
|
||||
FEED_BASE_URL="https://down.dllkids.xyz/openwrt-feed/clashoo"
|
||||
GITHUB_API_URL="https://api.github.com/repos/kenzok8/openwrt-clashoo/releases/latest"
|
||||
ENV_GITHUB_PROXY_PREFIX="${GITHUB_PROXY_PREFIX:-}"
|
||||
UCI_GITHUB_PROXY_PREFIX="$(uci -q get clashoo.config.core_mirror_prefix 2>/dev/null)"
|
||||
GITHUB_PROXY_PREFIX="${UCI_GITHUB_PROXY_PREFIX:-${ENV_GITHUB_PROXY_PREFIX:-https://ghfast.top/}}"
|
||||
case "$GITHUB_PROXY_PREFIX" in
|
||||
*/) ;;
|
||||
*) GITHUB_PROXY_PREFIX="${GITHUB_PROXY_PREFIX}/" ;;
|
||||
esac
|
||||
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
|
||||
REQUEST_TIMEOUT="${REQUEST_TIMEOUT:-20}"
|
||||
LOW_SPEED_TIME="${LOW_SPEED_TIME:-30}"
|
||||
LOW_SPEED_LIMIT="${LOW_SPEED_LIMIT:-1024}"
|
||||
|
||||
trap 'rm -f "$APK_UPGRADE_FLAG"' EXIT
|
||||
|
||||
log() {
|
||||
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")" >/dev/null 2>&1
|
||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG_FILE"
|
||||
printf 'component=%s\nstatus=%s\nmessage=%s\n' "${COMPONENT:-}" "${STATUS:-running}" "$*" >"$STATE_FILE"
|
||||
}
|
||||
|
||||
finish() {
|
||||
rc="$1"
|
||||
msg="$2"
|
||||
STATUS="success"
|
||||
[ "$rc" -eq 0 ] || STATUS="failed"
|
||||
log "$msg"
|
||||
rm -f "$RUN_FILE" "$APK_UPGRADE_FLAG"
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# In kernel-only mode there is no transparent proxy, so component downloads
|
||||
# would go out direct and stall behind the GFW. Route them through the running
|
||||
# core (shared logic in proxy_lib.sh). Normal mode returns empty -> TPROXY.
|
||||
# Guard the source: on a stale install missing proxy_lib.sh, an unguarded `.`
|
||||
# under `set -e` kills the whole update silently (empty log, nothing happens).
|
||||
# Fall back to no proxy detection (direct / TPROXY) instead of dying.
|
||||
if [ -f /usr/share/clashoo/update/proxy_lib.sh ]; then
|
||||
. /usr/share/clashoo/update/proxy_lib.sh
|
||||
else
|
||||
clashoo_detect_proxy() { :; }
|
||||
fi
|
||||
detect_proxy() { clashoo_detect_proxy; }
|
||||
|
||||
fetch_text() {
|
||||
url="$1"
|
||||
proxy="$(detect_proxy)"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" ${proxy:+--proxy "$proxy"} "$url"
|
||||
return $?
|
||||
fi
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
[ -n "$proxy" ] && { http_proxy="$proxy" https_proxy="$proxy" wget -qO- --timeout="$REQUEST_TIMEOUT" "$url"; return $?; }
|
||||
wget -qO- --timeout="$REQUEST_TIMEOUT" "$url"
|
||||
return $?
|
||||
fi
|
||||
return 127
|
||||
}
|
||||
|
||||
download_file() {
|
||||
url="$1"
|
||||
out="$2"
|
||||
proxy="$(detect_proxy)"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --connect-timeout "$CONNECT_TIMEOUT" --speed-time "$LOW_SPEED_TIME" --speed-limit "$LOW_SPEED_LIMIT" ${proxy:+--proxy "$proxy"} "$url" -o "$out"
|
||||
return $?
|
||||
fi
|
||||
if [ -n "$proxy" ]; then
|
||||
http_proxy="$proxy" https_proxy="$proxy" wget -qO "$out" --timeout="$REQUEST_TIMEOUT" "$url"
|
||||
return $?
|
||||
fi
|
||||
wget -qO "$out" --timeout="$REQUEST_TIMEOUT" "$url"
|
||||
}
|
||||
|
||||
download_url() {
|
||||
url="$1"
|
||||
case "$url" in
|
||||
https://github.com/*)
|
||||
printf '%s%s\n' "$GITHUB_PROXY_PREFIX" "$url"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$url"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
root_free_kb() {
|
||||
df -k / 2>/dev/null | awk 'NR==2 {print $4}'
|
||||
}
|
||||
|
||||
kb_to_mib() {
|
||||
awk "BEGIN {printf \"%.0f\", (${1:-0}) / 1024}"
|
||||
}
|
||||
|
||||
ensure_root_space() {
|
||||
need_kb="$1"
|
||||
free_kb="$(root_free_kb)"
|
||||
[ -n "$free_kb" ] || return 0
|
||||
[ "$free_kb" -ge "$need_kb" ] && return 0
|
||||
finish 1 "根分区空间不足:可用 $(kb_to_mib "$free_kb") MiB,至少需要 $(kb_to_mib "$need_kb") MiB"
|
||||
}
|
||||
|
||||
log_install_error() {
|
||||
file="$1"
|
||||
[ -s "$file" ] || return 0
|
||||
tail -8 "$file" 2>/dev/null | while IFS= read -r line; do
|
||||
[ -n "$line" ] && log "$line"
|
||||
done
|
||||
}
|
||||
|
||||
detect_manager() {
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
echo opkg
|
||||
return
|
||||
fi
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo apk
|
||||
return
|
||||
fi
|
||||
echo unsupported
|
||||
}
|
||||
|
||||
installed_package_version() {
|
||||
pkg="$1"
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
opkg status "$pkg" 2>/dev/null | awk -F': ' '/^Version:/{print $2; exit}'
|
||||
return
|
||||
fi
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk list -I "$pkg" 2>/dev/null | awk '{print $1; exit}' | sed -n "s/^${pkg}-//p"
|
||||
fi
|
||||
}
|
||||
|
||||
package_installed() {
|
||||
pkg="$1"
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
opkg status "$pkg" 2>/dev/null | awk '
|
||||
$1 == "Status:" && $2 == "install" && $NF == "installed" { found = 1 }
|
||||
END { exit !found }
|
||||
'
|
||||
return
|
||||
fi
|
||||
apk info -e "$pkg" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
apk_install() {
|
||||
proxy="$(detect_proxy)"
|
||||
if [ -n "$proxy" ]; then
|
||||
http_proxy="$proxy" https_proxy="$proxy" apk add --allow-untrusted --timeout 30 "$@"
|
||||
else
|
||||
apk add --allow-untrusted --timeout 30 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
apk_bundle_install() {
|
||||
printf '%s\n' "$$" >"$APK_UPGRADE_FLAG"
|
||||
apk_install "$@"
|
||||
rc=$?
|
||||
rm -f "$APK_UPGRADE_FLAG"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
verify_downloaded_package() {
|
||||
file="$1"
|
||||
[ -s "$file" ] || return 1
|
||||
if [ "$PM" = "apk" ]; then
|
||||
apk verify --allow-untrusted "$file" >/dev/null 2>&1
|
||||
return
|
||||
fi
|
||||
gzip -t "$file" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
download_package_file() {
|
||||
url="$1"
|
||||
out="$2"
|
||||
name="$3"
|
||||
download_file "$(download_url "$url")" "$out" || finish 1 "下载 ${name} 失败"
|
||||
verify_downloaded_package "$out" || finish 1 "${name} 文件校验失败"
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
pm="$1"
|
||||
if [ "$pm" = "opkg" ]; then
|
||||
opkg print-architecture 2>/dev/null | awk '/^arch / {print $2}' | tail -n 1
|
||||
return
|
||||
fi
|
||||
# apk: DISTRIB_ARCH is authoritative (e.g. aarch64_cortex-a53); apk --print-arch
|
||||
# only gives the generic "aarch64" which won't match the .apk package suffix.
|
||||
if [ -r /etc/openwrt_release ]; then
|
||||
a="$(sed -n "s/^DISTRIB_ARCH=['\"]\\([^'\"]*\\)['\"]$/\\1/p" /etc/openwrt_release | head -n 1)"
|
||||
[ -n "$a" ] && { printf '%s\n' "$a"; return; }
|
||||
fi
|
||||
apk --print-arch 2>/dev/null
|
||||
}
|
||||
|
||||
detect_sdk() {
|
||||
[ -r /etc/openwrt_release ] || return 1
|
||||
release="$(sed -n "s/^DISTRIB_RELEASE=['\"]\\([^'\"]*\\)['\"]$/\\1/p" /etc/openwrt_release | head -n 1)"
|
||||
[ -n "$release" ] || return 1
|
||||
printf '%s\n' "$release" | grep -Eo '[0-9]+\.[0-9]+' | head -n 1
|
||||
}
|
||||
|
||||
append_unique_word() {
|
||||
value="$1"
|
||||
list="$2"
|
||||
[ -n "$value" ] || {
|
||||
printf '%s\n' "$list"
|
||||
return
|
||||
}
|
||||
case " $list " in
|
||||
*" $value "*) ;;
|
||||
*) list="${list}${list:+ }${value}" ;;
|
||||
esac
|
||||
printf '%s\n' "$list"
|
||||
}
|
||||
|
||||
build_sdk_candidates() {
|
||||
pm="$1"
|
||||
candidates=""
|
||||
detected_sdk="$(detect_sdk || true)"
|
||||
candidates="$(append_unique_word "$detected_sdk" "$candidates")"
|
||||
|
||||
if [ "$pm" = "opkg" ]; then
|
||||
for sdk in 24.10 23.05 22.03 21.02; do
|
||||
candidates="$(append_unique_word "$sdk" "$candidates")"
|
||||
done
|
||||
else
|
||||
for sdk in 25.12 24.10; do
|
||||
candidates="$(append_unique_word "$sdk" "$candidates")"
|
||||
done
|
||||
fi
|
||||
|
||||
printf '%s\n' "$candidates"
|
||||
}
|
||||
|
||||
find_manifest_value() {
|
||||
key="$1"
|
||||
manifest_text="$2"
|
||||
printf '%s\n' "$manifest_text" | sed -n "s/^${key}=//p" | head -n 1
|
||||
}
|
||||
|
||||
load_manifest_urls() {
|
||||
sdk="$1"
|
||||
arch="$2"
|
||||
manifest_url="${FEED_BASE_URL}/${sdk}/${arch}/manifest-clashoo.txt"
|
||||
manifest_text="$(fetch_text "$manifest_url" || true)"
|
||||
[ -n "$manifest_text" ] || return 1
|
||||
|
||||
core_file="$(find_manifest_value "core" "$manifest_text")"
|
||||
luci_file="$(find_manifest_value "luci" "$manifest_text")"
|
||||
i18n_file="$(find_manifest_value "i18n" "$manifest_text")"
|
||||
[ -n "$core_file" ] || return 1
|
||||
[ -n "$luci_file" ] || return 1
|
||||
case "$core_file" in *."$EXT") ;; *) return 1 ;; esac
|
||||
case "$luci_file" in *."$EXT") ;; *) return 1 ;; esac
|
||||
case "$i18n_file" in ""|*."$EXT") ;; *) return 1 ;; esac
|
||||
|
||||
CORE_URL="${FEED_BASE_URL}/${sdk}/${arch}/${core_file}"
|
||||
LUCI_URL="${FEED_BASE_URL}/${sdk}/${arch}/${luci_file}"
|
||||
I18N_URL=""
|
||||
[ -n "$i18n_file" ] && I18N_URL="${FEED_BASE_URL}/${sdk}/${arch}/${i18n_file}"
|
||||
SOURCE_LABEL="R2 feed ${sdk}/${arch}"
|
||||
return 0
|
||||
}
|
||||
|
||||
load_github_urls() {
|
||||
arch="$1"
|
||||
ext="$2"
|
||||
payload="$(fetch_text "$GITHUB_API_URL" || true)"
|
||||
[ -n "$payload" ] || return 1
|
||||
|
||||
urls="$(printf '%s\n' "$payload" | sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*\)".*/\1/p')"
|
||||
[ -n "$urls" ] || return 1
|
||||
|
||||
if [ "$ext" = "apk" ]; then
|
||||
CORE_URL="$(printf '%s\n' "$urls" | grep -E '/clashoo-[^-]+.*-r[0-9]+-'"$arch"'\.apk$' | head -n 1)"
|
||||
LUCI_URL="$(printf '%s\n' "$urls" | grep -E '/luci-app-clashoo-[^-]+.*-r[0-9]+-('"$arch"'|all)\.apk$' | head -n 1)"
|
||||
I18N_URL="$(printf '%s\n' "$urls" | grep -E '/luci-i18n-clashoo-zh-cn-[^-]+.*-r[0-9]+-('"$arch"'|all)\.apk$' | head -n 1)"
|
||||
else
|
||||
CORE_URL="$(printf '%s\n' "$urls" | grep -E '/clashoo_.*_'"$arch"'\.ipk$' | head -n 1)"
|
||||
LUCI_URL="$(printf '%s\n' "$urls" | grep -E '/luci-app-clashoo_.*_all\.ipk$' | head -n 1)"
|
||||
I18N_URL="$(printf '%s\n' "$urls" | grep -E '/luci-i18n-clashoo-zh-cn_.*_all\.ipk$' | head -n 1)"
|
||||
fi
|
||||
|
||||
[ -n "$CORE_URL" ] || return 1
|
||||
[ -n "$LUCI_URL" ] || return 1
|
||||
SOURCE_LABEL="GitHub latest"
|
||||
return 0
|
||||
}
|
||||
|
||||
package_version_from_url() {
|
||||
file="${1##*/}"
|
||||
case "$file" in
|
||||
clashoo_*_"$ARCH".ipk)
|
||||
v="${file#clashoo_}"
|
||||
v="${v%_${ARCH}.ipk}"
|
||||
printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
|
||||
;;
|
||||
clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\(.*\)_[^_][^_]*\.ipk$/\1/p' | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' ;;
|
||||
luci-app-clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-app-clashoo_\(.*\)_all\.ipk$/\1/p' ;;
|
||||
luci-i18n-clashoo-zh-cn_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-i18n-clashoo-zh-cn_\(.*\)_all\.ipk$/\1/p' ;;
|
||||
clashoo-*.apk)
|
||||
v="$(printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1)"
|
||||
printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
|
||||
;;
|
||||
luci-app-clashoo-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
|
||||
luci-i18n-clashoo-zh-cn-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
|
||||
*) printf '%s\n' "$file" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_bundle_urls() {
|
||||
PM="$(detect_manager)"
|
||||
[ "$PM" != "unsupported" ] || return 1
|
||||
ARCH="$(detect_arch "$PM")"
|
||||
[ -n "$ARCH" ] || return 1
|
||||
EXT="ipk"
|
||||
[ "$PM" = "apk" ] && EXT="apk"
|
||||
|
||||
CORE_URL=""
|
||||
LUCI_URL=""
|
||||
I18N_URL=""
|
||||
SOURCE_LABEL=""
|
||||
|
||||
SDK_CANDIDATES="$(build_sdk_candidates "$PM")"
|
||||
for sdk in $SDK_CANDIDATES; do
|
||||
if load_manifest_urls "$sdk" "$ARCH"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
load_github_urls "$ARCH" "$EXT"
|
||||
}
|
||||
|
||||
backup_config() {
|
||||
ts="$(date '+%Y%m%d-%H%M%S')"
|
||||
BACKUP_DIR="/etc/clashoo/backup/component-upgrade-${ts}"
|
||||
mkdir -p "$BACKUP_DIR/etc-config" "$BACKUP_DIR/etc-clashoo" >/dev/null 2>&1 || return 1
|
||||
[ -r /etc/config/clashoo ] && cp -a /etc/config/clashoo "$BACKUP_DIR/etc-config/clashoo"
|
||||
# 只备份用户配置;Model.bin 等可重新下载的大文件不入备份,避免每次更新
|
||||
# 复制 ~5MB 的同一模型,把备份目录撑大、堆满本就紧张的根分区。
|
||||
for path in \
|
||||
/etc/clashoo/config.yaml \
|
||||
/etc/clashoo/config.json \
|
||||
/etc/clashoo/*.yaml \
|
||||
/etc/clashoo/*.yml \
|
||||
/etc/clashoo/*.json
|
||||
do
|
||||
[ -e "$path" ] || continue
|
||||
cp -a "$path" "$BACKUP_DIR/etc-clashoo/" 2>/dev/null || true
|
||||
done
|
||||
log "已备份配置:${BACKUP_DIR}"
|
||||
|
||||
# 只保留最近 1 个备份,删除更早的,避免无限堆积占满磁盘。
|
||||
ls -dt /etc/clashoo/backup/component-upgrade-* 2>/dev/null | tail -n +2 | while read -r _old; do
|
||||
[ -n "$_old" ] && rm -rf "$_old" 2>/dev/null
|
||||
done
|
||||
}
|
||||
|
||||
restore_config_backup() {
|
||||
[ -n "${BACKUP_DIR:-}" ] || return 0
|
||||
[ -r "$BACKUP_DIR/etc-config/clashoo" ] && cp -a "$BACKUP_DIR/etc-config/clashoo" /etc/config/clashoo
|
||||
if [ -d "$BACKUP_DIR/etc-clashoo" ]; then
|
||||
mkdir -p /etc/clashoo
|
||||
cp -a "$BACKUP_DIR/etc-clashoo/." /etc/clashoo/ 2>/dev/null || true
|
||||
fi
|
||||
uci commit clashoo >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
clashoo_was_running() {
|
||||
/etc/init.d/clashoo status >/dev/null 2>&1 && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
restart_web_stack() {
|
||||
# reload (not restart) rpcd so the LuCI login session survives a plugin
|
||||
# update — rpcd restart drops every ubus session and forces a re-login
|
||||
# (issue #12). reload still picks up the new ucode RPC backend. uhttpd needs
|
||||
# nothing: updated static assets are served on the next request.
|
||||
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# which: clashoo(仅核心)/ luci(luci-app + 语言包)。两者拆开,clashoo
|
||||
# 核心更新频率高于 LuCI,分别更新便于定位失败。
|
||||
run_pkg_update() {
|
||||
which="$1"
|
||||
log "正在检查组件包"
|
||||
if ! resolve_bundle_urls; then
|
||||
finish 1 "未找到适配当前设备的组件包"
|
||||
fi
|
||||
log "更新来源:${SOURCE_LABEL}"
|
||||
|
||||
core_ver="$(package_version_from_url "$CORE_URL")"
|
||||
luci_ver="$(package_version_from_url "$LUCI_URL")"
|
||||
i18n_ver=""
|
||||
[ -z "$I18N_URL" ] || i18n_ver="$(package_version_from_url "$I18N_URL")"
|
||||
core_ver_before="$(installed_package_version clashoo)"
|
||||
|
||||
apk_core_ready() {
|
||||
package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
|
||||
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ]
|
||||
}
|
||||
|
||||
apk_luci_ready() {
|
||||
package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
|
||||
&& { [ -z "$I18N_URL" ] || { package_installed luci-i18n-clashoo-zh-cn \
|
||||
&& [ "$(installed_package_version luci-i18n-clashoo-zh-cn)" = "$i18n_ver" ]; }; } \
|
||||
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
|
||||
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ]
|
||||
}
|
||||
|
||||
apk_bundle_ready() {
|
||||
apk_core_ready && apk_luci_ready
|
||||
}
|
||||
|
||||
apk_update_ready() {
|
||||
if [ "$which" = "clashoo" ]; then
|
||||
apk_bundle_ready
|
||||
else
|
||||
package_installed clashoo && apk_luci_ready
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$which" = "clashoo" ]; then
|
||||
log "目标版本:Clashoo 核心 ${core_ver}"
|
||||
cur_ver="$(installed_package_version clashoo)"
|
||||
if [ "$PM" = "apk" ] && apk_update_ready; then
|
||||
finish 0 "Clashoo 核心已是最新版本"
|
||||
elif [ "$PM" != "apk" ] && [ -n "$core_ver" ] && [ "$cur_ver" = "$core_ver" ]; then
|
||||
finish 0 "Clashoo 核心已是最新版本"
|
||||
fi
|
||||
else
|
||||
log "目标版本:客户端 ${luci_ver}"
|
||||
cur_ver="$(installed_package_version luci-app-clashoo)"
|
||||
if [ "$PM" = "apk" ] && apk_update_ready; then
|
||||
finish 0 "客户端已是最新版本"
|
||||
elif [ "$PM" != "apk" ] && [ -n "$luci_ver" ] && [ "$cur_ver" = "$luci_ver" ]; then
|
||||
finish 0 "客户端已是最新版本"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf "$TMP_DIR"
|
||||
mkdir -p "$TMP_DIR" || finish 1 "创建临时目录失败"
|
||||
|
||||
was_running=0
|
||||
clashoo_was_running && was_running=1
|
||||
backup_config || finish 1 "备份配置失败"
|
||||
apk_core_file=""
|
||||
|
||||
if [ "$PM" = "apk" ]; then
|
||||
log "正在下载组件包"
|
||||
if [ "$which" = "clashoo" ] || ! package_installed clashoo; then
|
||||
ensure_root_space 98304
|
||||
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
|
||||
apk_core_file="$TMP_DIR/core.${EXT}"
|
||||
fi
|
||||
download_package_file "$LUCI_URL" "$TMP_DIR/luci.${EXT}" "luci-app-clashoo"
|
||||
if [ -n "$I18N_URL" ]; then
|
||||
download_package_file "$I18N_URL" "$TMP_DIR/i18n.${EXT}" "语言包"
|
||||
fi
|
||||
elif [ "$which" = "clashoo" ]; then
|
||||
log "正在下载 Clashoo 核心"
|
||||
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
|
||||
else
|
||||
log "正在下载客户端(LuCI + 语言包)"
|
||||
download_package_file "$LUCI_URL" "$TMP_DIR/luci.${EXT}" "luci-app-clashoo"
|
||||
if [ -n "$I18N_URL" ]; then
|
||||
download_package_file "$I18N_URL" "$TMP_DIR/i18n.${EXT}" "语言包"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PM" != "apk" ] && [ "$which" = "clashoo" ]; then
|
||||
ensure_root_space 98304
|
||||
fi
|
||||
|
||||
if [ "$which" = "clashoo" ]; then
|
||||
log "正在安装 Clashoo 核心"
|
||||
if [ "$PM" = "opkg" ]; then
|
||||
opkg install --force-downgrade "$TMP_DIR/core.${EXT}" >"$TMP_DIR/install.log" 2>&1; rc=$?
|
||||
else
|
||||
apk_bundle_install ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
|
||||
fi
|
||||
else
|
||||
log "正在安装客户端"
|
||||
if [ "$PM" = "opkg" ]; then
|
||||
opkg install --force-downgrade "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
|
||||
else
|
||||
apk_bundle_install ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ "$PM" = "apk" ] && apk_update_ready; then
|
||||
log "apk 报告了其它软件包的错误,本次组件已升级到目标版本"
|
||||
rc=0
|
||||
else
|
||||
log_install_error "$TMP_DIR/install.log"
|
||||
log "安装失败,正在恢复配置备份"
|
||||
restore_config_backup
|
||||
finish "$rc" "组件更新失败,已停止操作,请查看组件更新日志"
|
||||
fi
|
||||
fi
|
||||
|
||||
validation_failed() {
|
||||
log "安装校验失败,正在恢复配置备份"
|
||||
restore_config_backup
|
||||
finish 1 "组件安装校验失败,已停止操作,请查看组件更新日志"
|
||||
}
|
||||
|
||||
if [ "$PM" = "apk" ]; then
|
||||
if ! apk_update_ready; then
|
||||
log "安装校验失败,正在安全重装组件包"
|
||||
if [ -z "$apk_core_file" ] && ! package_installed clashoo; then
|
||||
ensure_root_space 98304
|
||||
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
|
||||
apk_core_file="$TMP_DIR/core.${EXT}"
|
||||
fi
|
||||
apk_bundle_install --force-reinstall ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" \
|
||||
${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >>"$TMP_DIR/install.log" 2>&1; repair_rc=$?
|
||||
if [ "$repair_rc" -ne 0 ]; then
|
||||
log_install_error "$TMP_DIR/install.log"
|
||||
validation_failed
|
||||
fi
|
||||
apk_update_ready || validation_failed
|
||||
fi
|
||||
elif [ "$which" = "clashoo" ]; then
|
||||
package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
|
||||
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ] \
|
||||
|| validation_failed
|
||||
else
|
||||
package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
|
||||
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
|
||||
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ] \
|
||||
|| validation_failed
|
||||
[ -z "$I18N_URL" ] || package_installed luci-i18n-clashoo-zh-cn || validation_failed
|
||||
fi
|
||||
|
||||
# APK component updates install the LuCI bundle in the same transaction.
|
||||
if [ "$which" = "luci" ] || [ "$PM" = "apk" ]; then
|
||||
log "正在刷新 LuCI 服务"
|
||||
restart_web_stack
|
||||
fi
|
||||
core_ver_after="$(installed_package_version clashoo)"
|
||||
restart_clashoo=0
|
||||
[ "$which" = "clashoo" ] && restart_clashoo=1
|
||||
[ "$PM" = "apk" ] && [ "$core_ver_before" != "$core_ver_after" ] && restart_clashoo=1
|
||||
if [ "$restart_clashoo" -eq 1 ] && [ "$was_running" -eq 1 ] && [ "$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ]; then
|
||||
log "Clashoo 原本运行中,正在重启服务"
|
||||
sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1 || /etc/init.d/clashoo restart >/dev/null 2>&1 || true
|
||||
elif [ "$restart_clashoo" -eq 1 ]; then
|
||||
log "Clashoo 原本未运行,保持停止状态"
|
||||
fi
|
||||
|
||||
finish 0 "组件更新完成"
|
||||
}
|
||||
|
||||
run_core_update() {
|
||||
dcore="$1"
|
||||
label="$2"
|
||||
log "正在更新 ${label}"
|
||||
# Pass the target as an arg so core_download refreshes only that binary
|
||||
# without switching the active kernel. It restarts only if the target is the
|
||||
# currently-running core (see core_download.sh finalize). Switching kernels
|
||||
# stays a separate, explicit user action.
|
||||
touch /var/run/core_update
|
||||
sh /usr/share/clashoo/update/core_download.sh "$dcore" >>/tmp/clash_update.txt 2>&1
|
||||
rc=$?
|
||||
rm -f /var/run/core_update
|
||||
[ "$rc" -eq 0 ] || finish "$rc" "${label} 更新失败"
|
||||
finish 0 "${label} 更新完成"
|
||||
}
|
||||
|
||||
run_lgbm_update() {
|
||||
log "正在更新 Smart LightGBM 模型"
|
||||
sh /usr/share/clashoo/update/lgbm_update.sh >/tmp/lgbm_update.log 2>&1
|
||||
rc=$?
|
||||
tail -5 /tmp/lgbm_update.log 2>/dev/null | while IFS= read -r line; do
|
||||
[ -n "$line" ] && log "$line"
|
||||
done
|
||||
[ "$rc" -eq 0 ] || finish "$rc" "LightGBM 模型更新失败"
|
||||
finish 0 "LightGBM 模型更新完成"
|
||||
}
|
||||
|
||||
run_china_update() {
|
||||
log "正在更新大陆白名单"
|
||||
sh /usr/share/clashoo/update/update_china_ip.sh >>/tmp/clash_update.txt 2>&1
|
||||
rc=$?
|
||||
tail -6 /tmp/clash_update.txt 2>/dev/null | while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '%s\n' "$line" | grep -q '白名单' && log "$line"
|
||||
done
|
||||
[ "$rc" -eq 0 ] || finish "$rc" "大陆白名单更新失败"
|
||||
finish 0 "大陆白名单更新完成"
|
||||
}
|
||||
|
||||
run_geoip_update() {
|
||||
log "正在更新 GeoIP / GeoSite"
|
||||
touch /var/run/geoip_update
|
||||
sh /usr/share/clashoo/update/geoip.sh >/tmp/geoip_update.txt 2>&1
|
||||
rc=$?
|
||||
rm -f /var/run/geoip_update
|
||||
tail -8 /tmp/geoip_update.txt 2>/dev/null | while IFS= read -r line; do
|
||||
[ -n "$line" ] && log "$line"
|
||||
done
|
||||
[ "$rc" -eq 0 ] || finish "$rc" "GeoIP / GeoSite 更新失败"
|
||||
finish 0 "GeoIP / GeoSite 更新完成"
|
||||
}
|
||||
|
||||
COMPONENT="${1:-}"
|
||||
VARIANT="${2:-}" # mihomo/singbox: stable | alpha
|
||||
[ -n "$COMPONENT" ] || {
|
||||
echo "usage: $0 <clashoo|luci|mihomo|singbox|smart|lgbm|china|geoip> [stable|alpha]"
|
||||
exit 2
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$RUN_FILE")" >/dev/null 2>&1
|
||||
printf '%s\n' "$COMPONENT" >"$RUN_FILE"
|
||||
STATUS="running"
|
||||
log "组件更新任务启动:${COMPONENT}${VARIANT:+ ($VARIANT)}"
|
||||
|
||||
case "$COMPONENT" in
|
||||
clashoo) run_pkg_update clashoo ;;
|
||||
luci) run_pkg_update luci ;;
|
||||
mihomo)
|
||||
if [ "$VARIANT" = "alpha" ]; then run_core_update 3 "mihomo Alpha 版"
|
||||
else run_core_update 2 "mihomo 稳定版"; fi ;;
|
||||
singbox)
|
||||
if [ "$VARIANT" = "alpha" ]; then run_core_update 5 "sing-box Alpha 版"
|
||||
else run_core_update 4 "sing-box 稳定版"; fi ;;
|
||||
smart) run_core_update 1 "mihomo Smart 版" ;;
|
||||
lgbm) run_lgbm_update ;;
|
||||
china) run_china_update ;;
|
||||
geoip) run_geoip_update ;;
|
||||
*) finish 2 "未知组件:${COMPONENT}" ;;
|
||||
esac
|
||||
+1233
File diff suppressed because it is too large.
Load diff
+267
@@ -0,0 +1,267 @@
|
||||
#!/bin/sh
|
||||
|
||||
LOG_FILE="/tmp/geoip_update.txt"
|
||||
TMP_DIR="/tmp/clash_geoip_$$"
|
||||
MMDB_MIN_SIZE=2000000
|
||||
GEOSITE_MIN_SIZE=2000000
|
||||
|
||||
geoip_source="$(uci -q get clashoo.config.geoip_source 2>/dev/null)"
|
||||
license_key="$(uci -q get clashoo.config.license_key 2>/dev/null)"
|
||||
|
||||
cfg_mmdb_url="$(uci -q get clashoo.config.geoip_mmdb_url 2>/dev/null)"
|
||||
cfg_geosite_url="$(uci -q get clashoo.config.geosite_url 2>/dev/null)"
|
||||
cfg_geoip_dat_url="$(uci -q get clashoo.config.geoip_dat_url 2>/dev/null)"
|
||||
cfg_geoip_asn_url="$(uci -q get clashoo.config.geoip_asn_url 2>/dev/null)"
|
||||
|
||||
DEFAULT_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||
DEFAULT_GEOSITE_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geosite.dat"
|
||||
DEFAULT_GEOIP_DAT_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geoip.dat"
|
||||
DEFAULT_GEOIP_ASN_URL="https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb"
|
||||
|
||||
LOYALSOLDIER_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||
LOYALSOLDIER_GEOSITE_URL="https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geosite.dat"
|
||||
LOYALSOLDIER_GEOIP_DAT_URL="https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat"
|
||||
|
||||
# v2fly ships no Country.mmdb — borrow Loyalsoldier's for the mmdb slot
|
||||
V2FLY_GEOSITE_URL="https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"
|
||||
V2FLY_GEOIP_DAT_URL="https://github.com/v2fly/geoip/releases/latest/download/geoip.dat"
|
||||
|
||||
log() {
|
||||
echo " $(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR" >/dev/null 2>&1
|
||||
rm -f /var/run/geoip_update >/dev/null 2>&1
|
||||
}
|
||||
|
||||
config_needs_geosite() {
|
||||
_cfg="/etc/clashoo/config.yaml"
|
||||
[ -f "$_cfg" ] || return 1
|
||||
grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg"
|
||||
}
|
||||
|
||||
# Shared port detection (handles smart core + sing-box profile port); keep the
|
||||
# local anonymity probe so we skip a coexisting plugin's auth-locked proxy.
|
||||
. /usr/share/clashoo/update/proxy_lib.sh
|
||||
detect_proxy() {
|
||||
proxy="$(clashoo_detect_proxy)"
|
||||
[ -n "$proxy" ] || return 0
|
||||
# Only use it if it actually proxies anonymously. A coexisting plugin
|
||||
# (e.g. OpenClash on the same 7890) may hold this port behind auth and
|
||||
# answer HTTP 407 — in that case fall through to the mirrors instead.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS --proxy "$proxy" --connect-timeout 3 --max-time 5 -o /dev/null \
|
||||
http://www.gstatic.com/generate_204 >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
echo "$proxy"
|
||||
}
|
||||
|
||||
_fetch() {
|
||||
url="$1"; target="$2"; proxy="$3"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "$proxy" ]; then
|
||||
curl --proxy "$proxy" -fsSL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$url" -o "$target"
|
||||
else
|
||||
curl -fsSL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$url" -o "$target"
|
||||
fi
|
||||
return $?
|
||||
fi
|
||||
wget -q --timeout=120 --user-agent="Clash/OpenWRT" "$url" -O "$target"
|
||||
}
|
||||
|
||||
# 多源拉取:本机代理 → 国内镜像(gh-proxy / ghfast / jsdelivr)→ 原 URL 兜底
|
||||
download_to() {
|
||||
url="$1"; target="$2"
|
||||
[ -z "$url" ] && return 1
|
||||
|
||||
proxy="$(detect_proxy)"
|
||||
|
||||
if [ -n "$proxy" ]; then
|
||||
_fetch "$url" "$target" "$proxy" && return 0
|
||||
fi
|
||||
|
||||
case "$url" in
|
||||
https://raw.githubusercontent.com/*)
|
||||
rest="${url#https://raw.githubusercontent.com/}"
|
||||
owner="${rest%%/*}"; r1="${rest#*/}"
|
||||
repo="${r1%%/*}"; r2="${r1#*/}"
|
||||
branch="${r2%%/*}"; path="${r2#*/}"
|
||||
for m in \
|
||||
"https://gh-proxy.com/${url}" \
|
||||
"https://ghfast.top/${url}" \
|
||||
"https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}"; do
|
||||
_fetch "$m" "$target" "" && return 0
|
||||
done
|
||||
;;
|
||||
https://github.com/*)
|
||||
for m in \
|
||||
"https://gh-proxy.com/${url}" \
|
||||
"https://ghfast.top/${url}"; do
|
||||
_fetch "$m" "$target" "" && return 0
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
_fetch "$url" "$target" ""
|
||||
}
|
||||
|
||||
download_optional() {
|
||||
url="$1"
|
||||
target="$2"
|
||||
name="$3"
|
||||
tmp_target="${TMP_DIR}/$(basename "$target").tmp"
|
||||
if [ -z "$url" ]; then
|
||||
log "$name skip (url empty)"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
if download_to "$url" "$tmp_target"; then
|
||||
mv -f "$tmp_target" "$target" >/dev/null 2>&1 || return 1
|
||||
chmod 644 "$target" >/dev/null 2>&1
|
||||
log "$name updated"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
log "$name update failed"
|
||||
return 0
|
||||
}
|
||||
|
||||
download_geosite() {
|
||||
url="$1"
|
||||
tmp_target="${TMP_DIR}/GeoSite.dat.tmp"
|
||||
size=0
|
||||
if [ -z "$url" ]; then
|
||||
log "GeoSite.dat skip (url empty)"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
if ! download_to "$url" "$tmp_target"; then
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
log "GeoSite.dat update failed"
|
||||
return 0
|
||||
fi
|
||||
size=$(wc -c <"$tmp_target" 2>/dev/null)
|
||||
if [ -z "$size" ] || [ "$size" -lt "$GEOSITE_MIN_SIZE" ]; then
|
||||
log "GeoSite.dat download invalid or incomplete (${size:-0} bytes)"
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
return 0
|
||||
fi
|
||||
mv -f "$tmp_target" /etc/clashoo/GeoSite.dat >/dev/null 2>&1 || return 1
|
||||
cp -f /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1 || true
|
||||
chmod 644 /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1
|
||||
log "GeoSite.dat updated"
|
||||
return 0
|
||||
}
|
||||
|
||||
download_mmdb() {
|
||||
url="$1"
|
||||
tmp_target="${TMP_DIR}/Country.mmdb.tmp"
|
||||
size=0
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
if ! download_to "$url" "$tmp_target"; then
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
size=$(wc -c <"$tmp_target" 2>/dev/null)
|
||||
if [ -z "$size" ] || [ "$size" -lt "$MMDB_MIN_SIZE" ]; then
|
||||
log "Country.mmdb download invalid or incomplete (${size:-0} bytes)"
|
||||
rm -f "$tmp_target" >/dev/null 2>&1
|
||||
return 1
|
||||
fi
|
||||
mv -f "$tmp_target" /etc/clashoo/Country.mmdb >/dev/null 2>&1 || return 1
|
||||
chmod 644 /etc/clashoo/Country.mmdb >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
mkdir -p "$TMP_DIR" /etc/clashoo >/dev/null 2>&1
|
||||
|
||||
rm -f /var/run/geoip_down_complete >/dev/null 2>&1
|
||||
: > /var/run/geoip_update
|
||||
: > "$LOG_FILE"
|
||||
log "GeoIP 更新任务启动"
|
||||
|
||||
mmdb_url=""
|
||||
geosite_url=""
|
||||
geoip_dat_url=""
|
||||
geoip_asn_url=""
|
||||
|
||||
case "$geoip_source" in
|
||||
1)
|
||||
if [ -z "$license_key" ]; then
|
||||
log "MaxMind source selected but license key is empty"
|
||||
exit 1
|
||||
fi
|
||||
log "Updating Country.mmdb from MaxMind"
|
||||
if ! download_to "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${license_key}&suffix=tar.gz" "$TMP_DIR/geoip.tar.gz"; then
|
||||
log "MaxMind download failed"
|
||||
exit 1
|
||||
fi
|
||||
if ! tar zxf "$TMP_DIR/geoip.tar.gz" -C "$TMP_DIR" >/dev/null 2>&1; then
|
||||
log "MaxMind archive extract failed"
|
||||
exit 1
|
||||
fi
|
||||
mmdb_file="$(ls "$TMP_DIR"/GeoLite2-Country_*/GeoLite2-Country.mmdb 2>/dev/null | head -n 1)"
|
||||
if [ -z "$mmdb_file" ] || [ ! -f "$mmdb_file" ]; then
|
||||
log "MaxMind Country.mmdb not found in archive"
|
||||
exit 1
|
||||
fi
|
||||
cp -f "$mmdb_file" /etc/clashoo/Country.mmdb
|
||||
chmod 644 /etc/clashoo/Country.mmdb >/dev/null 2>&1
|
||||
log "Country.mmdb updated"
|
||||
;;
|
||||
3)
|
||||
# Loyalsoldier (default)
|
||||
mmdb_url="$LOYALSOLDIER_MMDB_URL"
|
||||
geosite_url="$LOYALSOLDIER_GEOSITE_URL"
|
||||
geoip_dat_url="$LOYALSOLDIER_GEOIP_DAT_URL"
|
||||
geoip_asn_url="$DEFAULT_GEOIP_ASN_URL"
|
||||
;;
|
||||
5)
|
||||
# v2fly (mmdb borrowed from Loyalsoldier)
|
||||
mmdb_url="$LOYALSOLDIER_MMDB_URL"
|
||||
geosite_url="$V2FLY_GEOSITE_URL"
|
||||
geoip_dat_url="$V2FLY_GEOIP_DAT_URL"
|
||||
geoip_asn_url="$DEFAULT_GEOIP_ASN_URL"
|
||||
;;
|
||||
4)
|
||||
# custom — asn falls back to default when left blank
|
||||
mmdb_url="$cfg_mmdb_url"
|
||||
geosite_url="$cfg_geosite_url"
|
||||
geoip_dat_url="$cfg_geoip_dat_url"
|
||||
geoip_asn_url="${cfg_geoip_asn_url:-$DEFAULT_GEOIP_ASN_URL}"
|
||||
;;
|
||||
*)
|
||||
mmdb_url="${cfg_mmdb_url:-$DEFAULT_MMDB_URL}"
|
||||
geosite_url="${cfg_geosite_url:-$DEFAULT_GEOSITE_URL}"
|
||||
geoip_dat_url="${cfg_geoip_dat_url:-$DEFAULT_GEOIP_DAT_URL}"
|
||||
geoip_asn_url="${cfg_geoip_asn_url:-$DEFAULT_GEOIP_ASN_URL}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$geoip_source" != "1" ]; then
|
||||
log "Updating Country.mmdb"
|
||||
if ! download_mmdb "$mmdb_url"; then
|
||||
log "Country.mmdb download failed"
|
||||
exit 1
|
||||
fi
|
||||
log "Country.mmdb updated"
|
||||
|
||||
if config_needs_geosite; then
|
||||
download_geosite "$geosite_url"
|
||||
else
|
||||
log "GeoSite.dat skip (no geosite reference in config)"
|
||||
fi
|
||||
download_optional "$geoip_dat_url" /etc/clashoo/geoip.dat "geoip.dat"
|
||||
download_optional "$geoip_asn_url" /etc/clashoo/GeoLite2-ASN.mmdb "GeoLite2-ASN.mmdb"
|
||||
fi
|
||||
|
||||
touch /var/run/geoip_down_complete >/dev/null 2>&1
|
||||
|
||||
if pidof mihomo >/dev/null 2>&1 || pidof clash-meta >/dev/null 2>&1; then
|
||||
log "GeoIP update completed, apply on next Clashoo restart"
|
||||
fi
|
||||
|
||||
log "GeoIP update completed"
|
||||
exit 0
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODEL_PATH="/etc/clashoo/Model.bin"
|
||||
TMP_PATH="/tmp/clash_Model.bin"
|
||||
LOG_FILE="/tmp/lgbm_update.log"
|
||||
|
||||
log() {
|
||||
echo " $(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
: > "$LOG_FILE"
|
||||
|
||||
DOWNLOAD_URL=$(uci get clashoo.config.smart_lgbm_url 2>/dev/null)
|
||||
[ -z "$DOWNLOAD_URL" ] && DOWNLOAD_URL="https://github.com/vernesong/mihomo/releases/download/LightGBM-Model/Model.bin"
|
||||
|
||||
log "Start downloading LightGBM model from: $DOWNLOAD_URL"
|
||||
|
||||
mkdir -p /etc/clashoo
|
||||
|
||||
rm -f "$TMP_PATH" 2>/dev/null
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$DOWNLOAD_URL" -o "$TMP_PATH" 2>/dev/null
|
||||
dl_rc=$?
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q --timeout=30 -U "Clash/OpenWRT" "$DOWNLOAD_URL" -O "$TMP_PATH" 2>/dev/null
|
||||
dl_rc=$?
|
||||
else
|
||||
log "No curl or wget found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$dl_rc" -ne 0 ] || [ ! -s "$TMP_PATH" ]; then
|
||||
log "Download failed (rc=$dl_rc)"
|
||||
rm -f "$TMP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$MODEL_PATH" ] && cmp -s "$TMP_PATH" "$MODEL_PATH"; then
|
||||
log "Model unchanged, no update needed"
|
||||
rm -f "$TMP_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mv "$TMP_PATH" "$MODEL_PATH" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
log "Failed to install model to $MODEL_PATH"
|
||||
rm -f "$TMP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "LightGBM model updated successfully"
|
||||
exit 0
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
|
||||
STATE_DIR="${CLASHOO_STATE_DIR:-/usr/share/clashbackup}"
|
||||
UPDATE_SCRIPT="${CLASHOO_UPDATE_SCRIPT:-/usr/share/clashoo/update/update_all.sh}"
|
||||
LOG_FILE="${CLASHOO_LOG_FILE:-/usr/share/clashoo/clashoo.txt}"
|
||||
LOCK_DIR="${CLASHOO_LOCK_DIR:-/tmp/clashoo_maintenance.lock}"
|
||||
NOW="${CLASHOO_NOW:-$(date +%s)}"
|
||||
|
||||
stamp() {
|
||||
tmp="$STATE_DIR/$1.tmp.$$"
|
||||
printf 'last_run=%s\n' "$NOW" >"$tmp" && mv -f "$tmp" "$STATE_DIR/$1"
|
||||
}
|
||||
|
||||
due() {
|
||||
hours="$(uci -q get "clashoo.config.$2")"
|
||||
echo "$hours" | grep -Eq '^[0-9]+$' && [ "$hours" -ge 1 ] || hours=12
|
||||
last="$(sed -n 's/^last_run=//p' "$STATE_DIR/$1" 2>/dev/null | head -1)"
|
||||
if ! echo "$last" | grep -Eq '^[0-9]+$' || [ "$last" -gt "$NOW" ]; then
|
||||
stamp "$1"
|
||||
return 1
|
||||
fi
|
||||
[ $((NOW - last)) -ge $((hours * 3600)) ]
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR" || exit 0
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
||||
trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1' EXIT INT TERM
|
||||
|
||||
if [ "$(uci -q get clashoo.config.auto_update)" = "1" ] && due rule_update.status auto_update_time; then
|
||||
CLASHOO_STATE_DIR="$STATE_DIR" sh "$UPDATE_SCRIPT" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
if [ "$(uci -q get clashoo.config.auto_clear_log)" = "1" ] && due log_cleanup.status clear_time; then
|
||||
: >"$LOG_FILE"
|
||||
stamp log_cleanup.status
|
||||
fi
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
#!/bin/sh
|
||||
|
||||
REAL_LOG="/usr/share/clashoo/clashoo_real.txt"
|
||||
panel="$1"
|
||||
lang="$(uci -q get luci.main.lang 2>/dev/null)"
|
||||
STATE_FILE="/tmp/clash_panel_download_state"
|
||||
RUN_FILE="/var/run/panel_downloading"
|
||||
DASHBOARD_LINK="/etc/clashoo/dashboard"
|
||||
|
||||
[ -n "$panel" ] || panel="$(uci -q get clashoo.config.dashboard_panel 2>/dev/null)"
|
||||
[ -n "$panel" ] || panel="zashboard"
|
||||
|
||||
case "$panel" in
|
||||
metacubexd)
|
||||
URLS="https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
|
||||
TARGET_DIR="/etc/clashoo/dashboard-metacubexd"
|
||||
;;
|
||||
yacd)
|
||||
URLS="https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip https://github.com/haishanh/yacd/archive/refs/heads/gh-pages.zip"
|
||||
TARGET_DIR="/etc/clashoo/dashboard-yacd"
|
||||
;;
|
||||
zashboard)
|
||||
URLS="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
|
||||
TARGET_DIR="/etc/clashoo/dashboard-zashboard"
|
||||
;;
|
||||
razord)
|
||||
URLS="https://github.com/MetaCubeX/Razord-meta/archive/refs/heads/gh-pages.zip https://github.com/ayanamist/clash-dashboard/archive/refs/heads/gh-pages.zip"
|
||||
TARGET_DIR="/etc/clashoo/dashboard-razord"
|
||||
;;
|
||||
*)
|
||||
URLS="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
|
||||
TARGET_DIR="/etc/clashoo/dashboard-zashboard"
|
||||
panel="zashboard"
|
||||
;;
|
||||
esac
|
||||
|
||||
TMP_ROOT="/tmp/clash_panel_${panel}_$$"
|
||||
ZIP_FILE="$TMP_ROOT/panel.zip"
|
||||
UNPACK_DIR="$TMP_ROOT/unpack"
|
||||
|
||||
UPDATE_LOG="/tmp/clash_update.txt"
|
||||
|
||||
update_log() {
|
||||
[ -n "$1" ] || return 0
|
||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
|
||||
}
|
||||
|
||||
log_msg() {
|
||||
if [ "$lang" = "en" ]; then
|
||||
echo "$1" >"$REAL_LOG"
|
||||
[ "$3" = "1" ] || update_log "$1"
|
||||
else
|
||||
echo "$2" >"$REAL_LOG"
|
||||
[ "$3" = "1" ] || update_log "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
set_state() {
|
||||
state="$1"
|
||||
msg="$2"
|
||||
echo "${state}:${panel}:${msg}" >"$STATE_FILE"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_ROOT" >/dev/null 2>&1
|
||||
rm -f "$RUN_FILE" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Route the panel download through the running core in kernel-only mode
|
||||
# (shared logic in proxy_lib.sh). Normal mode returns empty -> TPROXY.
|
||||
. /usr/share/clashoo/update/proxy_lib.sh
|
||||
detect_proxy() { clashoo_detect_proxy; }
|
||||
|
||||
download_zip() {
|
||||
proxy="$(detect_proxy)"
|
||||
for url in $URLS; do
|
||||
if [ -n "$proxy" ] && command -v curl >/dev/null 2>&1 &&
|
||||
curl -fsSL --connect-timeout 15 --max-time 300 --retry 1 \
|
||||
--proxy "$proxy" -A "Clash/OpenWRT" "$url" -o "$ZIP_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
if wget -q --timeout=60 --user-agent="Clash/OpenWRT" "$url" -O "$ZIP_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
validate_zip_entries() {
|
||||
entries="$TMP_ROOT/entries.txt"
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -Z1 "$ZIP_FILE" >"$entries" 2>/dev/null || return 1
|
||||
elif command -v bsdtar >/dev/null 2>&1; then
|
||||
bsdtar -tf "$ZIP_FILE" >"$entries" 2>/dev/null || return 1
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
[ -s "$entries" ] || return 1
|
||||
awk '
|
||||
/^\// || /^[A-Za-z]:/ || /(^|\/)\.\.(\/|$)/ || /\\/ || /\r/ { bad = 1 }
|
||||
END { exit bad }
|
||||
' "$entries"
|
||||
}
|
||||
|
||||
extract_zip() {
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -oq "$ZIP_FILE" -d "$UNPACK_DIR" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v busybox >/dev/null 2>&1 && busybox --list 2>/dev/null | grep -qx "unzip"; then
|
||||
busybox unzip -oq "$ZIP_FILE" -d "$UNPACK_DIR" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v bsdtar >/dev/null 2>&1; then
|
||||
bsdtar -xf "$ZIP_FILE" -C "$UNPACK_DIR" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
find_web_root() {
|
||||
if [ -f "$UNPACK_DIR/index.html" ]; then
|
||||
echo "$UNPACK_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
index_file="$(find "$UNPACK_DIR" -type f -name index.html 2>/dev/null | head -n 1)"
|
||||
if [ -n "$index_file" ]; then
|
||||
dirname "$index_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
activate_panel() {
|
||||
rm -rf "$DASHBOARD_LINK" >/dev/null 2>&1
|
||||
ln -s "$TARGET_DIR" "$DASHBOARD_LINK" >/dev/null 2>&1 || cp -a "$TARGET_DIR" "$DASHBOARD_LINK" >/dev/null 2>&1
|
||||
rm -rf /www/luci-static/yacd >/dev/null 2>&1
|
||||
if [ "$panel" = "yacd" ]; then
|
||||
ln -s "$TARGET_DIR" /www/luci-static/yacd >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
mkdir -p "$UNPACK_DIR" "$TARGET_DIR" >/dev/null 2>&1
|
||||
touch "$RUN_FILE"
|
||||
set_state "downloading" "开始下载"
|
||||
|
||||
log_msg "Downloading dashboard panel..." "正在下载面板..."
|
||||
if ! download_zip; then
|
||||
set_state "error" "下载失败"
|
||||
log_msg "Dashboard panel download failed" "面板下载失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! validate_zip_entries; then
|
||||
set_state "error" "压缩包路径无效"
|
||||
log_msg "Dashboard panel archive has unsafe entries" "面板压缩包含不安全路径"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extract_zip
|
||||
extract_rc=$?
|
||||
if [ "$extract_rc" -ne 0 ]; then
|
||||
if [ "$extract_rc" -eq 2 ]; then
|
||||
set_state "error" "缺少解压工具"
|
||||
log_msg "Panel unzip tool not found" "缺少解压工具,无法安装面板"
|
||||
else
|
||||
set_state "error" "解压失败"
|
||||
log_msg "Dashboard panel unzip failed" "面板解压失败"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if find "$UNPACK_DIR" ! -type d ! -type f | grep -q .; then
|
||||
set_state "error" "压缩包文件类型无效"
|
||||
log_msg "Dashboard panel archive has unsafe file types" "面板压缩包含不安全文件类型"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC_DIR="$(find_web_root)"
|
||||
if [ -z "$SRC_DIR" ] || [ ! -f "$SRC_DIR/index.html" ]; then
|
||||
set_state "error" "文件无效"
|
||||
log_msg "Dashboard panel content invalid" "面板文件无效"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$TARGET_DIR"/* >/dev/null 2>&1
|
||||
cp -a "$SRC_DIR"/. "$TARGET_DIR"/ >/dev/null 2>&1
|
||||
|
||||
activate_panel
|
||||
|
||||
uci set clashoo.config.dashboard_panel="$panel" >/dev/null 2>&1
|
||||
uci commit clashoo >/dev/null 2>&1
|
||||
|
||||
set_state "success" "安装成功"
|
||||
log_msg "Dashboard panel installed" "面板安装完成"
|
||||
sleep 1
|
||||
log_msg "Clashoo" "Clashoo" 1
|
||||
exit 0
|
||||
@@ -0,0 +1,80 @@
|
||||
# shellcheck shell=sh
|
||||
# Clashoo's sing-box is private; HomeProxy owns the public system binary.
|
||||
CLASHOO_SINGBOX_BIN_DIR="/usr/share/clashoo/bin"
|
||||
CLASHOO_SINGBOX_RUNTIME_CONFIG="/var/run/clashoo/sing-box/config.json"
|
||||
|
||||
# Shared proxy detection for clashoo's own outbound requests.
|
||||
#
|
||||
# clashoo's self-initiated downloads (update checks, component/panel/core/geoip
|
||||
# downloads, subscriptions) and health checks must reach GitHub through the
|
||||
# running core, otherwise they leak out direct and stall behind the GFW. Route
|
||||
# them through the core's mixed port whenever a core is running — in BOTH modes:
|
||||
# - core-only mode deliberately skips TPROXY to coexist with other plugins;
|
||||
# - normal mode used to rely on transparent redirect, but the router's OWN
|
||||
# output redirect is fragile (e.g. PPPoE/GL.iNet stacks where Redirect to
|
||||
# loopback never completes — issue #25), so don't depend on it. The explicit
|
||||
# mixed port works on every network stack.
|
||||
# When no core is running (e.g. bootstrapping the very first core download),
|
||||
# returns empty so the caller falls back to direct / mirror sources.
|
||||
#
|
||||
# Port source depends on the running Clashoo kernel. The liveness gate uses the
|
||||
# procd owner instead of a binary name, because other proxy plugins may run the
|
||||
# same binaries:
|
||||
# sing-box -> private runtime config mixed inbound
|
||||
# (an imported profile may carry its own port)
|
||||
# mihomo / clash-meta / smart -> /etc/clashoo/config.yaml mixed-port
|
||||
# (a custom config's port may differ from uci)
|
||||
# uci mixed_port is the last-resort fallback for both.
|
||||
clashoo_running_core_type() {
|
||||
_cdp_type="$(uci -q get clashoo.config.core_type 2>/dev/null)"
|
||||
case "$_cdp_type" in
|
||||
singbox)
|
||||
_cdp_service="clashoo"
|
||||
_cdp_filter='@.clashoo.instances.singbox.pid'
|
||||
;;
|
||||
*)
|
||||
_cdp_type="mihomo"
|
||||
_cdp_service="clashoo"
|
||||
_cdp_filter='@.clashoo.instances.*.pid'
|
||||
;;
|
||||
esac
|
||||
|
||||
_cdp_pid="$(ubus call service list "{\"name\":\"$_cdp_service\",\"verbose\":true}" 2>/dev/null \
|
||||
| jsonfilter -e "$_cdp_filter" 2>/dev/null | head -n1)"
|
||||
[ -n "$_cdp_pid" ] && [ -d "/proc/$_cdp_pid" ] || return 1
|
||||
if [ "$_cdp_type" = "singbox" ]; then
|
||||
local _cdp_exe _cdp_cmdline
|
||||
_cdp_exe="$(readlink -f "/proc/$_cdp_pid/exe" 2>/dev/null)"
|
||||
case "$_cdp_exe" in
|
||||
"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha") ;;
|
||||
*)
|
||||
_cdp_cmdline="$(tr '\000' ' ' < "/proc/$_cdp_pid/cmdline" 2>/dev/null)"
|
||||
case "$_cdp_cmdline" in
|
||||
"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable "*|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha "*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
printf '%s' "$_cdp_type"
|
||||
}
|
||||
|
||||
clashoo_detect_proxy() {
|
||||
_cdp_type="$(clashoo_running_core_type)" || return 0
|
||||
_cdp_port=""
|
||||
if [ "$_cdp_type" = "singbox" ]; then
|
||||
_cdp_port="$(jsonfilter -i "$CLASHOO_SINGBOX_RUNTIME_CONFIG" \
|
||||
-e '@.inbounds[@.type="mixed"].listen_port' 2>/dev/null | head -n 1)"
|
||||
else
|
||||
# prefer mixed-port (HTTP+SOCKS) over plain port; a single two-pattern
|
||||
# sed would return whichever appears first by line order, so loop by key
|
||||
for _cdp_key in mixed-port port socks-port; do
|
||||
_cdp_port="$(sed -n "s/^[[:space:]]*${_cdp_key}:[[:space:]]*\([0-9][0-9]*\).*/\1/p" \
|
||||
/etc/clashoo/config.yaml 2>/dev/null | head -n 1)"
|
||||
[ -n "$_cdp_port" ] && break
|
||||
done
|
||||
fi
|
||||
[ -n "$_cdp_port" ] || _cdp_port="$(uci -q get clashoo.config.mixed_port 2>/dev/null)"
|
||||
[ -n "$_cdp_port" ] || return 0
|
||||
printf 'http://127.0.0.1:%s' "$_cdp_port"
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/bin/sh
|
||||
|
||||
SUB_DIR="${CLASHOO_SUB_DIR:-/usr/share/clashoo/config/sub}"
|
||||
SINGBOX_DIR="${CLASHOO_SINGBOX_DIR:-/usr/share/clashoo/config/singbox}"
|
||||
BACKUP_DIR="${CLASHOO_BACKUP_DIR:-/usr/share/clashbackup}"
|
||||
TEMPLATE_DIR="${CLASHOO_TEMPLATE_DIR:-/usr/share/clashoo/config/custom}"
|
||||
LIST_FILE="${CLASHOO_LIST_FILE:-$BACKUP_DIR/confit_list.conf}"
|
||||
BIND_FILE="${CLASHOO_BIND_FILE:-$BACKUP_DIR/template_bindings.conf}"
|
||||
STATUS_FILE="${CLASHOO_STATUS_FILE:-$BACKUP_DIR/subscription_update.status}"
|
||||
LOCK_DIR="${CLASHOO_LOCK_DIR:-/tmp/clashoo_subscription_update.lock}"
|
||||
UPDATE_LOG="${CLASHOO_UPDATE_LOG:-/tmp/clash_update.txt}"
|
||||
SERVICE_CMD="${CLASHOO_SERVICE_CMD:-/etc/init.d/clashoo}"
|
||||
TMP_DIR="${CLASHOO_TMP_DIR:-/tmp}"
|
||||
DEFAULT_SUB_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
DOWNLOAD_ERROR=""
|
||||
|
||||
# Subscriptions are fetched direct first (many airports are domestic / geo-fence
|
||||
# foreign exit IPs). Only used as a last-resort fallback, see download_to.
|
||||
. /usr/share/clashoo/update/proxy_lib.sh
|
||||
|
||||
updated=0
|
||||
unchanged=0
|
||||
failed=0
|
||||
skipped=0
|
||||
restart_needed=0
|
||||
started_at="$(date +%s)"
|
||||
message=""
|
||||
record_status=0
|
||||
[ "$1" = "--all" ] && record_status=1
|
||||
|
||||
log_update() {
|
||||
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local tmp
|
||||
mkdir -p "$(dirname "$STATUS_FILE")" >/dev/null 2>&1
|
||||
tmp="${STATUS_FILE}.tmp.$$"
|
||||
cat >"$tmp" <<EOF
|
||||
running=$1
|
||||
last_run=$started_at
|
||||
finished_at=$2
|
||||
updated=$updated
|
||||
unchanged=$unchanged
|
||||
failed=$failed
|
||||
skipped=$skipped
|
||||
message=$message
|
||||
EOF
|
||||
mv "$tmp" "$STATUS_FILE"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$LOCK_DIR" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if ! mkdir "$LOCK_DIR" >/dev/null 2>&1; then
|
||||
exit 75
|
||||
fi
|
||||
trap cleanup EXIT INT TERM
|
||||
[ "$record_status" = "1" ] && write_status 1 0
|
||||
|
||||
safe_name() {
|
||||
case "$1" in
|
||||
''|*/*|*..*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
extract_host() {
|
||||
printf '%s' "$1" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's#:.*$##' -e 's#.*@##'
|
||||
}
|
||||
|
||||
resolve_via() {
|
||||
local host dns
|
||||
host="$1"
|
||||
dns="$2"
|
||||
nslookup "$host" "$dns" 2>/dev/null | awk '
|
||||
/^Address/ {
|
||||
ip = $NF
|
||||
if (ip ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ &&
|
||||
ip !~ /^127\./ && ip !~ /^0\./ && ip !~ /^198\.18\./ &&
|
||||
ip != "8.8.8.8" && ip != "1.1.1.1" &&
|
||||
ip != "223.5.5.5" && ip != "119.29.29.29") {
|
||||
print ip
|
||||
exit
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
curl_download() {
|
||||
local url out hdr ua host ip
|
||||
url="$1"
|
||||
out="$2"
|
||||
hdr="$3"
|
||||
ua="$4"
|
||||
host="$5"
|
||||
ip="$6"
|
||||
if [ -n "$host" ] && [ -n "$ip" ]; then
|
||||
curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
|
||||
--speed-limit 1 --retry 2 -A "$ua" -D "$hdr" -o "$out" \
|
||||
--resolve "$host:443:$ip" --resolve "$host:80:$ip" \
|
||||
-w '%{http_code}' "$url" 2>/dev/null
|
||||
else
|
||||
curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
|
||||
--speed-limit 1 --retry 2 -A "$ua" -D "$hdr" -o "$out" \
|
||||
-w '%{http_code}' "$url" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
subscription_ua() {
|
||||
local target ua
|
||||
target="$1"
|
||||
ua=""
|
||||
[ -r "${target}.ua" ] && ua="$(sed -n '1p' "${target}.ua" 2>/dev/null)"
|
||||
[ -n "$ua" ] || ua="$(uci -q get clashoo.config.sub_ua 2>/dev/null)"
|
||||
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
|
||||
printf '%s' "$ua"
|
||||
}
|
||||
|
||||
save_subscription_ua() {
|
||||
printf '%s\n' "$2" >"${1}.ua"
|
||||
}
|
||||
|
||||
curl_error() {
|
||||
case "$1" in
|
||||
6) printf '%s' "DNS 解析失败" ;;
|
||||
7) printf '%s' "连接失败" ;;
|
||||
28) printf '%s' "连接超时" ;;
|
||||
35) printf '%s' "TLS 握手失败" ;;
|
||||
60) printf '%s' "证书校验失败" ;;
|
||||
*) printf '网络错误(curl rc=%s)' "${1:-unknown}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_to() {
|
||||
local url out hdr ua code rc last_rc host dns ip proxy
|
||||
url="$1"
|
||||
out="$2"
|
||||
hdr="$3"
|
||||
ua="$4"
|
||||
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
|
||||
DOWNLOAD_ERROR=""
|
||||
|
||||
rm -f "$out" "$hdr" >/dev/null 2>&1
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
code="$(curl_download "$url" "$out" "$hdr" "$ua" "" "")"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ] && [ "$code" = "200" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
|
||||
DOWNLOAD_ERROR="HTTP ${code}"
|
||||
return 1
|
||||
fi
|
||||
last_rc="$rc"
|
||||
host="$(extract_host "$url")"
|
||||
for dns in 223.5.5.5 119.29.29.29 1.1.1.1 8.8.8.8; do
|
||||
ip="$(resolve_via "$host" "$dns")"
|
||||
[ -n "$ip" ] || continue
|
||||
log_update "DNS 回退:${host} -> ${ip} (@${dns})"
|
||||
code="$(curl_download "$url" "$out" "$hdr" "$ua" "$host" "$ip")"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] && [ "$code" = "200" ] && return 0
|
||||
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
|
||||
DOWNLOAD_ERROR="HTTP ${code}"
|
||||
return 1
|
||||
fi
|
||||
last_rc="$rc"
|
||||
done
|
||||
# Last resort: direct + DNS-override both failed, so the source may be
|
||||
# GFW-blocked (e.g. a github-hosted sub). In kernel-only mode try once
|
||||
# through the running core — nothing left to lose, and it won't disturb
|
||||
# any airport that already answered direct.
|
||||
proxy="$(clashoo_detect_proxy)"
|
||||
if [ -n "$proxy" ]; then
|
||||
log_update "代理兜底:通过本地核心重试订阅"
|
||||
code="$(curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
|
||||
--speed-limit 1 --retry 1 -A "$ua" -D "$hdr" -o "$out" \
|
||||
--proxy "$proxy" -w '%{http_code}' "$url" 2>/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] && [ "$code" = "200" ] && return 0
|
||||
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
|
||||
DOWNLOAD_ERROR="HTTP ${code}"
|
||||
return 1
|
||||
fi
|
||||
last_rc="$rc"
|
||||
fi
|
||||
DOWNLOAD_ERROR="$(curl_error "$last_rc")"
|
||||
return 1
|
||||
fi
|
||||
wget -q --timeout=20 --user-agent="$ua" "$url" -O "$out"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] && return 0
|
||||
DOWNLOAD_ERROR="网络错误(wget rc=${rc})"
|
||||
return 1
|
||||
}
|
||||
|
||||
update_info() {
|
||||
local hdr target info
|
||||
hdr="$1"
|
||||
target="$2"
|
||||
info="$(grep -i 'subscription-userinfo:' "$hdr" 2>/dev/null | head -1 | \
|
||||
sed 's/^[Ss]ubscription-[Uu]serinfo:[[:space:]]*//' | tr -d '\r')"
|
||||
if [ -n "$info" ]; then
|
||||
printf '%s\n' "$info" >"${target}.info"
|
||||
else
|
||||
rm -f "${target}.info" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
service_running() {
|
||||
[ "$("$SERVICE_CMD" status 2>/dev/null)" = "running" ]
|
||||
}
|
||||
|
||||
template_output_name() {
|
||||
local sub tpl
|
||||
sub="$(printf '%s' "$1" | sed -e 's/\.[Yy][Aa][Mm][Ll]$//' -e 's/\.[Yy][Mm][Ll]$//' -e 's/[^A-Za-z0-9._-]/-/g')"
|
||||
tpl="$(printf '%s' "$2" | sed -e 's/\.[Yy][Aa][Mm][Ll]$//' -e 's/\.[Yy][Mm][Ll]$//' -e 's/[^A-Za-z0-9._-]/-/g')"
|
||||
printf '_merged_%s__%s.yaml' "${sub:-sub}" "${tpl:-template}"
|
||||
}
|
||||
|
||||
apply_template() {
|
||||
local name target template merged merged_path use_config
|
||||
name="$1"
|
||||
target="$2"
|
||||
[ -r "$BIND_FILE" ] || return 0
|
||||
[ -x /usr/share/clashoo/update/template_merge.sh ] || return 0
|
||||
template="$(awk -F '#' -v n="$name" '$1==n && ($3=="1" || $3=="true") {print $2; exit}' "$BIND_FILE")"
|
||||
[ -n "$template" ] && [ -r "$TEMPLATE_DIR/$template" ] || return 0
|
||||
merged="$(template_output_name "$name" "$template")"
|
||||
merged_path="$TEMPLATE_DIR/$merged"
|
||||
if sh /usr/share/clashoo/update/template_merge.sh "$target" "$TEMPLATE_DIR/$template" "$merged_path" >/dev/null 2>&1; then
|
||||
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
|
||||
if [ "$use_config" = "$target" ] || [ "$use_config" = "$merged_path" ]; then
|
||||
uci -q set clashoo.config.use_config="$merged_path"
|
||||
uci -q set clashoo.config.config_type='3'
|
||||
uci -q commit clashoo >/dev/null 2>&1
|
||||
restart_needed=1
|
||||
fi
|
||||
else
|
||||
log_update "模板生成失败:${name} <- ${template}"
|
||||
fi
|
||||
}
|
||||
|
||||
update_mihomo() {
|
||||
local name url typ target tmp hdr ua use_config config_type
|
||||
name="$1"
|
||||
url="$2"
|
||||
typ="$3"
|
||||
safe_name "$name" || return 1
|
||||
case "$typ" in clash|meta) ;; *) skipped=$((skipped + 1)); return 0 ;; esac
|
||||
target="$SUB_DIR/$name"
|
||||
[ -f "$target" ] || { skipped=$((skipped + 1)); return 0; }
|
||||
tmp="$TMP_DIR/clashoo_sub_$$.yaml"
|
||||
hdr="$TMP_DIR/clashoo_sub_$$.hdr"
|
||||
ua="$(subscription_ua "$target")"
|
||||
if ! download_to "$url" "$tmp" "$hdr" "$ua"; then
|
||||
failed=$((failed + 1))
|
||||
log_update "更新失败(${DOWNLOAD_ERROR:-下载失败}):$name"
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 1
|
||||
fi
|
||||
if ! grep -Eq '^(proxies|proxy-providers):' "$tmp" 2>/dev/null; then
|
||||
failed=$((failed + 1))
|
||||
log_update "更新失败(无效 Mihomo 配置):$name"
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 1
|
||||
fi
|
||||
if cmp -s "$tmp" "$target"; then
|
||||
update_info "$hdr" "$target"
|
||||
save_subscription_ua "$target" "$ua"
|
||||
unchanged=$((unchanged + 1))
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 0
|
||||
fi
|
||||
if ! mv "$tmp" "$target"; then
|
||||
failed=$((failed + 1))
|
||||
rm -f "$hdr"
|
||||
return 1
|
||||
fi
|
||||
update_info "$hdr" "$target"
|
||||
save_subscription_ua "$target" "$ua"
|
||||
rm -f "$hdr"
|
||||
updated=$((updated + 1))
|
||||
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
|
||||
config_type="$(uci -q get clashoo.config.config_type 2>/dev/null)"
|
||||
[ "$config_type" = "1" ] && [ "$use_config" = "$target" ] && restart_needed=1
|
||||
apply_template "$name" "$target"
|
||||
log_update "更新完成:$name"
|
||||
}
|
||||
|
||||
valid_singbox() {
|
||||
ucode -e 'import { readfile } from "fs"; let c = json(readfile(ARGV[0])); exit(type(c) == "object" && type(c.outbounds) == "array" && length(c.outbounds) > 0 ? 0 : 1);' "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
update_singbox() {
|
||||
local name target url tmp hdr ua active
|
||||
name="$1"
|
||||
safe_name "$name" || return 1
|
||||
target="$SINGBOX_DIR/$name"
|
||||
[ -f "$target" ] && [ -r "$target.url" ] || { skipped=$((skipped + 1)); return 0; }
|
||||
url="$(sed -n '1p' "$target.url")"
|
||||
[ -n "$url" ] || { skipped=$((skipped + 1)); return 0; }
|
||||
tmp="$TMP_DIR/clashoo_sb_$$.json"
|
||||
hdr="$TMP_DIR/clashoo_sb_$$.hdr"
|
||||
ua="$(subscription_ua "$target")"
|
||||
if ! download_to "$url" "$tmp" "$hdr" "$ua"; then
|
||||
failed=$((failed + 1))
|
||||
log_update "更新失败(${DOWNLOAD_ERROR:-下载失败}):$name"
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 1
|
||||
fi
|
||||
if ! valid_singbox "$tmp"; then
|
||||
failed=$((failed + 1))
|
||||
log_update "更新失败(无效 sing-box 配置):$name"
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 1
|
||||
fi
|
||||
if cmp -s "$tmp" "$target"; then
|
||||
update_info "$hdr" "$target"
|
||||
save_subscription_ua "$target" "$ua"
|
||||
unchanged=$((unchanged + 1))
|
||||
rm -f "$tmp" "$hdr"
|
||||
return 0
|
||||
fi
|
||||
if ! mv "$tmp" "$target"; then
|
||||
failed=$((failed + 1))
|
||||
rm -f "$hdr"
|
||||
return 1
|
||||
fi
|
||||
update_info "$hdr" "$target"
|
||||
save_subscription_ua "$target" "$ua"
|
||||
rm -f "$hdr"
|
||||
updated=$((updated + 1))
|
||||
active="$(uci -q get clashoo.config.singbox_active 2>/dev/null)"
|
||||
[ "$active" = "$name" ] && restart_needed=1
|
||||
log_update "更新完成:$name"
|
||||
}
|
||||
|
||||
update_all() {
|
||||
local name url typ url_file
|
||||
if [ -r "$LIST_FILE" ]; then
|
||||
while IFS='#' read -r name url typ _rest; do
|
||||
[ -n "$name" ] && [ -n "$url" ] || continue
|
||||
update_mihomo "$name" "$url" "$typ" || true
|
||||
done <"$LIST_FILE"
|
||||
fi
|
||||
for url_file in "$SINGBOX_DIR"/*.url; do
|
||||
[ -f "$url_file" ] || continue
|
||||
name="$(basename "$url_file" .url)"
|
||||
update_singbox "$name" || true
|
||||
done
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
--all)
|
||||
update_all
|
||||
;;
|
||||
--mihomo)
|
||||
line="$(awk -F '#' -v n="$2" '$1==n {print; exit}' "$LIST_FILE" 2>/dev/null)"
|
||||
[ -n "$line" ] || { failed=1; message="subscription not found"; }
|
||||
if [ -n "$line" ]; then
|
||||
name="$(printf '%s' "$line" | awk -F '#' '{print $1}')"
|
||||
url="$(printf '%s' "$line" | awk -F '#' '{print $2}')"
|
||||
typ="$(printf '%s' "$line" | awk -F '#' '{print $3}')"
|
||||
update_mihomo "$name" "$url" "$typ" || true
|
||||
fi
|
||||
;;
|
||||
--singbox)
|
||||
update_singbox "$2" || true
|
||||
;;
|
||||
*)
|
||||
failed=1
|
||||
message="invalid arguments"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$restart_needed" = "1" ] && service_running; then
|
||||
"$SERVICE_CMD" restart >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
finished_at="$(date +%s)"
|
||||
[ -n "$message" ] || message="updated=$updated unchanged=$unchanged failed=$failed skipped=$skipped"
|
||||
[ "$record_status" = "1" ] && write_status 0 "$finished_at"
|
||||
[ "$failed" -eq 0 ]
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
STATUS_FILE="${CLASHOO_STATUS_FILE:-/usr/share/clashbackup/subscription_update.status}"
|
||||
UPDATER="${CLASHOO_SUBSCRIPTION_UPDATER:-/usr/share/clashoo/update/subscription_update.sh}"
|
||||
SERVICE_CMD="${CLASHOO_SERVICE_CMD:-/etc/init.d/clashoo}"
|
||||
interval="$(uci -q get clashoo.config.subscription_update_interval 2>/dev/null)"
|
||||
now="$(date +%s)"
|
||||
last=0
|
||||
|
||||
[ "$(uci -q get clashoo.config.auto_subscription_update 2>/dev/null)" = "1" ] || exit 0
|
||||
"$SERVICE_CMD" status >/dev/null 2>&1 || exit 0
|
||||
echo "$interval" | grep -Eq '^[0-9]+$' || interval=72
|
||||
[ "$interval" -gt 0 ] || interval=72
|
||||
[ -r "$STATUS_FILE" ] && last="$(sed -n 's/^last_run=//p' "$STATUS_FILE" | head -1)"
|
||||
echo "$last" | grep -Eq '^[0-9]+$' || last=0
|
||||
|
||||
[ $((now - last)) -ge $((interval * 3600)) ] || exit 0
|
||||
sh "$UPDATER" --all
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SUB_FILE="${1:-}"
|
||||
TEMPLATE_FILE="${2:-}"
|
||||
OUT_FILE="${3:-}"
|
||||
TMP_FILE="/tmp/clash_template_merge_$$.yaml"
|
||||
TMP_FILE2="${TMP_FILE}.norm"
|
||||
LOG_FILE="/tmp/clash_update.txt"
|
||||
|
||||
log() {
|
||||
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$LOG_FILE"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -f "$TMP_FILE" "$TMP_FILE2" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
[ -n "$SUB_FILE" ] || { log "模板复写失败:缺少订阅文件参数"; exit 1; }
|
||||
[ -n "$TEMPLATE_FILE" ] || { log "模板复写失败:缺少模板文件参数"; exit 1; }
|
||||
[ -n "$OUT_FILE" ] || { log "模板复写失败:缺少输出文件参数"; exit 1; }
|
||||
|
||||
[ -f "$SUB_FILE" ] || { log "模板复写失败:订阅文件不存在"; exit 1; }
|
||||
[ -f "$TEMPLATE_FILE" ] || { log "模板复写失败:模板文件不存在"; exit 1; }
|
||||
|
||||
if ! command -v yq >/dev/null 2>&1; then
|
||||
log "模板复写失败:缺少 yq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
core_bin=""
|
||||
for b in mihomo clash-meta clash; do
|
||||
if command -v "$b" >/dev/null 2>&1; then
|
||||
core_bin="$b"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$(dirname "$OUT_FILE")" >/dev/null 2>&1
|
||||
|
||||
log "开始模板复写:$(basename "$SUB_FILE") <- $(basename "$TEMPLATE_FILE")"
|
||||
export SUB_FILE TEMPLATE_FILE
|
||||
|
||||
# template is base; load() forces single-doc; explode anchors first (yq 4.53+ breaks "<<")
|
||||
yq -n '
|
||||
(load(strenv(SUB_FILE)) | explode(.)) as $sub |
|
||||
(load(strenv(TEMPLATE_FILE)) | explode(.)) as $tpl |
|
||||
(
|
||||
($tpl * {
|
||||
"proxies": (((($tpl.proxies // []) + ($sub.proxies // [])) | unique_by(.name))),
|
||||
"proxy-providers": (($tpl."proxy-providers" // {}) * ($sub."proxy-providers" // {}))
|
||||
})
|
||||
| .dns = ((.dns // {}) * {
|
||||
"proxy-server-nameserver": (
|
||||
.dns."proxy-server-nameserver"
|
||||
// $sub.dns."proxy-server-nameserver"
|
||||
// $sub.dns.nameserver
|
||||
// ["223.5.5.5", "119.29.29.29"]
|
||||
)
|
||||
})
|
||||
)
|
||||
' >"$TMP_FILE" 2>/dev/null || {
|
||||
log "模板复写失败:YAML 合并错误"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 兼容第三方模板中使用“直连”字样,统一转换为 Clash 内置 DIRECT。
|
||||
yq e '
|
||||
(.. | select(tag == "!!str")) |= sub("^直连$", "DIRECT")
|
||||
| .rules = ((.rules // []) | map(sub(",直连,", ",DIRECT,") | sub(",直连$", ",DIRECT")))
|
||||
' "$TMP_FILE" >"$TMP_FILE2" 2>/dev/null || {
|
||||
log "模板复写失败:直连别名标准化失败"
|
||||
exit 1
|
||||
}
|
||||
mv -f "$TMP_FILE2" "$TMP_FILE" >/dev/null 2>&1 || {
|
||||
log "模板复写失败:中间文件写入失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
yq e '.' "$TMP_FILE" >/dev/null 2>&1 || {
|
||||
log "模板复写失败:合并结果 YAML 无效"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -n "$core_bin" ]; then
|
||||
"$core_bin" -t -f "$TMP_FILE" >/dev/null 2>&1 || {
|
||||
log "模板复写失败:内核校验不通过"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
mv -f "$TMP_FILE" "$OUT_FILE" >/dev/null 2>&1 || {
|
||||
log "模板复写失败:写入输出文件失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
chmod 644 "$OUT_FILE" >/dev/null 2>&1 || true
|
||||
log "模板复写完成:$(basename "$OUT_FILE")"
|
||||
exit 0
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
config_name="${1:-$(uci -q get clashoo.config.config_update_name 2>/dev/null)}"
|
||||
[ -n "$config_name" ] || exit 1
|
||||
exec sh /usr/share/clashoo/update/subscription_update.sh --mihomo "$config_name"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
|
||||
UPDATE_LOG="/tmp/clash_update.txt"
|
||||
STATE_DIR="${CLASHOO_STATE_DIR:-/usr/share/clashbackup}"
|
||||
|
||||
log_update() {
|
||||
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
|
||||
}
|
||||
|
||||
log_update "更新大陆白名单"
|
||||
sh /usr/share/clashoo/update/update_china_ip.sh >> "$UPDATE_LOG" 2>&1
|
||||
|
||||
log_update "更新 GeoIP / GeoSite"
|
||||
sh /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
tmp="$STATE_DIR/rule_update.status.tmp.$$"
|
||||
printf 'last_run=%s\n' "$(date +%s)" >"$tmp" && mv -f "$tmp" "$STATE_DIR/rule_update.status"
|
||||
|
||||
exit 0
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
LOG_FILE="${LOG_FILE:-/tmp/clash_update.txt}"
|
||||
NFT_DIR="${NFT_DIR:-/usr/share/clashoo/nftables}"
|
||||
TARGET_V4="${NFT_DIR}/geoip_cn.nft"
|
||||
TARGET_V6="${NFT_DIR}/geoip6_cn.nft"
|
||||
FW4_SCRIPT="${FW4_SCRIPT:-/usr/share/clashoo/net/fw4.sh}"
|
||||
PROC_ROOT="${PROC_ROOT:-/proc}"
|
||||
SINGBOX_BIN_DIR="${SINGBOX_BIN_DIR:-/usr/share/clashoo/bin}"
|
||||
TMP_ROOT="$(mktemp -d /tmp/clashoo_china_ip.XXXXXX)"
|
||||
TMP_V4="$TMP_ROOT/china_ip.txt"
|
||||
TMP_V6="$TMP_ROOT/china_ipv6.txt"
|
||||
OUT_V4="$TMP_ROOT/geoip_cn.nft"
|
||||
OUT_V6="$TMP_ROOT/geoip6_cn.nft"
|
||||
|
||||
log() {
|
||||
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
bool_enabled() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_with_fallback() {
|
||||
local url="$1"
|
||||
local output="$2"
|
||||
local ip
|
||||
|
||||
if curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$output"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$url" in
|
||||
https://ispip.clang.cn/*)
|
||||
for ip in 182.247.248.127 103.220.64.183; do
|
||||
if curl -fsSL --connect-timeout 10 --max-time 120 --resolve "ispip.clang.cn:443:${ip}" "$url" -o "$output"; then
|
||||
log "DNS 异常,已使用 --resolve(${ip}) 回源下载"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
render_nft_set() {
|
||||
local source_file="$1"
|
||||
local output_file="$2"
|
||||
local set_name="$3"
|
||||
local set_type="$4"
|
||||
|
||||
awk -v set_name="$set_name" -v set_type="$set_type" '
|
||||
function valid_cidr(value, parts, octets, i, prefix) {
|
||||
if (split(value, parts, "/") != 2 || parts[2] !~ /^[0-9]+$/)
|
||||
return 0
|
||||
prefix = parts[2] + 0
|
||||
if (set_type == "ipv4_addr") {
|
||||
if (prefix > 32 || split(parts[1], octets, ".") != 4)
|
||||
return 0
|
||||
for (i = 1; i <= 4; i++)
|
||||
if (octets[i] !~ /^[0-9]+$/ || length(octets[i]) > 3 || octets[i] + 0 > 255)
|
||||
return 0
|
||||
return 1
|
||||
}
|
||||
return set_type == "ipv6_addr" && prefix <= 128 &&
|
||||
parts[1] ~ /^[0-9A-Fa-f:]+$/ && index(parts[1], ":") > 0
|
||||
}
|
||||
BEGIN {
|
||||
print "set " set_name " {"
|
||||
print "\ttype " set_type ";"
|
||||
print "\tflags interval;"
|
||||
print "\tauto-merge;"
|
||||
print "\telements = {"
|
||||
first = 1
|
||||
}
|
||||
!/^[[:space:]]*$/ && !/^[[:space:]]*#/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
|
||||
if ($0 == "")
|
||||
next
|
||||
if (!valid_cidr($0)) {
|
||||
if (!invalid)
|
||||
printf "invalid CIDR at line %d\n", NR > "/dev/stderr"
|
||||
invalid = 1
|
||||
next
|
||||
}
|
||||
if (!first)
|
||||
print ","
|
||||
printf "\t\t%s", $0
|
||||
first = 0
|
||||
}
|
||||
END {
|
||||
if (!first)
|
||||
print ""
|
||||
print "\t}"
|
||||
print "}"
|
||||
if (invalid || first)
|
||||
exit 1
|
||||
}
|
||||
' "$source_file" > "$output_file" || return 1
|
||||
# nft catches malformed IPv6 forms and invalid network prefixes before replacement.
|
||||
{ printf 'table inet clashoo_validate {\n'; cat "$output_file"; printf '\n}\n'; } |
|
||||
nft -c -f - >/dev/null 2>&1
|
||||
}
|
||||
|
||||
url4="$(uci -q get clashoo.config.china_ip_url 2>/dev/null || true)"
|
||||
url6="$(uci -q get clashoo.config.china_ipv6_url 2>/dev/null || true)"
|
||||
bypass_china="$(uci -q get clashoo.config.bypass_china 2>/dev/null || true)"
|
||||
bypass_china_ipv6="$(uci -q get clashoo.config.bypass_china_ipv6 2>/dev/null || true)"
|
||||
[ -n "$bypass_china_ipv6" ] || bypass_china_ipv6="$bypass_china"
|
||||
|
||||
[ -n "$url4" ] || url4='https://ispip.clang.cn/all_cn.txt'
|
||||
[ -n "$url6" ] || url6='https://ispip.clang.cn/all_cn_ipv6.txt'
|
||||
|
||||
mkdir -p "$NFT_DIR"
|
||||
|
||||
log '开始更新大陆白名单'
|
||||
|
||||
download_with_fallback "$url4" "$TMP_V4"
|
||||
[ -s "$TMP_V4" ] || {
|
||||
log '大陆 IPv4 白名单下载失败:返回为空'
|
||||
exit 1
|
||||
}
|
||||
changed=0
|
||||
|
||||
if ! render_nft_set "$TMP_V4" "$OUT_V4" clashoo_china ipv4_addr; then
|
||||
log '大陆 IPv4 白名单包含无效 CIDR,保留原文件'
|
||||
exit 1
|
||||
fi
|
||||
if cmp -s "$OUT_V4" "$TARGET_V4"; then
|
||||
log '大陆 IPv4 白名单内容无变化'
|
||||
else
|
||||
mv "$OUT_V4" "$TARGET_V4"
|
||||
chmod 644 "$TARGET_V4" >/dev/null 2>&1 || true
|
||||
changed=1
|
||||
log '大陆 IPv4 白名单更新完成'
|
||||
fi
|
||||
|
||||
if download_with_fallback "$url6" "$TMP_V6"; then
|
||||
if [ -s "$TMP_V6" ]; then
|
||||
if ! render_nft_set "$TMP_V6" "$OUT_V6" clashoo_china6 ipv6_addr; then
|
||||
log '大陆 IPv6 白名单包含无效 CIDR,保留原文件'
|
||||
exit 1
|
||||
fi
|
||||
if cmp -s "$OUT_V6" "$TARGET_V6"; then
|
||||
log '大陆 IPv6 白名单内容无变化'
|
||||
else
|
||||
mv "$OUT_V6" "$TARGET_V6"
|
||||
chmod 600 "$TARGET_V6" >/dev/null 2>&1 || true
|
||||
changed=1
|
||||
log '大陆 IPv6 白名单更新完成'
|
||||
fi
|
||||
else
|
||||
log '大陆 IPv6 白名单下载为空,保留原文件'
|
||||
fi
|
||||
else
|
||||
log '大陆 IPv6 白名单下载失败,保留原文件'
|
||||
fi
|
||||
|
||||
instance_pid() {
|
||||
local service="$1"
|
||||
local instance="${2:-}"
|
||||
local filter
|
||||
if [ -n "$instance" ]; then
|
||||
filter="@.${service}.instances.${instance}.pid"
|
||||
else
|
||||
filter="@[\"${service}\"].instances.*.pid"
|
||||
fi
|
||||
ubus call service list "{\"name\":\"$service\",\"verbose\":true}" 2>/dev/null |
|
||||
jsonfilter -e "$filter" 2>/dev/null | head -n1
|
||||
}
|
||||
|
||||
pid_running() {
|
||||
[ -n "$1" ] && [ -d "$PROC_ROOT/$1" ]
|
||||
}
|
||||
|
||||
singbox_pid_owns_private_binary() {
|
||||
local pid="$1" exe cmdline
|
||||
pid_running "$pid" || return 1
|
||||
exe="$(readlink -f "$PROC_ROOT/$pid/exe" 2>/dev/null)"
|
||||
case "$exe" in
|
||||
"$SINGBOX_BIN_DIR/sing-box-stable"|"$SINGBOX_BIN_DIR/sing-box-alpha") return 0 ;;
|
||||
esac
|
||||
cmdline="$(tr '\000' ' ' < "$PROC_ROOT/$pid/cmdline" 2>/dev/null)"
|
||||
case "$cmdline" in
|
||||
"$SINGBOX_BIN_DIR/sing-box-stable"|"$SINGBOX_BIN_DIR/sing-box-stable "*|"$SINGBOX_BIN_DIR/sing-box-alpha"|"$SINGBOX_BIN_DIR/sing-box-alpha "*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
selected_core_running() {
|
||||
local name pid
|
||||
if [ "$(uci -q get clashoo.config.core_type 2>/dev/null)" = "singbox" ]; then
|
||||
name="clashoo"
|
||||
pid="$(instance_pid "$name" singbox)"
|
||||
singbox_pid_owns_private_binary "$pid"
|
||||
return $?
|
||||
else
|
||||
name="clashoo"
|
||||
fi
|
||||
pid="$(instance_pid "$name")"
|
||||
pid_running "$pid"
|
||||
}
|
||||
|
||||
firewall_managed() {
|
||||
bool_enabled "$(uci -q get clashoo.config.enable 2>/dev/null)" || return 1
|
||||
[ "$(uci -q get clashoo.config.core_only 2>/dev/null)" = "1" ] && return 1
|
||||
selected_core_running
|
||||
}
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
log '白名单内容未变,跳过防火墙重载'
|
||||
elif ! firewall_managed; then
|
||||
log '未接管防火墙(服务未运行或仅内核模式),仅更新白名单文件'
|
||||
elif bool_enabled "$bypass_china" || bool_enabled "$bypass_china_ipv6"; then
|
||||
if "$FW4_SCRIPT" apply >/dev/null 2>&1; then
|
||||
log "大陆白名单规则已重载(IPv4=${bypass_china:-0},IPv6=${bypass_china_ipv6:-0})"
|
||||
else
|
||||
log '大陆白名单规则重载失败'
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log '大陆 IPv4/IPv6 绕过均未启用,仅更新白名单文件'
|
||||
fi
|
||||
|
||||
log '大陆白名单更新流程完成'
|
||||
@@ -0,0 +1,91 @@
|
||||
--- a/dns/middleware.go
|
||||
+++ b/dns/middleware.go
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
icontext "github.com/metacubex/mihomo/context"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
|
||||
+ M "github.com/metacubex/sing/common/metadata"
|
||||
D "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
@@ -152,6 +153,9 @@ func withFakeIP(skipper *fakeip.Skipper,
|
||||
q := r.Question[0]
|
||||
|
||||
host := strings.TrimRight(q.Name, ".")
|
||||
+ if !M.IsDomainName(host) {
|
||||
+ return next(ctx, r)
|
||||
+ }
|
||||
if skipper.ShouldSkipped(host) {
|
||||
return next(ctx, r)
|
||||
}
|
||||
--- /dev/null
|
||||
+++ b/dns/middleware_test.go
|
||||
@@ -0,0 +1,68 @@
|
||||
+package dns
|
||||
+
|
||||
+import (
|
||||
+ "context"
|
||||
+ "net/netip"
|
||||
+ "testing"
|
||||
+
|
||||
+ "github.com/metacubex/mihomo/component/fakeip"
|
||||
+ icontext "github.com/metacubex/mihomo/context"
|
||||
+
|
||||
+ D "github.com/miekg/dns"
|
||||
+ "github.com/stretchr/testify/require"
|
||||
+)
|
||||
+
|
||||
+func TestWithFakeIPSkipsInvalidDomain(t *testing.T) {
|
||||
+ pool, err := fakeip.New(fakeip.Options{
|
||||
+ IPNet: netip.MustParsePrefix("198.18.0.1/16"),
|
||||
+ Size: 10,
|
||||
+ })
|
||||
+ require.NoError(t, err)
|
||||
+
|
||||
+ downstreamCalled := false
|
||||
+ next := func(_ *icontext.DNSContext, request *D.Msg) (*D.Msg, error) {
|
||||
+ downstreamCalled = true
|
||||
+ response := new(D.Msg)
|
||||
+ response.SetReply(request)
|
||||
+ return response, nil
|
||||
+ }
|
||||
+
|
||||
+ request := new(D.Msg)
|
||||
+ request.SetQuestion("n-relay-ipc-txc-nj-00.tplinkcloud.com.cn\\152.", D.TypeA)
|
||||
+ response, err := withFakeIP(&fakeip.Skipper{}, pool, nil, 1)(next)(icontext.NewDNSContext(context.Background()), request)
|
||||
+
|
||||
+ require.NoError(t, err)
|
||||
+ require.True(t, downstreamCalled)
|
||||
+ require.Empty(t, response.Answer)
|
||||
+ _, mapped := pool.LookBack(netip.MustParseAddr("198.18.0.4"))
|
||||
+ require.False(t, mapped)
|
||||
+}
|
||||
+
|
||||
+func TestWithFakeIPKeepsValidDomain(t *testing.T) {
|
||||
+ pool, err := fakeip.New(fakeip.Options{
|
||||
+ IPNet: netip.MustParsePrefix("198.18.0.1/16"),
|
||||
+ Size: 10,
|
||||
+ })
|
||||
+ require.NoError(t, err)
|
||||
+
|
||||
+ downstreamCalled := false
|
||||
+ next := func(_ *icontext.DNSContext, request *D.Msg) (*D.Msg, error) {
|
||||
+ downstreamCalled = true
|
||||
+ response := new(D.Msg)
|
||||
+ response.SetReply(request)
|
||||
+ return response, nil
|
||||
+ }
|
||||
+
|
||||
+ request := new(D.Msg)
|
||||
+ request.SetQuestion("n-relay-ipc-txc-nj-00.tplinkcloud.com.cn.", D.TypeA)
|
||||
+ dnsCtx := icontext.NewDNSContext(context.Background())
|
||||
+ response, err := withFakeIP(&fakeip.Skipper{}, pool, nil, 1)(next)(dnsCtx, request)
|
||||
+
|
||||
+ require.NoError(t, err)
|
||||
+ require.False(t, downstreamCalled)
|
||||
+ require.Len(t, response.Answer, 1)
|
||||
+ require.Equal(t, icontext.DNSTypeFakeIP, dnsCtx.Type())
|
||||
+ host, mapped := pool.LookBack(netip.MustParseAddr("198.18.0.4"))
|
||||
+ require.True(t, mapped)
|
||||
+ require.Equal(t, "n-relay-ipc-txc-nj-00.tplinkcloud.com.cn", host)
|
||||
+}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Fail `pnpm install` early with a clear message on unsupported Node versions
|
||||
# instead of letting the install die somewhere deep inside a dependency.
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "luci-app-aurora-config-dev",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Build and test harness. htdocs/luci-static/resources is generated from src/resource by scripts/build-js.mjs -- never edit it by hand. Also pins the @eamonxg/luci-theme-tokens version that scripts/sync-tokens.mjs vendors (and lets Renovate/Dependabot propose bumps).",
|
||||
"engines": { "node": ">=20" },
|
||||
"scripts": {
|
||||
"build": "node scripts/build-js.mjs",
|
||||
"check": "node scripts/build-js.mjs --check",
|
||||
"test": "node scripts/build-js.mjs --check && node --test tests/*.test.mjs",
|
||||
"sync-tokens": "node scripts/sync-tokens.mjs",
|
||||
"gen-presets": "node scripts/gen-presets.mjs",
|
||||
"gen-font-presets": "node scripts/gen-font-presets.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eamonxg/luci-theme-tokens": "2.0.0",
|
||||
"terser": "^5.43.1"
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@eamonxg/luci-theme-tokens':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
terser:
|
||||
specifier: ^5.43.1
|
||||
version: 5.49.1
|
||||
|
||||
packages:
|
||||
|
||||
'@eamonxg/luci-theme-tokens@2.0.0':
|
||||
resolution: {integrity: sha512-LSPR6fW5iDN0r4eEfLLVirIgjJxUN8L1uJ3hZDHZRPbHtRE8v21pJSMhZoQKerCXUJx3spZc3W3r9f+baw3Jqw==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
acorn@8.18.0:
|
||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
colorjs.io@0.6.1:
|
||||
resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
|
||||
source-map@0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
terser@5.49.1:
|
||||
resolution: {integrity: sha512-7A2xlQ5EnGT8KPA92dUh6RbRYTVw8hEaEN9L1K68l4UOXFuV511NnAqObGoRqGOQofQcMypisu1s3xawCEHrvA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@eamonxg/luci-theme-tokens@2.0.0':
|
||||
dependencies:
|
||||
colorjs.io: 0.6.1
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.11':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
colorjs.io@0.6.1: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
terser@5.49.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.18.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"name": "@eamonxg/luci-theme-tokens",
|
||||
"version": "2.0.0",
|
||||
"presets": {
|
||||
"default": {
|
||||
"light": {
|
||||
"bg": "#f7fafc",
|
||||
"surface": "#fff",
|
||||
"text": "#121a22",
|
||||
"brand": "#0086bf",
|
||||
"on_brand": "#fff",
|
||||
"link": "#0086bf",
|
||||
"info": "#0e5794",
|
||||
"warning": "#653e00",
|
||||
"success": "#00523c",
|
||||
"danger": "#8d1925",
|
||||
"text_muted": "#5f666d",
|
||||
"text_subtle": "#7f858b",
|
||||
"surface_sunken": "#f4f7fa",
|
||||
"surface_overlay": "#fcffff",
|
||||
"hairline": "#121a2221",
|
||||
"hover_faint": "#ebeff2",
|
||||
"brand_hover": "#0074ac",
|
||||
"brand_subtle": "#dfecf5",
|
||||
"brand_subtle_hover": "#d1dfe8",
|
||||
"focus_ring": "#0186bf99",
|
||||
"progress_start": "#70aed5",
|
||||
"progress_end": "#0086bf",
|
||||
"info_surface": "#d8f0ff",
|
||||
"warning_surface": "#ffecd2",
|
||||
"success_surface": "#d5f8e9",
|
||||
"danger_surface": "#ffe6e4",
|
||||
"danger_surface_hover": "#f9d9d7",
|
||||
"control_bg": "#fcffff",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#f1f7fb",
|
||||
"mega_menu_scrim": "#0d1c2a61"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#05070e",
|
||||
"surface": "#141822",
|
||||
"text": "#f9fafb",
|
||||
"brand": "#00978f",
|
||||
"on_brand": "#fff",
|
||||
"link": "#00978f",
|
||||
"info": "#8dc1ff",
|
||||
"warning": "#f0ba59",
|
||||
"success": "#51bd85",
|
||||
"danger": "#f17070",
|
||||
"text_muted": "#909297",
|
||||
"text_subtle": "#5d6066",
|
||||
"surface_sunken": "#0a0e17",
|
||||
"surface_overlay": "#191d27",
|
||||
"hairline": "#f9fafb1a",
|
||||
"hover_faint": "#f9fafb0d",
|
||||
"brand_hover": "#008880",
|
||||
"brand_subtle": "#0a1a20",
|
||||
"brand_subtle_hover": "#142329",
|
||||
"focus_ring": "#00978f99",
|
||||
"progress_start": "#166262",
|
||||
"progress_end": "#00978f",
|
||||
"info_surface": "#20344c",
|
||||
"warning_surface": "#45310b",
|
||||
"success_surface": "#153524",
|
||||
"danger_surface": "#541f20",
|
||||
"danger_surface_hover": "#602a2a",
|
||||
"control_bg": "#0a0e17",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#0a0e17",
|
||||
"mega_menu_scrim": "#00000080"
|
||||
}
|
||||
},
|
||||
"sage-green": {
|
||||
"light": {
|
||||
"bg": "#f8f7f4",
|
||||
"surface": "#fff",
|
||||
"text": "#1a1f2e",
|
||||
"brand": "#7c9082",
|
||||
"on_brand": "#fff",
|
||||
"link": "#7c9082",
|
||||
"info": "#0e5794",
|
||||
"warning": "#653e00",
|
||||
"success": "#00523c",
|
||||
"danger": "#8d1925",
|
||||
"text_muted": "#656973",
|
||||
"text_subtle": "#84878f",
|
||||
"surface_sunken": "#f8f7f3",
|
||||
"surface_overlay": "#fdfcf9",
|
||||
"hairline": "#1a1f2e21",
|
||||
"hover_faint": "#f0eeea",
|
||||
"brand_hover": "#6a7e70",
|
||||
"brand_subtle": "#e8eae6",
|
||||
"brand_subtle_hover": "#dbddd9",
|
||||
"focus_ring": "#7c908299",
|
||||
"progress_start": "#a6b3a8",
|
||||
"progress_end": "#7c9082",
|
||||
"info_surface": "#d8f0ff",
|
||||
"warning_surface": "#ffecd2",
|
||||
"success_surface": "#d5f8e9",
|
||||
"danger_surface": "#ffe6e4",
|
||||
"danger_surface_hover": "#f9d9d7",
|
||||
"control_bg": "#fdfcf9",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#f8f6f0",
|
||||
"mega_menu_scrim": "#0d1c2a61"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#0a0a0a",
|
||||
"surface": "#121212",
|
||||
"text": "#f5f5f5",
|
||||
"brand": "#7c9082",
|
||||
"on_brand": "#000",
|
||||
"link": "#7c9082",
|
||||
"info": "#8dc1ff",
|
||||
"warning": "#f0ba59",
|
||||
"success": "#51bd85",
|
||||
"danger": "#f17070",
|
||||
"text_muted": "#919191",
|
||||
"text_subtle": "#616161",
|
||||
"surface_sunken": "#090909",
|
||||
"surface_overlay": "#161616",
|
||||
"hairline": "#f5f5f51a",
|
||||
"hover_faint": "#f5f5f50d",
|
||||
"brand_hover": "#6d8173",
|
||||
"brand_subtle": "#1a1c1a",
|
||||
"brand_subtle_hover": "#232524",
|
||||
"focus_ring": "#7c908299",
|
||||
"progress_start": "#4f5b53",
|
||||
"progress_end": "#7c9082",
|
||||
"info_surface": "#20344c",
|
||||
"warning_surface": "#45310b",
|
||||
"success_surface": "#153524",
|
||||
"danger_surface": "#541f20",
|
||||
"danger_surface_hover": "#602a2a",
|
||||
"control_bg": "#090909",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#090909",
|
||||
"mega_menu_scrim": "#00000080"
|
||||
}
|
||||
},
|
||||
"amber-sand": {
|
||||
"light": {
|
||||
"bg": "#faf9f5",
|
||||
"surface": "#faf9f5",
|
||||
"text": "#3d3929",
|
||||
"brand": "#c96442",
|
||||
"on_brand": "#fff",
|
||||
"link": "#c96442",
|
||||
"info": "#0e5794",
|
||||
"warning": "#653e00",
|
||||
"success": "#00523c",
|
||||
"danger": "#8d1925",
|
||||
"text_muted": "#7f7c70",
|
||||
"text_subtle": "#9a978c",
|
||||
"surface_sunken": "#f8f7f3",
|
||||
"surface_overlay": "#fffefa",
|
||||
"hairline": "#3d392921",
|
||||
"hover_faint": "#f0eeea",
|
||||
"brand_hover": "#b55230",
|
||||
"brand_subtle": "#f6e7df",
|
||||
"brand_subtle_hover": "#e9dad2",
|
||||
"focus_ring": "#c9644299",
|
||||
"progress_start": "#dd9880",
|
||||
"progress_end": "#c96442",
|
||||
"info_surface": "#d8f0ff",
|
||||
"warning_surface": "#ffecd2",
|
||||
"success_surface": "#d5f8e9",
|
||||
"danger_surface": "#ffe6e4",
|
||||
"danger_surface_hover": "#f9d9d7",
|
||||
"control_bg": "#fffefa",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#f7f6f0",
|
||||
"mega_menu_scrim": "#0d1c2a61"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#262624",
|
||||
"surface": "#30302e",
|
||||
"text": "#c3c0b6",
|
||||
"brand": "#d97757",
|
||||
"on_brand": "#000",
|
||||
"link": "#d97757",
|
||||
"info": "#8dc1ff",
|
||||
"warning": "#f0ba59",
|
||||
"success": "#51bd85",
|
||||
"danger": "#f17070",
|
||||
"text_muted": "#83817a",
|
||||
"text_subtle": "#63625d",
|
||||
"surface_sunken": "#252523",
|
||||
"surface_overlay": "#353533",
|
||||
"hairline": "#c3c0b61a",
|
||||
"hover_faint": "#c3c0b60d",
|
||||
"brand_hover": "#c86848",
|
||||
"brand_subtle": "#40332c",
|
||||
"brand_subtle_hover": "#4b3d37",
|
||||
"focus_ring": "#d9775799",
|
||||
"progress_start": "#965a45",
|
||||
"progress_end": "#d97757",
|
||||
"info_surface": "#20344c",
|
||||
"warning_surface": "#45310b",
|
||||
"success_surface": "#153524",
|
||||
"danger_surface": "#541f20",
|
||||
"danger_surface_hover": "#602a2a",
|
||||
"control_bg": "#252523",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#252523",
|
||||
"mega_menu_scrim": "#00000080"
|
||||
}
|
||||
},
|
||||
"monochrome": {
|
||||
"light": {
|
||||
"bg": "#fcfcfc",
|
||||
"surface": "#fff",
|
||||
"text": "#000",
|
||||
"brand": "#000",
|
||||
"on_brand": "#fff",
|
||||
"link": "#000",
|
||||
"info": "#0e5794",
|
||||
"warning": "#653e00",
|
||||
"success": "#00523c",
|
||||
"danger": "#8d1925",
|
||||
"text_muted": "#414141",
|
||||
"text_subtle": "#676767",
|
||||
"surface_sunken": "#f7f7f7",
|
||||
"surface_overlay": "#fff",
|
||||
"hairline": "#00000021",
|
||||
"hover_faint": "#eee",
|
||||
"brand_hover": "#000",
|
||||
"brand_subtle": "#d5d5d5",
|
||||
"brand_subtle_hover": "#c8c8c8",
|
||||
"focus_ring": "#0009",
|
||||
"progress_start": "#383838",
|
||||
"progress_end": "#000",
|
||||
"info_surface": "#d8f0ff",
|
||||
"warning_surface": "#ffecd2",
|
||||
"success_surface": "#d5f8e9",
|
||||
"danger_surface": "#ffe6e4",
|
||||
"danger_surface_hover": "#f9d9d7",
|
||||
"control_bg": "#fff",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#f6f6f6",
|
||||
"mega_menu_scrim": "#0d1c2a61"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#000",
|
||||
"surface": "#090909",
|
||||
"text": "#fff",
|
||||
"brand": "#fff",
|
||||
"on_brand": "#000",
|
||||
"link": "#fff",
|
||||
"info": "#8dc1ff",
|
||||
"warning": "#f0ba59",
|
||||
"success": "#51bd85",
|
||||
"danger": "#f17070",
|
||||
"text_muted": "#868686",
|
||||
"text_subtle": "#4d4d4d",
|
||||
"surface_sunken": "#030303",
|
||||
"surface_overlay": "#0d0d0d",
|
||||
"hairline": "#ffffff1a",
|
||||
"hover_faint": "#ffffff0d",
|
||||
"brand_hover": "#eee",
|
||||
"brand_subtle": "#0d0d0d",
|
||||
"brand_subtle_hover": "#161616",
|
||||
"focus_ring": "#fff9",
|
||||
"progress_start": "#999",
|
||||
"progress_end": "#fff",
|
||||
"info_surface": "#20344c",
|
||||
"warning_surface": "#45310b",
|
||||
"success_surface": "#153524",
|
||||
"danger_surface": "#541f20",
|
||||
"danger_surface_hover": "#602a2a",
|
||||
"control_bg": "#030303",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#030303",
|
||||
"mega_menu_scrim": "#00000080"
|
||||
}
|
||||
},
|
||||
"sky-blue": {
|
||||
"light": {
|
||||
"bg": "#fff",
|
||||
"surface": "#f7f8f8",
|
||||
"text": "#0f1419",
|
||||
"brand": "#1e9df1",
|
||||
"on_brand": "#fff",
|
||||
"link": "#1e9df1",
|
||||
"info": "#0e5794",
|
||||
"warning": "#653e00",
|
||||
"success": "#00523c",
|
||||
"danger": "#8d1925",
|
||||
"text_muted": "#5f6367",
|
||||
"text_subtle": "#808487",
|
||||
"surface_sunken": "#f7f7f7",
|
||||
"surface_overlay": "#fff",
|
||||
"hairline": "#0f141921",
|
||||
"hover_faint": "#eee",
|
||||
"brand_hover": "#008add",
|
||||
"brand_subtle": "#e8f4ff",
|
||||
"brand_subtle_hover": "#dbe7f1",
|
||||
"focus_ring": "#1e9df199",
|
||||
"progress_start": "#7cbef5",
|
||||
"progress_end": "#1e9df1",
|
||||
"info_surface": "#d8f0ff",
|
||||
"warning_surface": "#ffecd2",
|
||||
"success_surface": "#d5f8e9",
|
||||
"danger_surface": "#ffe6e4",
|
||||
"danger_surface_hover": "#f9d9d7",
|
||||
"control_bg": "#fff",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#f6f6f6",
|
||||
"mega_menu_scrim": "#0d1c2a61"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "#000",
|
||||
"surface": "#17181c",
|
||||
"text": "#e7e9ea",
|
||||
"brand": "#1c9cf0",
|
||||
"on_brand": "#fff",
|
||||
"link": "#1c9cf0",
|
||||
"info": "#8dc1ff",
|
||||
"warning": "#f0ba59",
|
||||
"success": "#51bd85",
|
||||
"danger": "#f17070",
|
||||
"text_muted": "#797a7a",
|
||||
"text_subtle": "#454646",
|
||||
"surface_sunken": "#0d0e12",
|
||||
"surface_overlay": "#1c1d21",
|
||||
"hairline": "#e7e9ea1a",
|
||||
"hover_faint": "#e7e9ea0d",
|
||||
"brand_hover": "#008cdf",
|
||||
"brand_subtle": "#00040c",
|
||||
"brand_subtle_hover": "#030c15",
|
||||
"focus_ring": "#1c9cf099",
|
||||
"progress_start": "#21669a",
|
||||
"progress_end": "#1c9cf0",
|
||||
"info_surface": "#20344c",
|
||||
"warning_surface": "#45310b",
|
||||
"success_surface": "#153524",
|
||||
"danger_surface": "#541f20",
|
||||
"danger_surface_hover": "#602a2a",
|
||||
"control_bg": "#0d0e12",
|
||||
"scrim": "#0009",
|
||||
"mega_menu_bg": "#0d0e12",
|
||||
"mega_menu_scrim": "#00000080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
// The ONLY writer of htdocs/luci-static/resources. Sources live in
|
||||
// .dev/src/resource and are never shipped; this turns them into the bytes the
|
||||
// router serves. uhttpd has no gzip -- there is no Content-Encoding anywhere in
|
||||
// its source -- so these bytes are the router's flash-read and socket-write cost
|
||||
// one for one. Rationale and measurements:
|
||||
// docs/specs/2026-08-05-asset-pipeline-and-color-shim.md
|
||||
import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { minify } from "terser";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const flag = (name, fallback) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i !== -1 && process.argv[i + 1] ? resolve(process.argv[i + 1]) : fallback;
|
||||
};
|
||||
const SRC = flag("--src", resolve(here, "../src/resource"));
|
||||
const OUT = flag("--out", resolve(here, "../../htdocs/luci-static/resources"));
|
||||
const check = process.argv.includes("--check");
|
||||
|
||||
// From luci-theme-aurora .dev/vite.config.ts. Every flag here is load-bearing:
|
||||
//
|
||||
// bare_returns a LuCI view's last statement is a top-level `return`
|
||||
// directives:false its dependency declarations are string directives, which
|
||||
// a directive-dropping compressor deletes
|
||||
//
|
||||
// And two options must stay at their DEFAULTS, which is why they are absent:
|
||||
// mangle.toplevel and compress.toplevel. color.global.js and tokens.global.js
|
||||
// publish themselves through a top-level `var Color` / `var AuroraTokens`;
|
||||
// turning either on deletes the whole declaration -- measured, the output
|
||||
// becomes an empty string -- and both pages white-screen with nothing logged.
|
||||
//
|
||||
// comments:false strips everything, so third-party licences are re-attached
|
||||
// from .license sidecars below rather than preserved in place.
|
||||
const TERSER = {
|
||||
parse: { bare_returns: true },
|
||||
compress: { directives: false, passes: 2 },
|
||||
mangle: true,
|
||||
format: { comments: false, beautify: false },
|
||||
};
|
||||
|
||||
async function walk(dir, base = dir) {
|
||||
const found = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
// Hidden files are never sources and must never be shipped: .DS_Store rode
|
||||
// along the first time this ran and would have been installed onto every
|
||||
// router that upgraded.
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) found.push(...(await walk(full, base)));
|
||||
else found.push(relative(base, full).split("\\").join("/"));
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// MIT and friends require the copyright and permission notice to travel with
|
||||
// every copy. Vendored libraries carry theirs as plain // comments, which
|
||||
// comments:false strips, so each such file gets a <name>.license sidecar whose
|
||||
// contents are re-attached verbatim as a banner. The sidecar is not itself an
|
||||
// artifact -- it only ever appears inside the file it belongs to.
|
||||
async function banner(rel) {
|
||||
try {
|
||||
const text = await readFile(join(SRC, `${rel}.license`), "utf8");
|
||||
return `/*!\n${text.trimEnd()}\n*/\n`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// `__ASSET_HASH(<path>)__` becomes the first 8 hex of that source file's
|
||||
// sha256, so a resource we fetch by a URL we build ourselves can carry a
|
||||
// version that changes if and only if its contents do.
|
||||
//
|
||||
// It has to happen here rather than being stamped into the source, because
|
||||
// only this script reads every source file. The artifact stays a pure function
|
||||
// of the sources, so --check is unaffected.
|
||||
//
|
||||
// Why it matters: uhttpd dates our installed files Last-Modified: epoch and
|
||||
// sends no Cache-Control, so the heuristic freshness a browser computes runs to
|
||||
// years -- it will not even revalidate. Resources LuCI `require`s get LuCI's
|
||||
// own ?v=; the ones we fetch get this.
|
||||
const ASSET_HASH = /__ASSET_HASH\(([^)]+)\)__/g;
|
||||
|
||||
async function resolveAssetHashes(code, rel) {
|
||||
const matches = [...code.matchAll(ASSET_HASH)];
|
||||
let out = code;
|
||||
for (const [token, target] of matches) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await readFile(join(SRC, target), "utf8");
|
||||
} catch {
|
||||
throw new Error(`__ASSET_HASH names ${target}, which is not a source`);
|
||||
}
|
||||
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
|
||||
out = out.split(token).join(hash);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function build(rel) {
|
||||
const source = await resolveAssetHashes(
|
||||
await readFile(join(SRC, rel), "utf8"),
|
||||
rel,
|
||||
);
|
||||
if (rel.endsWith(".js")) {
|
||||
// No sourceMap option at all: terser would otherwise be free to append a
|
||||
// sourceMappingURL comment, and the map is not installed on the device.
|
||||
const result = await minify(source, TERSER);
|
||||
if (typeof result.code !== "string" || result.code.length === 0)
|
||||
throw new Error(`${rel}: terser produced no output`);
|
||||
return (await banner(rel)) + result.code;
|
||||
}
|
||||
if (rel.endsWith(".json")) return JSON.stringify(JSON.parse(source));
|
||||
return source;
|
||||
}
|
||||
|
||||
const sources = (await walk(SRC)).filter((rel) => !rel.endsWith(".license"));
|
||||
if (sources.length === 0) {
|
||||
console.error(`build-js: no sources under ${SRC}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const built = new Map();
|
||||
for (const rel of sources) {
|
||||
try {
|
||||
built.set(rel, await build(rel));
|
||||
} catch (error) {
|
||||
console.error(`build-js: ${rel}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (check) {
|
||||
const stale = [];
|
||||
for (const [rel, content] of built) {
|
||||
let current = null;
|
||||
try {
|
||||
current = await readFile(join(OUT, rel), "utf8");
|
||||
} catch {}
|
||||
if (current !== content) stale.push(rel);
|
||||
}
|
||||
for (const rel of await walk(OUT))
|
||||
if (!built.has(rel)) stale.push(`${rel} (orphan: no source)`);
|
||||
if (stale.length) {
|
||||
console.error("build-js: artifacts are stale -- run `pnpm build` and commit:");
|
||||
for (const rel of stale) console.error(` ${rel}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`build-js: ${built.size} artifacts up to date`);
|
||||
} else {
|
||||
// Wipe first so a deleted source cannot leave a shipped artifact behind.
|
||||
await rm(OUT, { recursive: true, force: true });
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const [rel, content] of built) {
|
||||
const target = join(OUT, rel);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, content, "utf8");
|
||||
before += (await readFile(join(SRC, rel), "utf8")).length;
|
||||
after += content.length;
|
||||
}
|
||||
console.log(
|
||||
`build-js: ${built.size} files, ${before} -> ${after} chars ` +
|
||||
`(${Math.round((after / before) * 100)}%)`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Regenerates root/usr/share/aurora/font-presets.conf from the curated
|
||||
// manifest below. Downloads every woff2 from jsDelivr to compute its sha256,
|
||||
// so the router can verify integrity offline. Run: npm run gen-font-presets
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const OUT = fileURLToPath(
|
||||
new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url),
|
||||
);
|
||||
|
||||
const SANS_TAIL = '"Lato", ui-sans-serif, system-ui, sans-serif';
|
||||
const MONO_TAIL = 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace';
|
||||
|
||||
// slot, name, label, source, family, stack, [pkg id, version, weights]
|
||||
const MANIFEST = [
|
||||
["sans", "default", "Lato", "Built-in", "Lato", `"Lato", ui-sans-serif, system-ui, sans-serif`],
|
||||
["sans", "system", "System UI", "System", "", `system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`],
|
||||
["sans", "geist-sans", "Geist Sans", "Fontsource", "Geist Sans", `"Geist Sans", ${SANS_TAIL}`, "geist-sans", "5.2.5", [400, 500, 600, 700]],
|
||||
["sans", "nunito", "Nunito", "Fontsource", "Nunito", `"Nunito", ${SANS_TAIL}`, "nunito", "5.2.7", [400, 500, 600, 700]],
|
||||
["sans", "space-grotesk", "Space Grotesk", "Fontsource", "Space Grotesk", `"Space Grotesk", ${SANS_TAIL}`, "space-grotesk", "5.2.10", [400, 500, 600, 700]],
|
||||
["mono", "default", "System Mono", "Built-in", "", MONO_TAIL],
|
||||
["mono", "jetbrains-mono", "JetBrains Mono", "Fontsource", "JetBrains Mono", `"JetBrains Mono", ${MONO_TAIL}`, "jetbrains-mono", "5.2.8", [400, 700]],
|
||||
["mono", "maple-mono", "Maple Mono", "Fontsource", "Maple Mono", `"Maple Mono", ${MONO_TAIL}`, "maple-mono", "5.2.6", [400, 700]],
|
||||
["mono", "fira-code", "Fira Code", "Fontsource", "Fira Code", `"Fira Code", ${MONO_TAIL}`, "fira-code", "5.2.7", [400, 700]],
|
||||
["mono", "cascadia-code", "Cascadia Code", "Fontsource", "Cascadia Code", `"Cascadia Code", ${MONO_TAIL}`, "cascadia-code", "5.2.3", [400, 700]],
|
||||
];
|
||||
|
||||
const jsdelivr = (id, ver, w) =>
|
||||
`https://cdn.jsdelivr.net/npm/@fontsource/${id}@${ver}/files/${id}-latin-${w}-normal.woff2`;
|
||||
const npmmirror = (id, ver, w) =>
|
||||
`https://registry.npmmirror.com/@fontsource/${id}/${ver}/files/files/${id}-latin-${w}-normal.woff2`;
|
||||
|
||||
const lines = ["v2|generated-by-gen-font-presets|do-not-edit"];
|
||||
let total = 0;
|
||||
|
||||
for (const [slot, name, label, source, family, stack, id, ver, weights] of MANIFEST) {
|
||||
lines.push(`font|${slot}|${name}|${label}|${source}|${family}|${stack}`);
|
||||
if (!id) continue;
|
||||
if (weights.length > 4)
|
||||
throw new Error(`${slot}/${name} exceeds 4-file budget: ${weights.length} weights`);
|
||||
let presetBytes = 0;
|
||||
for (const w of weights) {
|
||||
const url = jsdelivr(id, ver, w);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
presetBytes += buf.length;
|
||||
const sha256 = createHash("sha256").update(buf).digest("hex");
|
||||
lines.push(`file|${slot}|${name}|${w}|${sha256}|${url}|${npmmirror(id, ver, w)}`);
|
||||
total += 1;
|
||||
}
|
||||
if (presetBytes > 200 * 1024)
|
||||
throw new Error(`${slot}/${name} exceeds 200KB budget: ${presetBytes} bytes`);
|
||||
console.log(`${slot}/${name}: ${weights.length} files, ${(presetBytes / 1024).toFixed(1)}KB`);
|
||||
}
|
||||
|
||||
writeFileSync(OUT, lines.join("\n") + "\n");
|
||||
console.log(`Wrote ${OUT} (${total} file entries)`);
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerate po/templates/aurora-config.pot from the translatable strings in
|
||||
// this package.
|
||||
//
|
||||
// LuCI's own i18n-scan.pl lives in the OpenWrt build tree, which is not a
|
||||
// dependency of this repo -- so the template used to be refreshed by hand and
|
||||
// drifted from the source. This reproduces the same output: entries sorted by
|
||||
// msgid, each preceded by "#:" references relative to the LuCI feed root.
|
||||
//
|
||||
// Two things the naive version of this script got wrong, both caught by
|
||||
// diffing against the previous template:
|
||||
// * _() calls routinely wrap across lines, so the scan runs over whole file
|
||||
// text rather than line by line;
|
||||
// * menu.d and acl.d titles/descriptions are translatable too, and LuCI's
|
||||
// scanner picks them up from the JSON.
|
||||
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Paths are held as repo-relative strings because they end up verbatim in the
|
||||
// "#:" reference lines, but they are read through REPO so the script works from
|
||||
// any working directory (it lives in .dev/scripts, two levels down).
|
||||
const REPO = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const at = (relPath) => join(REPO, relPath);
|
||||
|
||||
const PREFIX = "applications/luci-app-aurora-config";
|
||||
const OUT = "po/templates/aurora-config.pot";
|
||||
const JS_SCAN = ".dev/src/resource";
|
||||
// The "#:" references keep naming the installed path: that is where a reader
|
||||
// of the shipped package finds the string, and holding it steady keeps this
|
||||
// move out of the .pot and all 15 .po files.
|
||||
const JS_REF = "htdocs/luci-static/resources";
|
||||
const JSON_ROOTS = ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"];
|
||||
|
||||
async function walk(dir, ext) {
|
||||
const found = [];
|
||||
for (const entry of await readdir(at(dir), { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) found.push(...(await walk(path, ext)));
|
||||
else if (entry.name.endsWith(ext)) found.push(path);
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// _("...") / _('...'), tolerating newlines and indentation after the paren.
|
||||
// Concatenated expressions are deliberately not stitched together: LuCI's
|
||||
// scanner does not either, and a string it cannot see whole is one a
|
||||
// translator could not use whole.
|
||||
const CALL = /\b_\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
|
||||
|
||||
const entries = new Map();
|
||||
const record = (raw, file, line) => {
|
||||
// LuCI trims surrounding whitespace when it looks a string up, which is why
|
||||
// no msgid in the existing catalogue carries a trailing space even though
|
||||
// the source writes _("Theme: "). Emitting the untrimmed form would produce
|
||||
// keys that never resolve, silently dropping those translations.
|
||||
const msgid = raw.trim();
|
||||
if (!msgid) return;
|
||||
if (!entries.has(msgid)) entries.set(msgid, new Set());
|
||||
entries.get(msgid).add(`${PREFIX}/${file}:${line}`);
|
||||
};
|
||||
|
||||
for (const file of await walk(JS_SCAN, ".js")) {
|
||||
const text = await readFile(at(file), "utf8");
|
||||
const ref = JS_REF + file.slice(JS_SCAN.length);
|
||||
for (const match of text.matchAll(CALL)) {
|
||||
const line = text.slice(0, match.index).split("\n").length;
|
||||
record(match[2], ref, line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of JSON_ROOTS) {
|
||||
for (const file of await walk(root, ".json")) {
|
||||
const text = await readFile(at(file), "utf8");
|
||||
text.split("\n").forEach((line, index) => {
|
||||
const match = /"(?:title|description)"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(line);
|
||||
if (match) record(match[1], file, index + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...entries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n';
|
||||
for (const msgid of sorted) {
|
||||
out += "\n";
|
||||
for (const ref of [...entries.get(msgid)].sort()) out += `#: ${ref}\n`;
|
||||
out += `msgid "${msgid}"\nmsgstr ""\n`;
|
||||
}
|
||||
|
||||
await writeFile(at(OUT), out);
|
||||
console.log(`${OUT}: ${sorted.length} strings`);
|
||||
@@ -0,0 +1,230 @@
|
||||
// Regenerate the preset templates under root/usr/share/aurora/ from
|
||||
// scripts/aurora-presets.json -- resolved preset values precomputed at
|
||||
// @eamonxg/luci-theme-tokens build time (inputs rounded to hex first, then
|
||||
// derived, so stored == what the config UI recomputes; see the package's
|
||||
// build.mjs). This script only formats and injects UCI option lines, and
|
||||
// mirrors the preset data into the JS sources so the Marketplace can render
|
||||
// the built-in preset cards offline (.dev/src/resource/aurora/presets.json).
|
||||
//
|
||||
// Only the colours come from the vendored json. A preset's layout and
|
||||
// typography -- navigation shape, spacing, corner radius, content width, both
|
||||
// font stacks, the toolbar switch -- live in the templates themselves and are
|
||||
// read back out here: sync-tokens.mjs overwrites aurora-presets.json from the
|
||||
// npm package on every sync, so anything this repo authors has to live
|
||||
// somewhere that sync never touches.
|
||||
//
|
||||
// Zero dependencies / no build step. Run: node scripts/gen-presets.mjs
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// here = .dev/scripts, so the repo root is two levels up.
|
||||
const root = resolve(here, "../..");
|
||||
const PRESET_DIR = resolve(root, "root/usr/share/aurora");
|
||||
const SRC_PRESETS = resolve(
|
||||
root,
|
||||
".dev/src/resource/aurora/presets.json",
|
||||
);
|
||||
|
||||
// The two groups a hub payload sorts these keys into (see build_share_payload
|
||||
// in root/usr/libexec/rpcd/luci.aurora). The browser copy is emitted in that
|
||||
// same shape, so the Marketplace reads a built-in preset with exactly the
|
||||
// accessors it already uses for a shared configuration.
|
||||
const LAYOUT_KEYS = [
|
||||
"nav_type",
|
||||
"struct_spacing",
|
||||
"struct_radius_base",
|
||||
"struct_content_width_centered",
|
||||
"toolbar_enabled",
|
||||
];
|
||||
const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"];
|
||||
|
||||
// A hub payload's typography carries the roster id alongside the stack
|
||||
// (build_share_payload emits both). The id is what says whether the family has
|
||||
// to be downloaded before the preset looks the way it is drawn -- "default"
|
||||
// and "system" resolve to faces already on the router, every other id is a
|
||||
// Fontsource webfont. Recovered from the roster by stack, the same reverse
|
||||
// lookup resolve_font_preset_id does on the device.
|
||||
const FONT_PRESETS = resolve(PRESET_DIR, "font-presets.conf");
|
||||
|
||||
const fontIdByStack = () => {
|
||||
const table = { sans: new Map(), mono: new Map() };
|
||||
for (const line of readFileSync(FONT_PRESETS, "utf8").split("\n")) {
|
||||
const parts = line.split("|");
|
||||
if (parts[0] !== "font") continue;
|
||||
const [, slot, id, , , , stack] = parts;
|
||||
if (table[slot] && !table[slot].has(stack)) table[slot].set(stack, id);
|
||||
}
|
||||
return table;
|
||||
};
|
||||
|
||||
const { presets } = JSON.parse(
|
||||
readFileSync(resolve(here, "aurora-presets.json"), "utf8"),
|
||||
);
|
||||
|
||||
const colorLines = (preset) => {
|
||||
const lines = [];
|
||||
for (const mode of ["light", "dark"])
|
||||
for (const [key, hex] of Object.entries(presets[preset][mode]))
|
||||
lines.push(`\toption ${mode}_${key} '${hex}'`);
|
||||
return lines;
|
||||
};
|
||||
|
||||
const isColorOptionLine = (line) => /^\toption (light|dark)_/.test(line);
|
||||
|
||||
// `option <key> '<value>'` -> value. Only the first occurrence counts, the
|
||||
// same way load_preset_struct_snapshot's awk pass takes it.
|
||||
const readOption = (source, key) => {
|
||||
const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const fontIds = fontIdByStack();
|
||||
|
||||
const structureOf = (preset, source) => {
|
||||
const pick = (keys) =>
|
||||
keys.reduce((acc, key) => {
|
||||
const value = readOption(source, key);
|
||||
if (value === null)
|
||||
throw new Error(
|
||||
`${preset}: template is missing option ${key} -- every built-in ` +
|
||||
`preset is a whole look, not just a palette`,
|
||||
);
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const typography = pick(TYPOGRAPHY_KEYS);
|
||||
for (const slot of ["sans", "mono"]) {
|
||||
const stack = typography[`struct_font_${slot}`];
|
||||
const id = fontIds[slot].get(stack);
|
||||
// A stack the roster does not know would leave the device unable to
|
||||
// reverse-map it either, so the preset's typeface would silently degrade
|
||||
// to the next family in the stack. Copy the roster line, do not retype it.
|
||||
if (!id)
|
||||
throw new Error(
|
||||
`${preset}: struct_font_${slot} matches no ${slot} entry in ` +
|
||||
`font-presets.conf -- copy the stack from its "font|${slot}|..." row`,
|
||||
);
|
||||
typography[`font_${slot}`] = id;
|
||||
}
|
||||
|
||||
return { layout: pick(LAYOUT_KEYS), typography };
|
||||
};
|
||||
|
||||
const toolbarOf = (preset, source) => {
|
||||
const items = [];
|
||||
let current = null;
|
||||
for (const raw of source.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (/^config\s+toolbar_item\s*$/.test(line)) {
|
||||
if (current) items.push(current);
|
||||
current = { title: "", url: "", icon: "", enabled: "" };
|
||||
continue;
|
||||
}
|
||||
if (/^config\s/.test(line)) {
|
||||
if (current) items.push(current);
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const m = line.match(/^option\s+(title|url|icon|enabled)\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
current[m[1]] = m[2].replace(/^'(.*)'$/, "$1").replace(/^"(.*)"$/, "$1");
|
||||
}
|
||||
if (current) items.push(current);
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.title || !item.url || !/^[01]$/.test(item.enabled))
|
||||
throw new Error(
|
||||
`${preset}: a toolbar_item is missing title/url/enabled -- the device ` +
|
||||
`rejects the whole preset on this (see load_preset_toolbar)`,
|
||||
);
|
||||
}
|
||||
return items.map(({ title, url, icon, enabled }) =>
|
||||
icon ? { title, url, icon, enabled } : { title, url, enabled },
|
||||
);
|
||||
};
|
||||
|
||||
// The four colours the Marketplace draws a card from -- marketplace.js SWATCH_KEYS.
|
||||
// Kept in sync by builtin-presets.test.mjs, which parses that declaration and
|
||||
// fails if this list drifts from it.
|
||||
const SWATCH_KEYS = ["bg", "surface", "text", "brand"];
|
||||
|
||||
// Flat light_*/dark_* keys, the shape a hub payload's `colors` object uses --
|
||||
// but only the swatch keys. The templates keep all 62; this copy is downloaded
|
||||
// by every visitor to the store and the store reads no others. Applying a
|
||||
// built-in preset does not go through here at all: the browser sends a name and
|
||||
// apply_theme_preset reads the full template on the router.
|
||||
const flatColors = (preset) => {
|
||||
const out = {};
|
||||
for (const mode of ["light", "dark"])
|
||||
for (const key of SWATCH_KEYS) {
|
||||
const hex = presets[preset][mode][key];
|
||||
if (hex === undefined)
|
||||
throw new Error(`${preset}: ${mode} is missing the swatch key ${key}`);
|
||||
out[`${mode}_${key}`] = hex;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const browserPresets = {};
|
||||
|
||||
for (const preset of Object.keys(presets)) {
|
||||
const templateFile = preset === "default" ? "default.template" : `${preset}.template`;
|
||||
const path = resolve(PRESET_DIR, templateFile);
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
const out = [];
|
||||
let injected = false;
|
||||
for (const line of lines) {
|
||||
if (isColorOptionLine(line)) {
|
||||
if (!injected) {
|
||||
out.push(...colorLines(preset));
|
||||
injected = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
if (!injected) throw new Error(`${preset}: no colour block found to replace`);
|
||||
const source = out.join("\n");
|
||||
writeFileSync(path, source, "utf8");
|
||||
console.log(`gen-presets: wrote ${templateFile}`);
|
||||
|
||||
const { layout, typography } = structureOf(preset, source);
|
||||
browserPresets[preset] = {
|
||||
colors: flatColors(preset),
|
||||
layout,
|
||||
typography,
|
||||
toolbar: toolbarOf(preset, source),
|
||||
};
|
||||
}
|
||||
|
||||
// Browser copy for the Marketplace's built-in preset cards. Same data as the
|
||||
// templates, projected into the hub's payload shape; regenerated together so
|
||||
// the two can never drift.
|
||||
mkdirSync(dirname(SRC_PRESETS), { recursive: true });
|
||||
const presetsJson = JSON.stringify({ presets: browserPresets }, null, 2) + "\n";
|
||||
writeFileSync(SRC_PRESETS, presetsJson, "utf8");
|
||||
console.log("gen-presets: wrote .dev/src/resource/aurora/presets.json");
|
||||
|
||||
// Stamp a content hash into marketplace.js, which appends it as ?v= when fetching
|
||||
// the file above. uhttpd dates every static resource at the epoch and sends no
|
||||
// Cache-Control, so a browser's heuristic freshness for it runs to years and it
|
||||
// never revalidates -- without this stamp, a change here reaches nobody who has
|
||||
// already opened the store. Same mechanism sync-tokens.mjs uses for the token
|
||||
// engine; tests/builtin-presets.test.mjs checks the two agree.
|
||||
const hash = createHash("sha256").update(presetsJson).digest("hex").slice(0, 8);
|
||||
const stampLine = `const PRESETS_VERSION = "${hash}";`;
|
||||
const galleryPath = resolve(root, ".dev/src/resource/view/aurora/marketplace.js");
|
||||
const gallery = readFileSync(galleryPath, "utf8").replace(
|
||||
/const PRESETS_VERSION = "[^"]*";/,
|
||||
stampLine,
|
||||
);
|
||||
if (!gallery.includes(stampLine))
|
||||
throw new Error("PRESETS_VERSION marker not found in marketplace.js");
|
||||
writeFileSync(galleryPath, gallery, "utf8");
|
||||
console.log(`gen-presets: stamped PRESETS_VERSION=${hash} into marketplace.js`);
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync po/<lang>/aurora-config.po against the regenerated template: drop
|
||||
// entries the template no longer lists, append the ones it gained, and leave
|
||||
// every existing translation untouched.
|
||||
//
|
||||
// Translations for the new strings come from translations.json next to this
|
||||
// script. A language missing an entry leaves msgstr empty rather than shipping
|
||||
// an English string dressed as a translation.
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// This script lives in .dev/scripts; the catalogues it edits are at the repo
|
||||
// root, so nothing here may depend on the working directory.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const at = (relPath) => join(here, "../..", relPath);
|
||||
|
||||
const POT = at("po/templates/aurora-config.pot");
|
||||
const TABLE = join(here, "translations.json");
|
||||
|
||||
const parsePo = (text) => {
|
||||
const entries = [];
|
||||
let refs = [];
|
||||
let msgid = null;
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("#:")) refs.push(line);
|
||||
else if (line.startsWith("msgid ")) msgid = line.slice(7, -1);
|
||||
else if (line.startsWith("msgstr ") && msgid !== null) {
|
||||
entries.push({ refs, msgid, msgstr: line.slice(8, -1) });
|
||||
refs = [];
|
||||
msgid = null;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const potText = await readFile(POT, "utf8");
|
||||
const potEntries = parsePo(potText).filter((e) => e.msgid !== "");
|
||||
const table = JSON.parse(await readFile(TABLE, "utf8"));
|
||||
|
||||
const langs = [
|
||||
"de", "es", "fr", "id", "it", "ja", "ko",
|
||||
"nl", "pl", "ru", "tr", "uk", "zh_Hans", "zh_Hant",
|
||||
];
|
||||
|
||||
for (const lang of langs) {
|
||||
const path = at(`po/${lang}/aurora-config.po`);
|
||||
const existing = new Map(
|
||||
parsePo(await readFile(path, "utf8"))
|
||||
.filter((e) => e.msgid !== "")
|
||||
.map((e) => [e.msgid, e.msgstr]),
|
||||
);
|
||||
|
||||
let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n';
|
||||
let added = 0;
|
||||
let untranslated = 0;
|
||||
|
||||
for (const entry of potEntries) {
|
||||
// An entry that exists but was never filled in counts as missing: a blank
|
||||
// msgstr ships the English string, which is exactly what the table is for.
|
||||
let msgstr = existing.get(entry.msgid);
|
||||
if (!msgstr) {
|
||||
if (msgstr === undefined) added += 1;
|
||||
msgstr = table[entry.msgid]?.[lang] ?? "";
|
||||
if (!msgstr) untranslated += 1;
|
||||
}
|
||||
out += "\n";
|
||||
for (const ref of entry.refs) out += `${ref}\n`;
|
||||
out += `msgid "${entry.msgid}"\nmsgstr "${msgstr}"\n`;
|
||||
}
|
||||
|
||||
await writeFile(path, out);
|
||||
const dropped = [...existing.keys()].filter(
|
||||
(id) => !potEntries.some((e) => e.msgid === id),
|
||||
).length;
|
||||
console.log(
|
||||
`${lang}: ${potEntries.length} entries (+${added} new, -${dropped} obsolete` +
|
||||
(untranslated ? `, ${untranslated} UNTRANSLATED` : "") +
|
||||
")",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Vendor the color-token artifacts from the @eamonxg/luci-theme-tokens npm
|
||||
// package (renamed from @eamonxg/aurora-tokens; published from the
|
||||
// luci-theme-tokens repo). Everything here is a plain copy -- all derivation
|
||||
// happens upstream at package build time:
|
||||
// - .dev/src/resource/utils/tokens.global.js
|
||||
// - root/usr/share/aurora/color-tokens.conf
|
||||
// - .dev/scripts/aurora-presets.json (resolved preset hex values)
|
||||
// then stamps TOKENS_ENGINE_VERSION into view/aurora/studio.js and reruns
|
||||
// scripts/gen-presets.mjs. All vendored artifacts are committed; nothing
|
||||
// downstream (CI, SDK build, device) needs npm or the package repo.
|
||||
//
|
||||
// Source resolution:
|
||||
// 1. --local <path> a local luci-theme-tokens checkout (run `npm install`
|
||||
// + `node build.mjs` there first)
|
||||
// 2. npm registry the version pinned in package.json devDependencies
|
||||
// 3. sibling checkout ../luci-theme-tokens, when the registry is unreachable
|
||||
//
|
||||
// Usage: node scripts/sync-tokens.mjs [--local <path>]
|
||||
// node scripts/sync-tokens.mjs --check exit 1 if outputs are stale
|
||||
//
|
||||
// Zero dependencies, same as gen-presets.mjs.
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, dirname, relative, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// here = .dev/scripts, so the repo root is two levels up.
|
||||
const root = resolve(here, "../..");
|
||||
const PACKAGE = "@eamonxg/luci-theme-tokens";
|
||||
const check = process.argv.includes("--check");
|
||||
|
||||
const argValue = (flag) => {
|
||||
const index = process.argv.indexOf(flag);
|
||||
return index !== -1 ? process.argv[index + 1] : null;
|
||||
};
|
||||
|
||||
const pinnedVersion = () =>
|
||||
JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"))
|
||||
.devDependencies[PACKAGE];
|
||||
|
||||
const buildCheckout = (checkout) => {
|
||||
const built = spawnSync(process.execPath, [resolve(checkout, "build.mjs")], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (built.status !== 0)
|
||||
throw new Error(`building ${checkout} failed -- run \`npm install\` there first`);
|
||||
return checkout;
|
||||
};
|
||||
|
||||
const fetchFromRegistry = async (version) => {
|
||||
const tarballUrl = `https://registry.npmjs.org/${PACKAGE}/-/${PACKAGE.split("/")[1]}-${version}.tgz`;
|
||||
const response = await fetch(tarballUrl);
|
||||
if (!response.ok) throw new Error(`fetch ${tarballUrl}: HTTP ${response.status}`);
|
||||
const dir = mkdtempSync(join(tmpdir(), "luci-theme-tokens-"));
|
||||
writeFileSync(join(dir, "package.tgz"), Buffer.from(await response.arrayBuffer()));
|
||||
const extracted = spawnSync("tar", ["-xzf", join(dir, "package.tgz"), "-C", dir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (extracted.status !== 0) throw new Error("tar extraction failed");
|
||||
return join(dir, "package");
|
||||
};
|
||||
|
||||
async function loadPackage() {
|
||||
const explicit = argValue("--local");
|
||||
if (explicit) {
|
||||
console.log(`sync-tokens: source = local checkout ${explicit}`);
|
||||
return buildCheckout(explicit);
|
||||
}
|
||||
const version = pinnedVersion();
|
||||
try {
|
||||
const dir = await fetchFromRegistry(version);
|
||||
console.log(`sync-tokens: source = npm ${PACKAGE}@${version}`);
|
||||
return dir;
|
||||
} catch (error) {
|
||||
const sibling = resolve(root, "../luci-theme-tokens");
|
||||
if (existsSync(join(sibling, "build.mjs"))) {
|
||||
console.warn(
|
||||
`sync-tokens: WARNING registry unavailable (${error.message}); ` +
|
||||
`falling back to sibling checkout -- output may not match the pinned ${version}`,
|
||||
);
|
||||
return buildCheckout(sibling);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const pkgDir = await loadPackage();
|
||||
const dist = (f) => readFileSync(join(pkgDir, "dist/aurora", f), "utf8");
|
||||
const version = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")).version;
|
||||
|
||||
// Stamp the engine version into theme.js: it appends ?v= when loading
|
||||
// tokens.global.js so a browser can never pair new theme.js with a cached engine.
|
||||
const THEME_VIEW = resolve(root, ".dev/src/resource/view/aurora/studio.js");
|
||||
const versionLine = `const TOKENS_ENGINE_VERSION = "${version}";`;
|
||||
const themeSource = readFileSync(THEME_VIEW, "utf8").replace(
|
||||
/const TOKENS_ENGINE_VERSION = "[^"]*";/,
|
||||
versionLine,
|
||||
);
|
||||
if (!themeSource.includes(versionLine))
|
||||
throw new Error("TOKENS_ENGINE_VERSION marker not found in theme.js");
|
||||
|
||||
const outputs = [
|
||||
[resolve(root, ".dev/src/resource/utils/tokens.global.js"), dist("tokens.global.js")],
|
||||
[resolve(root, "root/usr/share/aurora/color-tokens.conf"), dist("color-tokens.conf")],
|
||||
[resolve(here, "aurora-presets.json"), dist("presets.json")],
|
||||
[THEME_VIEW, themeSource],
|
||||
];
|
||||
|
||||
let stale = false;
|
||||
for (const [path, content] of outputs) {
|
||||
const name = relative(root, path);
|
||||
let current = null;
|
||||
try {
|
||||
current = readFileSync(path, "utf8");
|
||||
} catch {}
|
||||
if (current === content) {
|
||||
console.log(`sync-tokens: ${name} up to date`);
|
||||
continue;
|
||||
}
|
||||
if (check) {
|
||||
console.error(`sync-tokens: ${name} is STALE -- run: node scripts/sync-tokens.mjs`);
|
||||
stale = true;
|
||||
continue;
|
||||
}
|
||||
writeFileSync(path, content, "utf8");
|
||||
console.log(`sync-tokens: wrote ${name}`);
|
||||
}
|
||||
|
||||
if (check) process.exit(stale ? 1 : 0);
|
||||
|
||||
// Preset templates embed resolved preset values; refresh them from the JSON.
|
||||
const presets = spawnSync(process.execPath, [resolve(here, "gen-presets.mjs")], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
process.exit(presets.status ?? 1);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"presets": {
|
||||
"default": {
|
||||
"colors": {
|
||||
"light_bg": "#f7fafc",
|
||||
"light_surface": "#fff",
|
||||
"light_text": "#121a22",
|
||||
"light_brand": "#0086bf",
|
||||
"dark_bg": "#05070e",
|
||||
"dark_surface": "#141822",
|
||||
"dark_text": "#f9fafb",
|
||||
"dark_brand": "#00978f"
|
||||
},
|
||||
"layout": {
|
||||
"nav_type": "mega-menu",
|
||||
"struct_spacing": "0.25rem",
|
||||
"struct_radius_base": "0.5rem",
|
||||
"struct_content_width_centered": "80rem",
|
||||
"toolbar_enabled": "1"
|
||||
},
|
||||
"typography": {
|
||||
"struct_font_sans": "\"Lato\", ui-sans-serif, system-ui, sans-serif",
|
||||
"struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
|
||||
"font_sans": "default",
|
||||
"font_mono": "default"
|
||||
},
|
||||
"toolbar": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"url": "/cgi-bin/luci/admin/status/overview",
|
||||
"icon": "overview.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "System",
|
||||
"url": "/cgi-bin/luci/admin/system/system",
|
||||
"icon": "system.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "Software",
|
||||
"url": "/cgi-bin/luci/admin/system/package-manager",
|
||||
"icon": "software.svg",
|
||||
"enabled": "0"
|
||||
},
|
||||
{
|
||||
"title": "Network",
|
||||
"url": "/cgi-bin/luci/admin/network/network",
|
||||
"icon": "network.svg",
|
||||
"enabled": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sage-green": {
|
||||
"colors": {
|
||||
"light_bg": "#f8f7f4",
|
||||
"light_surface": "#fff",
|
||||
"light_text": "#1a1f2e",
|
||||
"light_brand": "#7c9082",
|
||||
"dark_bg": "#0a0a0a",
|
||||
"dark_surface": "#121212",
|
||||
"dark_text": "#f5f5f5",
|
||||
"dark_brand": "#7c9082"
|
||||
},
|
||||
"layout": {
|
||||
"nav_type": "mega-menu",
|
||||
"struct_spacing": "0.3rem",
|
||||
"struct_radius_base": "0.875rem",
|
||||
"struct_content_width_centered": "92rem",
|
||||
"toolbar_enabled": "1"
|
||||
},
|
||||
"typography": {
|
||||
"struct_font_sans": "\"Nunito\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
|
||||
"struct_font_mono": "\"Maple Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
|
||||
"font_sans": "nunito",
|
||||
"font_mono": "maple-mono"
|
||||
},
|
||||
"toolbar": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"url": "/cgi-bin/luci/admin/status/overview",
|
||||
"icon": "overview.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "System",
|
||||
"url": "/cgi-bin/luci/admin/system/system",
|
||||
"icon": "system.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "Software",
|
||||
"url": "/cgi-bin/luci/admin/system/package-manager",
|
||||
"icon": "software.svg",
|
||||
"enabled": "0"
|
||||
},
|
||||
{
|
||||
"title": "Network",
|
||||
"url": "/cgi-bin/luci/admin/network/network",
|
||||
"icon": "network.svg",
|
||||
"enabled": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"amber-sand": {
|
||||
"colors": {
|
||||
"light_bg": "#faf9f5",
|
||||
"light_surface": "#faf9f5",
|
||||
"light_text": "#3d3929",
|
||||
"light_brand": "#c96442",
|
||||
"dark_bg": "#262624",
|
||||
"dark_surface": "#30302e",
|
||||
"dark_text": "#c3c0b6",
|
||||
"dark_brand": "#d97757"
|
||||
},
|
||||
"layout": {
|
||||
"nav_type": "sidebar",
|
||||
"struct_spacing": "0.25rem",
|
||||
"struct_radius_base": "0.375rem",
|
||||
"struct_content_width_centered": "96rem",
|
||||
"toolbar_enabled": "0"
|
||||
},
|
||||
"typography": {
|
||||
"struct_font_sans": "\"Space Grotesk\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
|
||||
"struct_font_mono": "\"Fira Code\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
|
||||
"font_sans": "space-grotesk",
|
||||
"font_mono": "fira-code"
|
||||
},
|
||||
"toolbar": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"url": "/cgi-bin/luci/admin/status/overview",
|
||||
"icon": "overview.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "System",
|
||||
"url": "/cgi-bin/luci/admin/system/system",
|
||||
"icon": "system.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "Software",
|
||||
"url": "/cgi-bin/luci/admin/system/package-manager",
|
||||
"icon": "software.svg",
|
||||
"enabled": "0"
|
||||
},
|
||||
{
|
||||
"title": "Network",
|
||||
"url": "/cgi-bin/luci/admin/network/network",
|
||||
"icon": "network.svg",
|
||||
"enabled": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"monochrome": {
|
||||
"colors": {
|
||||
"light_bg": "#fcfcfc",
|
||||
"light_surface": "#fff",
|
||||
"light_text": "#000",
|
||||
"light_brand": "#000",
|
||||
"dark_bg": "#000",
|
||||
"dark_surface": "#090909",
|
||||
"dark_text": "#fff",
|
||||
"dark_brand": "#fff"
|
||||
},
|
||||
"layout": {
|
||||
"nav_type": "dropdown",
|
||||
"struct_spacing": "0.2rem",
|
||||
"struct_radius_base": "0rem",
|
||||
"struct_content_width_centered": "88rem",
|
||||
"toolbar_enabled": "0"
|
||||
},
|
||||
"typography": {
|
||||
"struct_font_sans": "\"Geist Sans\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
|
||||
"struct_font_mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
|
||||
"font_sans": "geist-sans",
|
||||
"font_mono": "jetbrains-mono"
|
||||
},
|
||||
"toolbar": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"url": "/cgi-bin/luci/admin/status/overview",
|
||||
"icon": "overview.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "System",
|
||||
"url": "/cgi-bin/luci/admin/system/system",
|
||||
"icon": "system.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "Software",
|
||||
"url": "/cgi-bin/luci/admin/system/package-manager",
|
||||
"icon": "software.svg",
|
||||
"enabled": "0"
|
||||
},
|
||||
{
|
||||
"title": "Network",
|
||||
"url": "/cgi-bin/luci/admin/network/network",
|
||||
"icon": "network.svg",
|
||||
"enabled": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sky-blue": {
|
||||
"colors": {
|
||||
"light_bg": "#fff",
|
||||
"light_surface": "#f7f8f8",
|
||||
"light_text": "#0f1419",
|
||||
"light_brand": "#1e9df1",
|
||||
"dark_bg": "#000",
|
||||
"dark_surface": "#17181c",
|
||||
"dark_text": "#e7e9ea",
|
||||
"dark_brand": "#1c9cf0"
|
||||
},
|
||||
"layout": {
|
||||
"nav_type": "sidebar",
|
||||
"struct_spacing": "0.25rem",
|
||||
"struct_radius_base": "0.75rem",
|
||||
"struct_content_width_centered": "80rem",
|
||||
"toolbar_enabled": "1"
|
||||
},
|
||||
"typography": {
|
||||
"struct_font_sans": "system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif",
|
||||
"struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
|
||||
"font_sans": "system",
|
||||
"font_mono": "default"
|
||||
},
|
||||
"toolbar": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"url": "/cgi-bin/luci/admin/status/overview",
|
||||
"icon": "overview.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "System",
|
||||
"url": "/cgi-bin/luci/admin/system/system",
|
||||
"icon": "system.svg",
|
||||
"enabled": "1"
|
||||
},
|
||||
{
|
||||
"title": "Software",
|
||||
"url": "/cgi-bin/luci/admin/system/package-manager",
|
||||
"icon": "software.svg",
|
||||
"enabled": "0"
|
||||
},
|
||||
{
|
||||
"title": "Network",
|
||||
"url": "/cgi-bin/luci/admin/network/network",
|
||||
"icon": "network.svg",
|
||||
"enabled": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
|
||||
const INDICATOR_ID = "aurora-inbox";
|
||||
const STYLE_ID = "aurora-inbox-indicator";
|
||||
const INBOX_PATH = "admin/system/aurora/marketplace";
|
||||
const INBOX_HASH = "#inbox";
|
||||
|
||||
const ICON_CLASS = "aurora-inbox-icon";
|
||||
const INDICATOR = '#indicators span[data-indicator="' + INDICATOR_ID + '"].' + ICON_CLASS;
|
||||
|
||||
// Only for themes that hide indicator text and paint ::before by indicator
|
||||
// name (Aurora, shadcn): there an unknown name is a solid square. Themes that
|
||||
// show the text keep LuCI's plain "Inbox 3".
|
||||
const INDICATOR_CSS =
|
||||
INDICATOR +
|
||||
"{position:relative;order:1;--aurora-inbox-icon:url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M22 12h-6l-2 3h-4l-2-3H2'/%3e%3cpath d='M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z'/%3e%3c/svg%3e\")}" +
|
||||
INDICATOR +
|
||||
"::before{-webkit-mask:var(--aurora-inbox-icon) center/cover no-repeat;mask:var(--aurora-inbox-icon) center/cover no-repeat}" +
|
||||
INDICATOR +
|
||||
"[data-count]::after{content:attr(data-count);position:absolute;top:-2px;right:-2px;z-index:10;" +
|
||||
"display:flex;align-items:center;justify-content:center;min-width:12px;min-height:12px;" +
|
||||
"padding:0 2px;border-radius:99px;background:var(--danger);color:var(--on-brand);" +
|
||||
"font-size:8px;font-weight:700;line-height:1;box-shadow:0 1px 2px rgba(0,0,0,.1)}";
|
||||
|
||||
const openInbox = () => {
|
||||
window.location.href = L.url(INBOX_PATH) + INBOX_HASH;
|
||||
};
|
||||
|
||||
const update = () =>
|
||||
Promise.all([L.require("utils.notices"), L.require("utils.hub-api"), L.require("ui")]).then(
|
||||
([notices, hubApi, ui]) => {
|
||||
const count = notices.badgeCount(
|
||||
hubApi.noticesCache.getStale(),
|
||||
hubApi.meCache.getStale(),
|
||||
hubApi.inboxState(),
|
||||
);
|
||||
if (!count) return ui.hideIndicator(INDICATOR_ID);
|
||||
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = E("style", { id: STYLE_ID });
|
||||
style.textContent = INDICATOR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
ui.showIndicator(INDICATOR_ID, _("Inbox") + " " + count, openInbox, "active");
|
||||
const node = document.querySelector('span[data-indicator="' + INDICATOR_ID + '"]');
|
||||
if (node) {
|
||||
node.setAttribute("data-count", count);
|
||||
node.title = node.textContent;
|
||||
if (window.getComputedStyle(node).fontSize === "0px") node.classList.add(ICON_CLASS);
|
||||
// Last in the DOM and the highest order: after the existing indicators in
|
||||
// either row direction, and still there when a later one appears.
|
||||
node.parentNode.appendChild(node);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const run = () =>
|
||||
Promise.all([L.require("utils.notices"), L.require("utils.hub-api")])
|
||||
.then(([notices, hubApi]) =>
|
||||
notices.isDue(hubApi.noticesCheckedAt(), Date.now())
|
||||
? hubApi.refreshNotices().catch(() => null)
|
||||
: null,
|
||||
)
|
||||
.then(update);
|
||||
|
||||
return baseclass.extend({
|
||||
INBOX_HASH: INBOX_HASH,
|
||||
|
||||
__init__() {
|
||||
if (!L.env.sessionid) return;
|
||||
document.addEventListener(
|
||||
"luci-loaded",
|
||||
() =>
|
||||
(window.requestIdleCallback || window.setTimeout)(() =>
|
||||
Promise.resolve().then(run).catch(() => {}),
|
||||
),
|
||||
{ once: true },
|
||||
);
|
||||
},
|
||||
|
||||
update() {
|
||||
return Promise.resolve().then(update).catch(() => {});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
"require rpc";
|
||||
"require ui";
|
||||
|
||||
// Shared upload plumbing for the config UI. Two surfaces consume it (custom
|
||||
// fonts, brand asset library); anything font- or image-specific stays with
|
||||
// the caller -- this module only knows about files, bytes, and the
|
||||
// cgi-upload channel.
|
||||
|
||||
const MAX_UPLOAD = 8 * 1024 * 1024;
|
||||
|
||||
const formatSize = (bytes) =>
|
||||
bytes >= 1048576
|
||||
? (bytes / 1048576).toFixed(1) + " MB"
|
||||
: Math.max(1, Math.round(bytes / 1024)) + " KB";
|
||||
|
||||
const extOf = (name) => (name.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
|
||||
|
||||
return baseclass.extend({
|
||||
MAX_UPLOAD,
|
||||
|
||||
formatSize,
|
||||
|
||||
extOf,
|
||||
|
||||
// Pure pre-check for instant feedback; the rpcd receive_upload gate stays
|
||||
// authoritative. exts is a lowercase list without dots, e.g. ["woff2"].
|
||||
checkFile(file, opts) {
|
||||
const exts = opts?.exts || [];
|
||||
const ext = extOf(file.name);
|
||||
if (exts.length && !exts.includes(ext))
|
||||
return {
|
||||
ok: false,
|
||||
err: _("Unsupported file type. Allowed: %s").format(exts.join(", ")),
|
||||
};
|
||||
if (file.size > MAX_UPLOAD)
|
||||
return {
|
||||
ok: false,
|
||||
err: _("File is %s, exceeding the 8MB limit.").format(
|
||||
formatSize(file.size),
|
||||
),
|
||||
};
|
||||
return { ok: true, err: "" };
|
||||
},
|
||||
|
||||
// XHR to /cgi-bin/cgi-upload. Resolves on HTTP 200; the caller still has
|
||||
// to run its own RPC confirm (upload_font / upload_icon) afterwards.
|
||||
uploadToRouter(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (opts.onProgress)
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable)
|
||||
opts.onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () =>
|
||||
xhr.status === 200
|
||||
? resolve()
|
||||
: reject(new Error(_("Upload failed (HTTP %s)").format(xhr.status))),
|
||||
);
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error(_("Upload failed"))),
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("sessionid", rpc.getSessionID());
|
||||
formData.append("filename", opts.tmpPath);
|
||||
formData.append("filemode", "0600");
|
||||
formData.append("filedata", opts.file, opts.file.name);
|
||||
|
||||
xhr.open("POST", "/cgi-bin/cgi-upload");
|
||||
xhr.withCredentials = true;
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
// Composite "asset manager": a native LuCI `.table` (preview | name |
|
||||
// badge | size | delete) sitting on a dock that shows either the slim
|
||||
// upload bar, the in-place confirm form, or a progress row -- never more
|
||||
// than one at a time. Both custom-fonts and the brand asset library
|
||||
// render through this; the only per-kind knowledge lives in the caller's
|
||||
// row/bar/form/checkFile/upload callbacks. Table markup deliberately
|
||||
// mirrors the theme's other cbi tables (`table` / `tr table-titles` /
|
||||
// `th` / `tr` / `td`) so it inherits the Aurora theme's colors, dark
|
||||
// mode, and row hover instead of fighting it with bespoke inline chrome.
|
||||
//
|
||||
// cfg = {
|
||||
// badgeHeader, // _("Slot") or _("Type")
|
||||
// emptyText,
|
||||
// rows: [{ preview, name, badge, size, onDelete() }], // badge: plain text or falsy
|
||||
// bar: { hint, sub, accept },
|
||||
// checkFile(file) -> { ok, err },
|
||||
// form: {
|
||||
// fields(file) -> {
|
||||
// rows: [{label, control}], // rendered as cbi-value label/field rows
|
||||
// value(), valid(), setDisabled(bool), // bool reaches every control
|
||||
// },
|
||||
// },
|
||||
// upload(file, meta, onProgress) -> Promise, // caller composes
|
||||
// // uploadToRouter + RPC
|
||||
// }
|
||||
createAssetManager(cfg) {
|
||||
const rows = cfg.rows || [];
|
||||
|
||||
const shell = E("div", {});
|
||||
|
||||
const dock = E("div", {});
|
||||
shell.appendChild(dock);
|
||||
|
||||
if (rows.length) {
|
||||
shell.appendChild(
|
||||
E("table", { class: "table" }, [
|
||||
E("tr", { class: "tr table-titles" }, [
|
||||
E("th", { class: "th", style: "width:56px;" }, ""),
|
||||
E("th", { class: "th" }, _("Name")),
|
||||
E("th", { class: "th" }, cfg.badgeHeader),
|
||||
E("th", { class: "th" }, _("Size")),
|
||||
E("th", { class: "th center" }, ""),
|
||||
]),
|
||||
...rows.map((row) =>
|
||||
E("tr", { class: "tr" }, [
|
||||
E("td", { class: "td" }, row.preview),
|
||||
E(
|
||||
"td",
|
||||
{ class: "td", style: "word-break:break-all;" },
|
||||
row.name,
|
||||
),
|
||||
E(
|
||||
"td",
|
||||
{
|
||||
class: "td",
|
||||
style: "color:var(--text-muted);font-size:0.9em;",
|
||||
},
|
||||
row.badge || "",
|
||||
),
|
||||
E(
|
||||
"td",
|
||||
{
|
||||
class: "td",
|
||||
style:
|
||||
"color:var(--text-muted);font-variant-numeric:tabular-nums;" +
|
||||
"white-space:nowrap;",
|
||||
},
|
||||
formatSize(row.size || 0),
|
||||
),
|
||||
E(
|
||||
"td",
|
||||
{ class: "td center" },
|
||||
E(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "cbi-button cbi-button-remove",
|
||||
click: row.onDelete,
|
||||
},
|
||||
_("Delete"),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
shell.appendChild(
|
||||
E("div", { style: "padding:0.5em 0;" }, [E("em", {}, cfg.emptyText)]),
|
||||
);
|
||||
}
|
||||
|
||||
const clearDock = () => {
|
||||
while (dock.firstChild) dock.firstChild.remove();
|
||||
};
|
||||
|
||||
const renderBar = () => {
|
||||
clearDock();
|
||||
const input = E("input", {
|
||||
type: "file",
|
||||
style: "display:none;",
|
||||
accept: (cfg.bar && cfg.bar.accept) || "",
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = "";
|
||||
if (file) onFile(file);
|
||||
});
|
||||
|
||||
const bar = E(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
style:
|
||||
"display:block;width:100%;" +
|
||||
"border:2px dashed var(--hairline);border-radius:0.5em;" +
|
||||
"padding:1.25em 1em;text-align:center;cursor:pointer;" +
|
||||
"margin-bottom:0.75em;" +
|
||||
"transition:border-color 0.15s,background 0.15s;",
|
||||
click: () => input.click(),
|
||||
dragover: (e) => {
|
||||
e.preventDefault();
|
||||
bar.style.borderColor = "var(--brand)";
|
||||
bar.style.background = "var(--brand-subtle)";
|
||||
},
|
||||
dragleave: () => {
|
||||
bar.style.borderColor = "";
|
||||
bar.style.background = "";
|
||||
},
|
||||
drop: (e) => {
|
||||
e.preventDefault();
|
||||
bar.style.borderColor = "";
|
||||
bar.style.background = "";
|
||||
const file = e.dataTransfer && e.dataTransfer.files[0];
|
||||
if (file) onFile(file);
|
||||
},
|
||||
},
|
||||
[
|
||||
E(
|
||||
"div",
|
||||
{ style: "font-size:1.5em;margin-bottom:0.25em;pointer-events:none;" },
|
||||
"⬆",
|
||||
),
|
||||
E("strong", { style: "pointer-events:none;" }, cfg.bar.hint),
|
||||
E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"font-size:0.8em;opacity:0.6;margin-top:0.25em;pointer-events:none;",
|
||||
},
|
||||
cfg.bar.sub || "",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
dock.appendChild(bar);
|
||||
dock.appendChild(input);
|
||||
};
|
||||
|
||||
const buildForm = (file, check) => {
|
||||
const fields = cfg.form.fields(file);
|
||||
|
||||
const progressBar = E("div", {
|
||||
style:
|
||||
"height:100%;width:0%;transition:width 0.12s linear;" +
|
||||
"background:var(--brand);border-radius:999px;",
|
||||
});
|
||||
const progressWrap = E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"display:none;height:5px;border-radius:999px;" +
|
||||
"background:var(--hairline);overflow:hidden;margin-top:0.6em;",
|
||||
},
|
||||
[progressBar],
|
||||
);
|
||||
const setProgress = (p) => {
|
||||
progressBar.style.width = p + "%";
|
||||
};
|
||||
const showProgress = () => {
|
||||
progressWrap.style.display = "block";
|
||||
};
|
||||
const hideProgress = () => {
|
||||
progressWrap.style.display = "none";
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
let busy = false;
|
||||
|
||||
const cancelBtn = E(
|
||||
"button",
|
||||
{ type: "button", class: "cbi-button", click: renderBar },
|
||||
_("Cancel"),
|
||||
);
|
||||
|
||||
const fileSummary = E("div", { style: "margin-bottom:0.6em;" }, [
|
||||
E("strong", { style: "word-break:break-all;" }, file.name),
|
||||
" · " + formatSize(file.size),
|
||||
]);
|
||||
|
||||
const errEl = check.ok
|
||||
? null
|
||||
: E(
|
||||
"p",
|
||||
{
|
||||
style:
|
||||
"color:var(--danger);font-weight:600;font-size:0.9em;" +
|
||||
"margin:0 0 0.6em;",
|
||||
},
|
||||
check.err,
|
||||
);
|
||||
|
||||
const rowsEl = E(
|
||||
"div",
|
||||
{ style: "margin-bottom:0.6em;" },
|
||||
fields.rows.map((row) =>
|
||||
E("div", { class: "cbi-value" }, [
|
||||
E("label", { class: "cbi-value-title" }, row.label),
|
||||
E("div", { class: "cbi-value-field" }, row.control),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const goBtn = E(
|
||||
"button",
|
||||
{ type: "button", class: "cbi-button cbi-button-positive" },
|
||||
_("Confirm Upload"),
|
||||
);
|
||||
const updateGoState = () => {
|
||||
if (busy) return;
|
||||
goBtn.disabled = !check.ok || !fields.valid();
|
||||
};
|
||||
updateGoState();
|
||||
rowsEl.addEventListener("input", updateGoState);
|
||||
rowsEl.addEventListener("change", updateGoState);
|
||||
|
||||
const actionsRow = E("div", { class: "right" }, [
|
||||
cancelBtn,
|
||||
" ",
|
||||
goBtn,
|
||||
]);
|
||||
|
||||
goBtn.addEventListener("click", () => {
|
||||
busy = true;
|
||||
goBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
fields.setDisabled(true);
|
||||
showProgress();
|
||||
setProgress(0);
|
||||
const meta = fields.value();
|
||||
cfg
|
||||
.upload(file, meta, setProgress)
|
||||
.catch((err) => {
|
||||
busy = false;
|
||||
cancelBtn.disabled = false;
|
||||
fields.setDisabled(false);
|
||||
hideProgress();
|
||||
updateGoState();
|
||||
ui.addNotification(
|
||||
null,
|
||||
E("p", _("Upload failed: %s").format(err.message)),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"margin-bottom:0.5em;padding-bottom:0.6em;" +
|
||||
"border-bottom:1px solid var(--hairline);",
|
||||
},
|
||||
[fileSummary, errEl, rowsEl, actionsRow, progressWrap].filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
const onFile = (file) => {
|
||||
const check = cfg.checkFile(file);
|
||||
clearDock();
|
||||
dock.appendChild(buildForm(file, check));
|
||||
};
|
||||
|
||||
renderBar();
|
||||
return shell;
|
||||
},
|
||||
|
||||
confirmDelete(opts) {
|
||||
return new Promise((resolve) => {
|
||||
ui.showModal(opts.title, [
|
||||
E("p", {}, opts.message),
|
||||
E("div", { class: "right" }, [
|
||||
E(
|
||||
"button",
|
||||
{
|
||||
class: "btn",
|
||||
click: () => {
|
||||
ui.hideModal();
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
_("Cancel"),
|
||||
),
|
||||
" ",
|
||||
E(
|
||||
"button",
|
||||
{
|
||||
class: "btn cbi-button-negative",
|
||||
click: () => {
|
||||
ui.hideModal();
|
||||
resolve(true);
|
||||
},
|
||||
},
|
||||
_("Delete"),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
colorjs.io -- https://colorjs.io -- MIT License
|
||||
Copyright (c) 2021 Lea Verou, Chris Lilley
|
||||
|
||||
This build embeds the following, each under the MIT License:
|
||||
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2024 Matt DesLauriers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
----------------------------------------------------------------
|
||||
|
||||
Copyright (c) 2021 Björn Ottosson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
|
||||
// 更新源相关的纯判断:版本比对与软件包页定位。
|
||||
// 刻意不碰 DOM、不发请求 —— 这样它们能被真正单元测试,而不是只做正则断言。
|
||||
|
||||
// 软件包页的地址在各 OpenWrt 版本间不一致(主题支持 23.05 及以后):
|
||||
// snapshot 上实测是 package-manager,更早的版本用 opkg。不硬编码猜测,而是
|
||||
// 从 ui.menu.load() 的真实菜单树里找,顺带覆盖两种本来要单独处理的情况:
|
||||
// 用户无权访问该页(节点带 satisfied:false),以及精简固件根本没装这个页面。
|
||||
const PACKAGE_PAGE_CANDIDATES = ["package-manager", "opkg", "packages"];
|
||||
|
||||
return baseclass.extend({
|
||||
PACKAGE_PAGE_CANDIDATES: PACKAGE_PAGE_CANDIDATES,
|
||||
|
||||
// "1.1.3-r20260803" → 20260803;其余形态一律 null。
|
||||
parseRevision: function (version) {
|
||||
const match = /-r(\d{8})\b/.exec(String(version == null ? "" : version));
|
||||
return match ? Number(match[1]) : null;
|
||||
},
|
||||
|
||||
// install.sh 写明 opkg 与 apk 的版本方案不可比较、脚本从不排序它们。
|
||||
// 这里沿用同一条纪律:只有两边都带 rYYYYMMDD 戳时才敢说"有新版"。
|
||||
// 判不出来就沉默 —— 误报"有新版"比不报更糟。
|
||||
isNewer: function (installed, available) {
|
||||
const a = this.parseRevision(installed);
|
||||
const b = this.parseRevision(available);
|
||||
if (a === null || b === null) return false;
|
||||
return b > a;
|
||||
},
|
||||
|
||||
// 从 manifest.json 里取某渠道某包的版本。结构由 feed 的 gen-manifest.sh
|
||||
// 生成:channels[渠道][格式] 是一个 {pkg, version} 数组。
|
||||
findManifestVersion: function (manifest, channel, format, pkg) {
|
||||
const entries = manifest?.channels?.[channel]?.[format];
|
||||
if (!Array.isArray(entries)) return null;
|
||||
const hit = entries.find((entry) => entry?.pkg === pkg);
|
||||
return hit?.version || null;
|
||||
},
|
||||
|
||||
pickPackageManagerPath: function (menuTree) {
|
||||
const system = menuTree?.children?.admin?.children?.system?.children;
|
||||
if (!system) return null;
|
||||
const name = PACKAGE_PAGE_CANDIDATES.find((candidate) => {
|
||||
const node = system[candidate];
|
||||
// ui.menu 保留无权限的节点但标记 satisfied:false(getChildren 就是按它
|
||||
// 过滤的)。不检查这个的话,会给没权限的用户渲染出一条点了吃 403 的链接。
|
||||
return node && node.satisfied !== false;
|
||||
});
|
||||
return name ? `admin/system/${name}` : null;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
"require rpc";
|
||||
"require utils.notices as notices";
|
||||
|
||||
const CACHE_KEY = "aurora.hub.list";
|
||||
const ME_CACHE_KEY = "aurora.hub.me";
|
||||
const NOTICES_CACHE_KEY = "aurora.hub.notices";
|
||||
const INBOX_READ_KEY = "aurora.hub.inboxRead";
|
||||
const INBOX_DONE_KEY = "aurora.hub.inboxDone";
|
||||
const NOTICES_CHECKED_KEY = "aurora.hub.noticesChecked";
|
||||
|
||||
const HUB_BASE = "https://themes.eamonxg.fun";
|
||||
const HUB_TIMEOUT_MS = 15000;
|
||||
const HUB_NOT_FOUND = "invalid_id";
|
||||
|
||||
// 本客户端读得懂的最高 payload schema。升 schema 时只改这一处。
|
||||
const CLIENT_SCHEMA = 1;
|
||||
|
||||
// 浏览器直连 hub 的读路径。返回包络刻意与 rpcd 保持一致
|
||||
// ({result:0,data} / {result:1,error}),marketplace.js 无需感知传输方式的变化。
|
||||
const hubFetch = (path) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), HUB_TIMEOUT_MS);
|
||||
return fetch(HUB_BASE + path, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 404) return { result: 1, error: HUB_NOT_FOUND };
|
||||
if (!res.ok) return { result: 1, error: "hub_unreachable" };
|
||||
return res.json().then((data) => ({ result: 0, data }));
|
||||
})
|
||||
.catch(() => ({ result: 1, error: "hub_unreachable" }))
|
||||
.finally(() => clearTimeout(timer));
|
||||
};
|
||||
|
||||
// 资产字节走浏览器,不走路由器。原因不是"更快"(虽然确实快),是 OpenWrt 的
|
||||
// uclient-fetch 走 TLS 根本推不动:实测同一台机器,512KB 三次里挂一次、1MB
|
||||
// 必断("Connection reset prematurely"),而同机 curl 传 1.6MB 只要 3.4 秒
|
||||
// —— 是它的 TLS 写路径在缓冲填满后不再续写。浏览器 fetch/XHR 没这个毛病。
|
||||
//
|
||||
// 浏览器拿到的是一张票据,不是 device_token:票据把 (draft_id, kind, size,
|
||||
// sha256) 全钉死,泄漏了也只能把同一份字节再传一遍。device_token 是创作者
|
||||
// 身份本身 —— 谁拿到谁能以你的名义发布、删掉你所有作品 —— 它留在路由器
|
||||
// 那个 0600 的文件里。
|
||||
//
|
||||
// 用 XHR 而不是 fetch,只为了 upload.onprogress:一张 1.2MB 的图在慢上行的
|
||||
// 线路上要传十几秒,没有进度条的等待会被当成卡死。
|
||||
const putAssetBytes = (url, ticket, blob, onProgress) =>
|
||||
new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Authorization", "Bearer " + ticket);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.timeout = 120000;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
|
||||
};
|
||||
xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300);
|
||||
xhr.onerror = () => resolve(false);
|
||||
xhr.ontimeout = () => resolve(false);
|
||||
xhr.send(blob);
|
||||
});
|
||||
|
||||
// hub 的资产 url 是相对路径("/assets/{id}/{kind}"),和详情接口一直以来的
|
||||
// 返回一致 —— 拼接 base 是客户端的活,这样缓存下来的列表里不会烙进主机名。
|
||||
// HUB_BASE 归本模块所有,所以拼接也放在这里。
|
||||
//
|
||||
// 路径来自 hub,属于不可信输入,因此按 hub 唯一可能产出的形状做匹配,而不是
|
||||
// 直接拼。其它一律返回 "",调用方退回纯色块。
|
||||
const ASSET_PATH_RE = /^\/assets\/[A-Za-z0-9]{1,32}\/[a-z0-9_]{1,32}$/;
|
||||
|
||||
// 两份 localStorage 缓存的共同实现。它们只作首帧的种子:render 先用缓存画出
|
||||
// 画面,随后 fetchSort / refreshMyShares 的结果无条件覆盖 —— 所以刻意不设
|
||||
// TTL。到 hub 的每次往返都是 205ms 起步、首连约 800ms(TLS 握手不可复用),
|
||||
// 一份"旧但立刻可用"的数据,比一次准确的白屏有用得多。
|
||||
//
|
||||
// 信封保留 timestamp:它没有读者,但在真机上翻 localStorage 时,它是唯一能看
|
||||
// 出这帧画面有多旧的东西,而且保持了与旧版本写入的条目同形,无需迁移。
|
||||
const makeCache = (key, label) => ({
|
||||
getStale() {
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (!cached) return null;
|
||||
return JSON.parse(cached).value ?? null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set(value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), value }));
|
||||
} catch (e) {
|
||||
console.error("Failed to cache hub " + label + " data:", e);
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (e) {}
|
||||
},
|
||||
});
|
||||
|
||||
const readStored = (key, fallback) => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key));
|
||||
return parsed == null ? fallback : parsed;
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStored = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const patchSnapshot = (cache, patch) => {
|
||||
const stale = cache.getStale();
|
||||
const snapshot = Object.assign(
|
||||
{
|
||||
notices: notices.sanitize(stale && stale.notices),
|
||||
local: (stale && stale.local) || null,
|
||||
},
|
||||
patch,
|
||||
);
|
||||
cache.set(snapshot);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
return baseclass.extend({
|
||||
listCache: makeCache(CACHE_KEY, "list"),
|
||||
|
||||
meCache: makeCache(ME_CACHE_KEY, "me"),
|
||||
|
||||
// { notices, local }
|
||||
noticesCache: makeCache(NOTICES_CACHE_KEY, "notices"),
|
||||
|
||||
CLIENT_SCHEMA: CLIENT_SCHEMA,
|
||||
|
||||
callHubList(sort, page) {
|
||||
const safeSort = sort === "new" ? "new" : "hot";
|
||||
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
|
||||
return hubFetch(
|
||||
"/api/v1/themes/aurora/configs?sort=" +
|
||||
safeSort +
|
||||
"&page=" +
|
||||
safePage +
|
||||
"&schema=" +
|
||||
CLIENT_SCHEMA,
|
||||
);
|
||||
},
|
||||
|
||||
callHubNotices() {
|
||||
return hubFetch("/api/v1/notices?theme=aurora&schema=" + CLIENT_SCHEMA).then((res) =>
|
||||
res.error === HUB_NOT_FOUND ? { result: 0, data: { notices: [] } } : res,
|
||||
);
|
||||
},
|
||||
|
||||
noticesCheckedAt() {
|
||||
const stamp = readStored(NOTICES_CHECKED_KEY, 0);
|
||||
return Number.isFinite(stamp) ? stamp : 0;
|
||||
},
|
||||
|
||||
markNoticesChecked(nowMs) {
|
||||
writeStored(NOTICES_CHECKED_KEY, nowMs);
|
||||
},
|
||||
|
||||
inboxState() {
|
||||
const keys = (key) => {
|
||||
const list = readStored(key, []);
|
||||
return Array.isArray(list) ? list.filter((item) => typeof item === "string") : [];
|
||||
};
|
||||
return { read: keys(INBOX_READ_KEY), done: keys(INBOX_DONE_KEY) };
|
||||
},
|
||||
|
||||
setInboxState(state) {
|
||||
writeStored(INBOX_READ_KEY, state.read);
|
||||
writeStored(INBOX_DONE_KEY, state.done);
|
||||
},
|
||||
|
||||
// local 随 feed 一起落缓存:preload 在两次轮询之间只读缓存,
|
||||
// 不为"缺不缺更新源"去问 ubus。
|
||||
setNoticesLocal(local) {
|
||||
return patchSnapshot(this.noticesCache, { local: local });
|
||||
},
|
||||
|
||||
// options.me:调用方自己已经在跑 hub_me 时,把那份结果(或它的 promise)传
|
||||
// 进来。不传则只在 feed 里有 creators 通知、或缓存显示本机发过作品时才调
|
||||
// hub_me(路由器 → hub,约 1 s)。
|
||||
refreshNotices(options) {
|
||||
const opts = options || {};
|
||||
const supplied =
|
||||
opts.me === undefined ? null : Promise.resolve(opts.me).catch(() => null);
|
||||
this.markNoticesChecked(Date.now());
|
||||
const probed = L.resolveDefault(this.callGetInitData(), null).then((reply) =>
|
||||
notices.localState(reply),
|
||||
);
|
||||
return Promise.all([this.callHubNotices(), probed]).then(([res, fresh]) => {
|
||||
const stale = this.noticesCache.getStale();
|
||||
const local = fresh || (stale && stale.local) || null;
|
||||
const known = { feed: Boolean(res && res.result === 0), me: false, local: Boolean(fresh) };
|
||||
const snapshot = {
|
||||
notices: notices.sanitize(known.feed ? res.data && res.data.notices : stale && stale.notices),
|
||||
local: local,
|
||||
};
|
||||
const list = snapshot.notices;
|
||||
const cachedMe = this.meCache.getStale();
|
||||
const ask = () =>
|
||||
known.feed &&
|
||||
(list.some((notice) => notice.audience === "creators") || notices.hasShares(cachedMe))
|
||||
? L.resolveDefault(this.callHubMe(), null).then((reply) => {
|
||||
const me = reply && reply.result === 0 && reply.data ? reply.data : null;
|
||||
if (me) this.meCache.set(me);
|
||||
return me;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
return (supplied || ask()).then((me) => {
|
||||
this.noticesCache.set(snapshot);
|
||||
const active = notices.inbox(snapshot, me || cachedMe, null).map((item) => item.key);
|
||||
const state = this.inboxState();
|
||||
known.me = Boolean(me);
|
||||
this.setInboxState({
|
||||
read: notices.pruneKeys(state.read, active, known),
|
||||
done: notices.pruneKeys(state.done, active, known),
|
||||
});
|
||||
return snapshot;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
callHubGet(id) {
|
||||
if (!/^[A-Za-z0-9]+$/.test(String(id || ""))) {
|
||||
return Promise.resolve({ result: 1, error: HUB_NOT_FOUND });
|
||||
}
|
||||
return hubFetch("/api/v1/themes/aurora/configs/" + id);
|
||||
},
|
||||
|
||||
hubAssetUrl(path) {
|
||||
return typeof path === "string" && ASSET_PATH_RE.test(path)
|
||||
? HUB_BASE + path
|
||||
: "";
|
||||
},
|
||||
|
||||
callHubApply: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_apply",
|
||||
params: ["id"],
|
||||
}),
|
||||
|
||||
callGetHubStatus: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "get_hub_status",
|
||||
params: ["job_id"],
|
||||
}),
|
||||
|
||||
callHubRestore: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_restore_backup",
|
||||
}),
|
||||
|
||||
// 纯本地:回答「路由器现在穿的是哪一套、还是不是那一套、被盖掉的那套长什么
|
||||
// 样」。不走 hub,所以不进上面那套缓存 —— 它便宜到可以每次开页面都问一遍。
|
||||
callHubLocalState: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_local_state",
|
||||
}),
|
||||
|
||||
// 三段式发布的两端。中间那段(资产字节)由 publishCurrentConfig 用浏览器
|
||||
// 直接 PUT 到 hub —— 见下。target_id 为空串是新发布、非空是更新那一条,
|
||||
// 所以"更新分享"不需要自己的一套方法:它跟发布本来就是同一件事,只差目标。
|
||||
callHubShareBegin: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_share_begin",
|
||||
params: ["name", "description", "target_id"],
|
||||
}),
|
||||
|
||||
callHubShareCommit: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_share_commit",
|
||||
params: ["draft_id"],
|
||||
}),
|
||||
|
||||
// 发布一整套配置:路由器建草稿并拿票据 -> 浏览器把字节直接送到 hub ->
|
||||
// 路由器提交。返回的信封与其它 hub 调用一致({result:0,...})。
|
||||
publishCurrentConfig(options) {
|
||||
const opts = options || {};
|
||||
const report = (info) => {
|
||||
if (typeof opts.onProgress === "function") opts.onProgress(info);
|
||||
};
|
||||
|
||||
report({ phase: "begin" });
|
||||
return L.resolveDefault(
|
||||
this.callHubShareBegin(opts.name, opts.description || "", opts.targetId || ""),
|
||||
null,
|
||||
).then((begun) => {
|
||||
if (!begun || begun.result !== 0) {
|
||||
return { result: 1, error: (begun && begun.error) || "hub_unreachable" };
|
||||
}
|
||||
// 内容与已有的一模一样:hub 直接给了那条的 id,一个字节都不必传。
|
||||
if (begun.duplicate) return { result: 0, id: begun.id, duplicate: true };
|
||||
|
||||
const entries = begun.assets || [];
|
||||
let index = 0;
|
||||
|
||||
// 顺序上传,不并发:一台路由器的上行本来就窄,几条流互相抢只会让每一条
|
||||
// 都变慢,而进度条上也读不出"到底传到哪了"。
|
||||
const next = () => {
|
||||
if (index >= entries.length) {
|
||||
report({ phase: "commit" });
|
||||
// assets 是这一趟真正传上去的份数。调用方据此决定要不要提审核 ——
|
||||
// 它已经在手里,不必为一句话再往 hub 跑一趟。
|
||||
return L.resolveDefault(this.callHubShareCommit(begun.draft_id), null).then(
|
||||
(done) =>
|
||||
done && done.result === 0
|
||||
? { result: 0, id: done.id, assets: entries.length }
|
||||
: { result: 1, error: (done && done.error) || "hub_unreachable" },
|
||||
);
|
||||
}
|
||||
|
||||
const entry = entries[index];
|
||||
const position = { kind: entry.kind, index: index + 1, count: entries.length };
|
||||
index += 1;
|
||||
|
||||
// 字节从路由器自己的 web 服务器上读,同源,不需要任何凭证。
|
||||
return fetch(entry.src)
|
||||
.then((res) => (res.ok ? res.blob() : null))
|
||||
.catch(() => null)
|
||||
.then((blob) => {
|
||||
if (!blob) return { result: 1, error: "asset_unreadable" };
|
||||
report({ phase: "upload", loaded: 0, total: blob.size, ...position });
|
||||
return putAssetBytes(HUB_BASE + entry.url, entry.ticket, blob, (loaded, total) =>
|
||||
report({ phase: "upload", loaded, total, ...position }),
|
||||
).then((ok) => (ok ? next() : { result: 1, error: "asset_upload_failed" }));
|
||||
});
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
},
|
||||
|
||||
callHubSetNickname: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_set_nickname",
|
||||
params: ["nickname"],
|
||||
}),
|
||||
|
||||
callGetInitData: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "get_init_data",
|
||||
}),
|
||||
|
||||
callAddFeed: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "add_feed",
|
||||
}),
|
||||
|
||||
callHubMe: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_me",
|
||||
}),
|
||||
|
||||
// Both sit behind the write ACL: the key is the creator account's password,
|
||||
// so reading it out is as much a privileged act as replacing it.
|
||||
callHubExportKey: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_export_key",
|
||||
}),
|
||||
|
||||
callHubImportKey: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_import_key",
|
||||
params: ["key"],
|
||||
}),
|
||||
|
||||
callHubDelete: rpc.declare({
|
||||
object: "luci.aurora",
|
||||
method: "hub_delete",
|
||||
params: ["id"],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
|
||||
// 两条标记注释之间是纯函数,hub 管理台的预览由同步脚本逐字取用这一段:
|
||||
// 里面不得引用 LuCI、window 或本文件的其它部分。
|
||||
// <markdown-core>
|
||||
const MD_MAX_DEPTH = 2;
|
||||
const MD_HEADING_OFFSET = 3;
|
||||
const MD_FENCE = "```";
|
||||
const MD_HEADING_RE = /^(#{1,3}) +(.*)$/;
|
||||
const MD_RULE_RE = /^-{3,} *$/;
|
||||
const MD_QUOTE_RE = /^> ?/;
|
||||
const MD_BULLET_RE = /^[-*] +/;
|
||||
const MD_NUMBER_RE = /^[0-9]{1,9}[.] +/;
|
||||
const MD_EXTERNAL_RE = /^https:/;
|
||||
|
||||
function mdClean(src) {
|
||||
const text = typeof src === "string" ? src : "";
|
||||
let out = "";
|
||||
let from = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if ((code < 32 && code !== 9 && code !== 10) || code === 127) {
|
||||
out += text.slice(from, i);
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
return out + text.slice(from);
|
||||
}
|
||||
|
||||
// 每个分隔符只向右找一次「最近的闭合」;找不到就记下来,同一种分隔符不再找。
|
||||
// 方括号的扫描遇到下一个 "[" 就停。两条合起来保证整体是线性的。
|
||||
function mdInline(text, depth) {
|
||||
const tokens = [];
|
||||
const dead = {};
|
||||
let plain = "";
|
||||
let i = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (plain) tokens.push({ type: "text", text: plain });
|
||||
plain = "";
|
||||
};
|
||||
const closer = (mark, from) => {
|
||||
if (dead[mark]) return -1;
|
||||
const at = text.indexOf(mark, from);
|
||||
if (at === -1) dead[mark] = true;
|
||||
return at;
|
||||
};
|
||||
const nested = (inner) =>
|
||||
depth < MD_MAX_DEPTH ? mdInline(inner, depth + 1) : [{ type: "text", text: inner }];
|
||||
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
|
||||
if (ch === "`") {
|
||||
const end = closer("`", i + 1);
|
||||
if (end > i + 1) {
|
||||
flush();
|
||||
tokens.push({ type: "code", text: text.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
} else if (ch === "*") {
|
||||
const mark = text[i + 1] === "*" ? "**" : "*";
|
||||
const start = i + mark.length;
|
||||
if (start < text.length && text[start] !== " ") {
|
||||
const end = closer(mark, start);
|
||||
if (end > start && text[end - 1] !== " ") {
|
||||
flush();
|
||||
tokens.push({
|
||||
type: mark === "**" ? "strong" : "em",
|
||||
children: nested(text.slice(start, end)),
|
||||
});
|
||||
i = end + mark.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (mark === "**") {
|
||||
plain += mark;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
} else if (ch === "[") {
|
||||
let j = i + 1;
|
||||
while (j < text.length && text[j] !== "[" && text[j] !== "]") j += 1;
|
||||
if (j > i + 1 && text[j] === "]" && text[j + 1] === "(") {
|
||||
let k = j + 2;
|
||||
while (k < text.length && text[k] !== ")" && text[k] !== "(" && text[k] !== " ") k += 1;
|
||||
if (k > j + 2 && text[k] === ")") {
|
||||
flush();
|
||||
tokens.push({ type: "link", text: text.slice(i + 1, j), url: text.slice(j + 2, k) });
|
||||
i = k + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plain += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
flush();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function mdBlocks(src) {
|
||||
const lines = mdClean(src).split("\n");
|
||||
const blocks = [];
|
||||
let open = null;
|
||||
|
||||
const close = () => {
|
||||
open = null;
|
||||
};
|
||||
const extend = (type, text) => {
|
||||
if (open && open.type === type) open.lines.push(text);
|
||||
else {
|
||||
open = { type: type, lines: [text] };
|
||||
blocks.push(open);
|
||||
}
|
||||
};
|
||||
const item = (type, text) => {
|
||||
if (open && open.type === type) open.items.push(text);
|
||||
else {
|
||||
open = { type: type, items: [text] };
|
||||
blocks.push(open);
|
||||
}
|
||||
};
|
||||
|
||||
for (let n = 0; n < lines.length; n += 1) {
|
||||
const line = lines[n];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith(MD_FENCE)) {
|
||||
const code = [];
|
||||
n += 1;
|
||||
while (n < lines.length && !lines[n].trim().startsWith(MD_FENCE)) {
|
||||
code.push(lines[n]);
|
||||
n += 1;
|
||||
}
|
||||
close();
|
||||
blocks.push({ type: "code", text: code.join("\n") });
|
||||
continue;
|
||||
}
|
||||
if (trimmed === "") {
|
||||
close();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = MD_HEADING_RE.exec(trimmed);
|
||||
if (heading) {
|
||||
close();
|
||||
blocks.push({ type: "heading", level: heading[1].length, text: heading[2] });
|
||||
} else if (MD_RULE_RE.test(trimmed)) {
|
||||
close();
|
||||
blocks.push({ type: "rule" });
|
||||
} else if (MD_QUOTE_RE.test(trimmed)) extend("quote", trimmed.replace(MD_QUOTE_RE, ""));
|
||||
else if (MD_BULLET_RE.test(trimmed)) item("bullets", trimmed.replace(MD_BULLET_RE, ""));
|
||||
else if (MD_NUMBER_RE.test(trimmed)) item("numbers", trimmed.replace(MD_NUMBER_RE, ""));
|
||||
else if (open && open.items) open.items[open.items.length - 1] += " " + trimmed;
|
||||
else extend("paragraph", trimmed);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function mdPlain(tokens) {
|
||||
return tokens
|
||||
.map((token) => (token.children ? mdPlain(token.children) : token.text))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderMarkdown(src, doc, safeUrl) {
|
||||
const root = doc.createDocumentFragment();
|
||||
|
||||
const inline = (parent, tokens) => {
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "text") parent.appendChild(doc.createTextNode(token.text));
|
||||
else if (token.type === "code") {
|
||||
const code = doc.createElement("code");
|
||||
code.appendChild(doc.createTextNode(token.text));
|
||||
parent.appendChild(code);
|
||||
} else if (token.type === "link") {
|
||||
const href = typeof safeUrl === "function" ? safeUrl(token.url) : "";
|
||||
if (typeof href !== "string" || href === "") {
|
||||
parent.appendChild(doc.createTextNode(token.text));
|
||||
return;
|
||||
}
|
||||
const link = doc.createElement("a");
|
||||
link.setAttribute("href", href);
|
||||
if (MD_EXTERNAL_RE.test(href)) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noreferrer");
|
||||
}
|
||||
link.appendChild(doc.createTextNode(token.text));
|
||||
parent.appendChild(link);
|
||||
} else {
|
||||
const node = doc.createElement(token.type === "strong" ? "strong" : "em");
|
||||
inline(node, token.children);
|
||||
parent.appendChild(node);
|
||||
}
|
||||
});
|
||||
return parent;
|
||||
};
|
||||
|
||||
const element = (tag, text) => inline(doc.createElement(tag), mdInline(text, 0));
|
||||
|
||||
mdBlocks(src).forEach((block) => {
|
||||
if (block.type === "code") {
|
||||
const pre = doc.createElement("pre");
|
||||
const code = doc.createElement("code");
|
||||
code.appendChild(doc.createTextNode(block.text));
|
||||
pre.appendChild(code);
|
||||
root.appendChild(pre);
|
||||
} else if (block.type === "rule") root.appendChild(doc.createElement("hr"));
|
||||
else if (block.type === "heading")
|
||||
root.appendChild(element("h" + (block.level + MD_HEADING_OFFSET), block.text));
|
||||
else if (block.items) {
|
||||
const list = doc.createElement(block.type === "numbers" ? "ol" : "ul");
|
||||
block.items.forEach((text) => list.appendChild(element("li", text)));
|
||||
root.appendChild(list);
|
||||
} else
|
||||
root.appendChild(
|
||||
element(block.type === "quote" ? "blockquote" : "p", block.lines.join(" ")),
|
||||
);
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function markdownToText(src) {
|
||||
const blocks = mdBlocks(src);
|
||||
for (let n = 0; n < blocks.length; n += 1) {
|
||||
const block = blocks[n];
|
||||
let text = "";
|
||||
if (block.type === "code")
|
||||
text = block.text.split("\n").filter((line) => line.trim() !== "")[0] || "";
|
||||
else if (block.type === "heading") text = mdPlain(mdInline(block.text, 0));
|
||||
else if (block.items) text = mdPlain(mdInline(block.items[0], 0));
|
||||
else if (block.lines) text = mdPlain(mdInline(block.lines[0], 0));
|
||||
if (text.trim() !== "") return text.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
// </markdown-core>
|
||||
|
||||
return baseclass.extend({
|
||||
render: renderMarkdown,
|
||||
toText: markdownToText,
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
|
||||
const POLL_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
||||
const MAX_NOTICES = 20;
|
||||
const MAX_TITLE_CHARS = 120;
|
||||
const MAX_BODY_CHARS = 4000;
|
||||
const MAX_REASON_CHARS = 1000;
|
||||
const MAX_VERSION_CHARS = 64;
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9]{1,32}$/;
|
||||
const LANG_RE = /^[a-z]{2}(-[a-z]{2,4})?$/;
|
||||
const LEVELS = ["info", "warning", "critical"];
|
||||
const BADGE_LEVELS = ["warning", "critical"];
|
||||
const AUDIENCES = ["all", "creators"];
|
||||
const COMPAT_STATES = ["unsupported", "deprecated"];
|
||||
|
||||
const HTTPS_PREFIX = "https://";
|
||||
const LUCI_PATH_RE = /^admin\/[A-Za-z0-9_\-./?=&%#]*$/;
|
||||
const URL_FORBIDDEN_RE = /[\u0000-\u0020\u007f\\]/;
|
||||
const STAMP_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/;
|
||||
const SUNSET_DATE_RE = /^\d{4}-\d{2}-\d{2}(?=$|[ T])/;
|
||||
|
||||
const GROUP_ACTION = "action";
|
||||
const GROUP_UPDATES = "updates";
|
||||
const SOURCE_ROUTER = "router";
|
||||
const SOURCE_SHARES = "shares";
|
||||
const SOURCE_STORE = "store";
|
||||
const FEED_KEY = "feed:missing";
|
||||
const LOCAL_KEY_PREFIX = "feed:";
|
||||
const DERIVED_KEY_MARK = ":";
|
||||
|
||||
const AGE_STEPS = [
|
||||
["week", 7 * 24 * 60 * 60 * 1000],
|
||||
["day", 24 * 60 * 60 * 1000],
|
||||
["hour", 60 * 60 * 1000],
|
||||
["minute", 60 * 1000],
|
||||
];
|
||||
|
||||
const isObject = (value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
// 按码点计数:hub 的上限数的是字符,而 .length 数的是 UTF-16 码元。
|
||||
const charCount = (value) => Array.from(value).length;
|
||||
|
||||
const isTitle = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.trim() !== "" &&
|
||||
charCount(value) <= MAX_TITLE_CHARS;
|
||||
|
||||
const isBody = (value) =>
|
||||
typeof value === "string" && charCount(value) <= MAX_BODY_CHARS;
|
||||
|
||||
const cleanI18n = (i18n) => {
|
||||
const out = {};
|
||||
if (!isObject(i18n)) return out;
|
||||
Object.keys(i18n).forEach((lang) => {
|
||||
const entry = i18n[lang];
|
||||
if (!LANG_RE.test(lang) || !isObject(entry)) return;
|
||||
const kept = {};
|
||||
if (isTitle(entry.title)) kept.title = entry.title;
|
||||
if (isBody(entry.body) && entry.body !== "") kept.body = entry.body;
|
||||
if (Object.keys(kept).length) out[lang] = kept;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const stampOf = (value) => {
|
||||
const match = typeof value === "string" ? STAMP_RE.exec(value) : null;
|
||||
return match ? match[0] : "";
|
||||
};
|
||||
|
||||
const cleanNotice = (raw) => {
|
||||
if (!isObject(raw)) return null;
|
||||
if (typeof raw.id !== "string" || !ID_RE.test(raw.id)) return null;
|
||||
if (!LEVELS.includes(raw.level) || !AUDIENCES.includes(raw.audience)) return null;
|
||||
if (!isTitle(raw.title) || !isBody(raw.body)) return null;
|
||||
return {
|
||||
id: raw.id,
|
||||
level: raw.level,
|
||||
audience: raw.audience,
|
||||
title: raw.title,
|
||||
body: raw.body,
|
||||
url: typeof raw.url === "string" ? raw.url : "",
|
||||
i18n: cleanI18n(raw.i18n),
|
||||
at: stampOf(raw.starts_at) || stampOf(raw.at) || stampOf(raw.created_at),
|
||||
};
|
||||
};
|
||||
|
||||
const stringList = (value) =>
|
||||
Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||
|
||||
const ownConfigs = (me) =>
|
||||
isObject(me) && Array.isArray(me.configs)
|
||||
? me.configs.filter(
|
||||
(config) => isObject(config) && typeof config.id === "string" && ID_RE.test(config.id),
|
||||
)
|
||||
: [];
|
||||
|
||||
const isReason = (value) =>
|
||||
typeof value === "string" && charCount(value) <= MAX_REASON_CHARS;
|
||||
|
||||
const versionOf = (pkg) => {
|
||||
const version = isObject(pkg) ? pkg.installed_version : null;
|
||||
return typeof version === "string" && charCount(version) <= MAX_VERSION_CHARS ? version : "";
|
||||
};
|
||||
|
||||
const nameOf = (config) => (typeof config.name === "string" ? config.name : "");
|
||||
|
||||
// FNV-1a,32 位。只为让"受影响的那一组 id"一变键就变,不承担任何安全含义。
|
||||
const shortHash = (text) => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
return baseclass.extend({
|
||||
POLL_INTERVAL_MS: POLL_INTERVAL_MS,
|
||||
MAX_NOTICES: MAX_NOTICES,
|
||||
GROUP_ACTION: GROUP_ACTION,
|
||||
GROUP_UPDATES: GROUP_UPDATES,
|
||||
SOURCE_ROUTER: SOURCE_ROUTER,
|
||||
SOURCE_SHARES: SOURCE_SHARES,
|
||||
SOURCE_STORE: SOURCE_STORE,
|
||||
FEED_KEY: FEED_KEY,
|
||||
|
||||
localize(notice, lang) {
|
||||
const source = isObject(notice) ? notice : {};
|
||||
const i18n = isObject(source.i18n) ? source.i18n : {};
|
||||
const code = String(lang == null ? "" : lang).toLowerCase().replace(/_/g, "-");
|
||||
const chain = [i18n[code], i18n[code.split("-")[0]], source];
|
||||
const pick = (field) => {
|
||||
const hit = chain.find(
|
||||
(entry) => isObject(entry) && typeof entry[field] === "string" && entry[field] !== "",
|
||||
);
|
||||
return hit ? hit[field] : "";
|
||||
};
|
||||
return { title: pick("title"), body: pick("body") };
|
||||
},
|
||||
|
||||
safeUrl(url) {
|
||||
if (typeof url !== "string" || URL_FORBIDDEN_RE.test(url)) return "";
|
||||
if (LUCI_PATH_RE.test(url))
|
||||
return url.includes("..") || url.includes("//") ? "" : url;
|
||||
if (!url.startsWith(HTTPS_PREFIX)) return "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" || !parsed.hostname) return "";
|
||||
if (parsed.username || parsed.password) return "";
|
||||
return url;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
isExternalUrl(url) {
|
||||
return typeof url === "string" && url.startsWith(HTTPS_PREFIX);
|
||||
},
|
||||
|
||||
present(notice, lang) {
|
||||
const text = this.localize(notice, lang);
|
||||
const url = this.safeUrl(notice?.url);
|
||||
return {
|
||||
title: text.title,
|
||||
body: text.body,
|
||||
url: url,
|
||||
external: this.isExternalUrl(url),
|
||||
};
|
||||
},
|
||||
|
||||
sanitize(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out = [];
|
||||
for (const raw of list) {
|
||||
if (out.length >= MAX_NOTICES) break;
|
||||
const notice = cleanNotice(raw);
|
||||
if (notice && !out.some((kept) => kept.id === notice.id)) out.push(notice);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
isDue(lastCheckedMs, nowMs) {
|
||||
if (!Number.isFinite(lastCheckedMs) || lastCheckedMs <= 0) return true;
|
||||
if (!Number.isFinite(nowMs) || nowMs < lastCheckedMs) return true;
|
||||
return nowMs - lastCheckedMs >= POLL_INTERVAL_MS;
|
||||
},
|
||||
|
||||
creatorAffected(me) {
|
||||
if (!isObject(me)) return false;
|
||||
const summary = isObject(me.compat_summary) ? me.compat_summary : {};
|
||||
const count = (field) => (Number.isFinite(summary[field]) ? summary[field] : 0);
|
||||
if (count("deprecated") + count("unsupported") > 0) return true;
|
||||
return (Array.isArray(me.configs) ? me.configs : []).some((config) => {
|
||||
if (!isObject(config) || config.status === "removed") return false;
|
||||
const state = isObject(config.compat) ? config.compat.state : null;
|
||||
return typeof state === "string" && state !== "ok";
|
||||
});
|
||||
},
|
||||
|
||||
hasShares(me) {
|
||||
return ownConfigs(me).length > 0;
|
||||
},
|
||||
|
||||
compatBadge(config) {
|
||||
const state = config?.compat?.state;
|
||||
return COMPAT_STATES.includes(state) ? state : null;
|
||||
},
|
||||
|
||||
sunsetDate(config) {
|
||||
const stamp = config?.compat?.sunset_at;
|
||||
const match = typeof stamp === "string" ? SUNSET_DATE_RE.exec(stamp) : null;
|
||||
return match ? match[0] : "";
|
||||
},
|
||||
|
||||
compatCounts(me) {
|
||||
const counts = { deprecated: 0, unsupported: 0, sunset: "" };
|
||||
ownConfigs(me)
|
||||
.filter((config) => config.status !== "removed")
|
||||
.forEach((config) => {
|
||||
const state = this.compatBadge(config);
|
||||
if (state) counts[state] += 1;
|
||||
const sunset = state === "deprecated" ? this.sunsetDate(config) : "";
|
||||
if (sunset && (!counts.sunset || sunset < counts.sunset)) counts.sunset = sunset;
|
||||
});
|
||||
if (isObject(me) && isObject(me.compat_summary))
|
||||
["deprecated", "unsupported"].forEach((state) => {
|
||||
const n = me.compat_summary[state];
|
||||
counts[state] = Number.isInteger(n) && n > 0 ? n : 0;
|
||||
});
|
||||
return counts;
|
||||
},
|
||||
|
||||
shortHash: shortHash,
|
||||
|
||||
derive(me) {
|
||||
const configs = ownConfigs(me);
|
||||
const live = configs.filter((config) => config.status !== "removed");
|
||||
const items = [];
|
||||
|
||||
COMPAT_STATES.forEach((state) => {
|
||||
const hit = live.filter((config) => this.compatBadge(config) === state);
|
||||
if (!hit.length) return;
|
||||
const sunsets = hit
|
||||
.map((config) => this.sunsetDate(config))
|
||||
.filter((day) => day)
|
||||
.sort();
|
||||
items.push({
|
||||
key:
|
||||
"compat:" +
|
||||
state +
|
||||
":" +
|
||||
shortHash(
|
||||
hit
|
||||
.map((config) => config.id)
|
||||
.sort()
|
||||
.join(","),
|
||||
),
|
||||
group: GROUP_ACTION,
|
||||
source: SOURCE_SHARES,
|
||||
kind: "compat",
|
||||
state: state,
|
||||
level: "critical",
|
||||
names: hit.map(nameOf),
|
||||
sunset: state === "deprecated" && sunsets.length ? sunsets[0] : "",
|
||||
});
|
||||
});
|
||||
|
||||
live
|
||||
.filter((config) => config.assets_status === "rejected")
|
||||
.forEach((config) =>
|
||||
items.push({
|
||||
key: "rejected:" + config.id,
|
||||
group: GROUP_ACTION,
|
||||
source: SOURCE_SHARES,
|
||||
kind: "rejected",
|
||||
level: "warning",
|
||||
name: nameOf(config),
|
||||
reason: isReason(config.assets_reject_reason) ? config.assets_reject_reason : "",
|
||||
}),
|
||||
);
|
||||
|
||||
configs
|
||||
.filter((config) => config.status === "removed")
|
||||
.forEach((config) =>
|
||||
items.push({
|
||||
key: "removed:" + config.id,
|
||||
group: GROUP_ACTION,
|
||||
source: SOURCE_SHARES,
|
||||
kind: "removed",
|
||||
level: "warning",
|
||||
name: nameOf(config),
|
||||
}),
|
||||
);
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
// get_init_data 的应答 -> 随 feed 一起缓存的本机结论。判断不出来(没有 feed
|
||||
// 字段、包管理器未知)就是 null:不知道,不等于"缺"。
|
||||
localState(initData) {
|
||||
const feed = isObject(initData) && isObject(initData.feed) ? initData.feed : null;
|
||||
if (!feed || typeof feed.pm !== "string" || feed.pm === "" || feed.pm === "unknown")
|
||||
return null;
|
||||
const versions = isObject(initData.versions) ? initData.versions : {};
|
||||
return {
|
||||
feedMissing: feed.configured !== true,
|
||||
theme: versionOf(versions.theme),
|
||||
app: versionOf(versions.config),
|
||||
};
|
||||
},
|
||||
|
||||
deriveLocal(local) {
|
||||
if (!isObject(local) || local.feedMissing !== true) return [];
|
||||
return [
|
||||
{
|
||||
key: FEED_KEY,
|
||||
group: GROUP_ACTION,
|
||||
source: SOURCE_ROUTER,
|
||||
kind: "feed",
|
||||
level: "warning",
|
||||
theme: typeof local.theme === "string" ? local.theme : "",
|
||||
app: typeof local.app === "string" ? local.app : "",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
inbox(snapshot, me, state) {
|
||||
const read = stringList(state && state.read);
|
||||
const done = stringList(state && state.done);
|
||||
const affected = this.creatorAffected(me);
|
||||
const updates = this.sanitize(isObject(snapshot) ? snapshot.notices : null)
|
||||
.filter((notice) => notice.audience !== "creators" || affected)
|
||||
.map((notice) => ({
|
||||
key: notice.id,
|
||||
group: GROUP_UPDATES,
|
||||
source: SOURCE_STORE,
|
||||
kind: "notice",
|
||||
level: notice.level,
|
||||
at: notice.at,
|
||||
notice: notice,
|
||||
}));
|
||||
return this.deriveLocal(isObject(snapshot) ? snapshot.local : null)
|
||||
.concat(this.derive(me), updates)
|
||||
.filter((item) => !done.includes(item.key))
|
||||
.map((item) => Object.assign(item, { unread: !read.includes(item.key) }));
|
||||
},
|
||||
|
||||
unreadCount(items) {
|
||||
return (Array.isArray(items) ? items : []).filter((item) => item && item.unread).length;
|
||||
},
|
||||
|
||||
badgeCount(snapshot, me, state) {
|
||||
if (!isObject(snapshot)) return 0;
|
||||
return this.inbox(snapshot, me, state).filter(
|
||||
(item) =>
|
||||
item.unread && (item.group === GROUP_ACTION || BADGE_LEVELS.includes(item.level)),
|
||||
).length;
|
||||
},
|
||||
|
||||
withKey(keys, key) {
|
||||
const list = stringList(keys);
|
||||
return typeof key !== "string" || list.includes(key) ? list : list.concat(key);
|
||||
},
|
||||
|
||||
withoutKey(keys, key) {
|
||||
return stringList(keys).filter((kept) => kept !== key);
|
||||
},
|
||||
|
||||
withKeys(keys, items) {
|
||||
return (Array.isArray(items) ? items : []).reduce(
|
||||
(list, item) => this.withKey(list, item && item.key),
|
||||
stringList(keys),
|
||||
);
|
||||
},
|
||||
|
||||
// 某一路来源这次没拿到(hub 不通、hub_me 失败)时,它名下的键原样保留:
|
||||
// 否则一次断网就把 Done 过的条目全放回来。
|
||||
pruneKeys(keys, activeKeys, known) {
|
||||
const active = stringList(activeKeys);
|
||||
const unknown = (key) => {
|
||||
if (key.startsWith(LOCAL_KEY_PREFIX)) return !(known && known.local);
|
||||
if (key.includes(DERIVED_KEY_MARK)) return !(known && known.me);
|
||||
return !(known && known.feed);
|
||||
};
|
||||
return stringList(keys).filter(
|
||||
(key, index, all) => all.indexOf(key) === index && (active.includes(key) || unknown(key)),
|
||||
);
|
||||
},
|
||||
|
||||
stampMs(stamp) {
|
||||
const match = typeof stamp === "string" ? STAMP_RE.exec(stamp) : null;
|
||||
if (!match) return null;
|
||||
const [year, month, day, hour, minute, second] = match
|
||||
.slice(1)
|
||||
.map((part) => Number(part || 0));
|
||||
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
},
|
||||
|
||||
ageOf(thenMs, nowMs) {
|
||||
if (!Number.isFinite(thenMs) || !Number.isFinite(nowMs)) return null;
|
||||
const elapsed = Math.max(0, nowMs - thenMs);
|
||||
const step = AGE_STEPS.find((candidate) => elapsed >= candidate[1]);
|
||||
return step
|
||||
? { value: -Math.floor(elapsed / step[1]), unit: step[0] }
|
||||
: { value: 0, unit: "minute" };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
|
||||
// Palette-painted mini previews for the Marketplace (marketplace.js is the only
|
||||
// caller: cards, detail drawer and share panel): a fake LuCI page drawn from
|
||||
// a 4-key palette ({bg, surface, text, brand}).
|
||||
// Geometry is static cssText; only sanitised
|
||||
// colors vary, so untrusted hub palettes can never inject styles.
|
||||
//
|
||||
// SECURITY: a config's logo is a user-uploaded SVG served by the hub. It may
|
||||
// only ever reach the page through <img src>, where SVG scripting does not
|
||||
// run. Nothing in this package may put hub SVG text into the DOM -- not
|
||||
// innerHTML, not DOMParser, not insertAdjacentHTML -- however thoroughly the
|
||||
// hub sanitises it on upload. Defence in depth: the sanitiser is not the
|
||||
// only thing standing between a shared config and script execution.
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
const safeHex = (value, fallback) =>
|
||||
HEX_RE.test(value || "") ? value : fallback;
|
||||
|
||||
const mixHex = (hex, pct) =>
|
||||
"color-mix(in srgb, " + hex + " " + pct + "%, transparent)";
|
||||
|
||||
// An <img> that silently degrades to `fallback` when the image cannot be
|
||||
// loaded, keeping the slot it occupies exactly the same size either way.
|
||||
// Callers pass the css of the box the fallback already fills, so has-logo,
|
||||
// no-logo and failed-load are three states with identical geometry.
|
||||
//
|
||||
// No spinner and no skeleton on purpose: the store paints from a stale
|
||||
// cached list whenever the hub is unreachable, and in that state every image
|
||||
// request fails. A flash followed by a jump is worse than a plain colour
|
||||
// block that was right from the first frame.
|
||||
const logoImage = (url, fallback, styleCss) => {
|
||||
if (!url) return fallback;
|
||||
const img = E("img", { src: url, alt: "", style: styleCss });
|
||||
img.addEventListener("error", () => {
|
||||
if (img.parentNode) img.parentNode.replaceChild(fallback, img);
|
||||
});
|
||||
return img;
|
||||
};
|
||||
|
||||
// opts.nav: "sidebar" draws a left rail; "mega-menu" draws the top bar plus
|
||||
// the expanded panel that sits under it; anything else (notably "dropdown")
|
||||
// draws the plain top bar. These are the three values validate_and_apply_hub_
|
||||
// payload accepts, so the preview covers the whole enum.
|
||||
//
|
||||
// opts.logo: an absolute url for the config's logo_svg. The brand square is
|
||||
// exactly where a logo shows up in the real chrome, so drawing it there is
|
||||
// the whole change -- one node swapped for another of the same size. That is
|
||||
// why this shape survives no-asset, has-asset and failed-load without moving
|
||||
// a pixel, which matters: most shared configs carry no assets at all.
|
||||
const buildMini = (pal, opts) => {
|
||||
const nav = (opts && opts.nav) || "top";
|
||||
const bg = safeHex(pal && pal.bg, "#f7fafc");
|
||||
const surface = safeHex(pal && pal.surface, "#ffffff");
|
||||
const text = safeHex(pal && pal.text, "#121a22");
|
||||
const brand = safeHex(pal && pal.brand, "#0086bf");
|
||||
|
||||
const bar = (width, pct, extra) => {
|
||||
const el = E("span", {
|
||||
style:
|
||||
"height:22%;border-radius:99px;width:" + width + ";" + (extra || ""),
|
||||
});
|
||||
el.style.background = mixHex(text, pct);
|
||||
return el;
|
||||
};
|
||||
|
||||
const row = (width) => {
|
||||
const el = E("div", {
|
||||
style:
|
||||
"height:5px;border-radius:99px;margin-bottom:4%;width:" + width + ";",
|
||||
});
|
||||
el.style.background = mixHex(text, 24);
|
||||
return el;
|
||||
};
|
||||
|
||||
const card = (children) => {
|
||||
const el = E(
|
||||
"div",
|
||||
{ style: "border-radius:8px;padding:4%;margin-bottom:5%;" },
|
||||
children,
|
||||
);
|
||||
el.style.background = surface;
|
||||
el.style.border = "1px solid " + mixHex(text, 10);
|
||||
return el;
|
||||
};
|
||||
|
||||
const DOT_CSS = "width:7%;aspect-ratio:1;border-radius:25%;flex:none;";
|
||||
const brandSquare = E("span", { style: DOT_CSS });
|
||||
brandSquare.style.background = brand;
|
||||
const dot = logoImage(
|
||||
(opts && opts.logo) || "",
|
||||
brandSquare,
|
||||
DOT_CSS + "object-fit:contain;",
|
||||
);
|
||||
|
||||
const title = E("div", {
|
||||
style:
|
||||
"height:8px;max-height:14%;border-radius:99px;width:45%;margin-bottom:6%;",
|
||||
});
|
||||
title.style.background = mixHex(text, 72);
|
||||
|
||||
const btn = E("span", {
|
||||
style: "display:inline-block;width:26%;height:12px;border-radius:99px;",
|
||||
});
|
||||
btn.style.background = brand;
|
||||
|
||||
const content = [card([title, row("90%"), row("70%"), btn]), card([row("85%"), row("60%")])];
|
||||
|
||||
let chromeNodes;
|
||||
let main;
|
||||
if (nav === "sidebar") {
|
||||
const rail = E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"position:absolute;left:0;top:0;bottom:0;width:22%;display:flex;" +
|
||||
"flex-direction:column;gap:8%;padding:6% 4%;box-sizing:border-box;",
|
||||
},
|
||||
[dot, bar("80%", 28, "height:7%;"), bar("90%", 28, "height:7%;"), bar("70%", 28, "height:7%;")],
|
||||
);
|
||||
rail.children[2].style.background = brand;
|
||||
rail.style.background = surface;
|
||||
rail.style.borderRight = "1px solid " + mixHex(text, 12);
|
||||
chromeNodes = [rail];
|
||||
main = E(
|
||||
"div",
|
||||
{ style: "position:absolute;left:27%;top:7%;right:5%;bottom:0;" },
|
||||
content,
|
||||
);
|
||||
} else {
|
||||
const top = E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"position:absolute;left:0;right:0;top:0;height:16%;display:flex;" +
|
||||
"align-items:center;gap:4%;padding:0 4%;box-sizing:border-box;",
|
||||
},
|
||||
[dot, bar("18%", 55), bar("12%", 34), bar("12%", 34)],
|
||||
);
|
||||
top.style.background = surface;
|
||||
top.style.borderBottom = "1px solid " + mixHex(text, 12);
|
||||
chromeNodes = [top];
|
||||
|
||||
// A mega menu is only distinguishable from a dropdown once its panel is
|
||||
// open, so the thumbnail draws it open.
|
||||
if (nav === "mega-menu") {
|
||||
const panel = E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"position:absolute;left:0;right:0;top:16%;height:13%;display:flex;" +
|
||||
"align-items:center;gap:3%;padding:0 4%;box-sizing:border-box;",
|
||||
},
|
||||
[
|
||||
bar("10%", 30, "height:26%;"),
|
||||
bar("14%", 30, "height:26%;"),
|
||||
bar("9%", 30, "height:26%;"),
|
||||
bar("12%", 30, "height:26%;"),
|
||||
],
|
||||
);
|
||||
panel.style.background = surface;
|
||||
panel.style.borderBottom = "1px solid " + mixHex(text, 12);
|
||||
chromeNodes.push(panel);
|
||||
}
|
||||
|
||||
main = E(
|
||||
"div",
|
||||
{
|
||||
style:
|
||||
"position:absolute;left:5%;top:" +
|
||||
(nav === "mega-menu" ? "37%" : "24%") +
|
||||
";right:5%;bottom:0;",
|
||||
},
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
const root = E(
|
||||
"div",
|
||||
{ style: "position:absolute;inset:0;overflow:hidden;" },
|
||||
chromeNodes.concat([main]),
|
||||
);
|
||||
root.style.background = bg;
|
||||
return root;
|
||||
};
|
||||
|
||||
// Light/dark diagonal split: two minis, the dark one clipped to the right.
|
||||
const buildDuo = (palette, opts) => {
|
||||
const dark = buildMini((palette && palette.dark) || {}, opts);
|
||||
dark.style.clipPath = "polygon(58% 0, 100% 0, 100% 100%, 42% 100%)";
|
||||
return E("div", { style: "position:relative;width:100%;height:100%;" }, [
|
||||
buildMini((palette && palette.light) || {}, opts),
|
||||
dark,
|
||||
]);
|
||||
};
|
||||
|
||||
return baseclass.extend({
|
||||
safeHex,
|
||||
mixHex,
|
||||
logoImage,
|
||||
buildMini,
|
||||
buildDuo,
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @eamonxg/luci-theme-tokens v2.0.0 -- GENERATED, DO NOT EDIT.
|
||||
* Aurora design-token engine (browser global). Built from aurora/spec.js +
|
||||
* aurora/defaults.js + operators.js by build.mjs. Depends on the global
|
||||
* `Color` (utils/color.global.js in luci-app-aurora-config); load that first.
|
||||
*/
|
||||
var AuroraTokens = (() => {
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// global.js
|
||||
var global_exports = {};
|
||||
__export(global_exports, {
|
||||
DEFAULTS: () => DEFAULTS,
|
||||
DERIVATIONS: () => DERIVATIONS,
|
||||
DERIVED_KEYS: () => DERIVED_KEYS,
|
||||
INPUTS: () => INPUTS,
|
||||
resolve: () => resolve
|
||||
});
|
||||
|
||||
// aurora/spec.js
|
||||
var DERIVATIONS = {
|
||||
light: {
|
||||
text_muted: ["mix", "text", "bg", 0.62],
|
||||
text_subtle: ["mix", "text", "bg", 0.48],
|
||||
// Recessed fills need chroma ABOVE bg's (set, not shade): at these sizes a
|
||||
// bg-inherited tint reads as dirty grey against the pure-white cards.
|
||||
surface_sunken: ["set", "bg", 0.975, 5e-3],
|
||||
surface_overlay: ["shade", "bg", 0.016],
|
||||
hairline: ["alpha", "text", 0.13],
|
||||
hover_faint: ["set", "bg", 0.95, 6e-3],
|
||||
brand_hover: ["shade", "brand", -0.06],
|
||||
brand_subtle: ["mix", "brand", "bg", 0.12],
|
||||
brand_subtle_hover: ["shade", "brand_subtle", -0.04],
|
||||
focus_ring: ["alpha", "brand", 0.6],
|
||||
progress_start: ["mix", "brand", "surface_sunken", 0.65],
|
||||
progress_end: ["const", "var:brand"],
|
||||
info_surface: ["set", "info", 0.945, 0.042],
|
||||
warning_surface: ["set", "warning", 0.955, 0.042],
|
||||
success_surface: ["set", "success", 0.95, 0.042],
|
||||
danger_surface: ["set", "danger", 0.95, 0.036],
|
||||
danger_surface_hover: ["shade", "danger_surface", -0.04],
|
||||
// Form-control fill (inputs/selects/textareas/checkboxes, login field).
|
||||
// One semantic token so components never fork per mode: light rests a
|
||||
// near-white overlay on the page; dark recesses below the card.
|
||||
control_bg: ["const", "var:surface_overlay"],
|
||||
scrim: ["const", "oklch(0 0 0 / 0.6)"],
|
||||
// Fully opaque: a clean solid panel (Apple's #fafafc). Any translucency let
|
||||
// the dimmed curtain bleed through, greying the panel off the header tone
|
||||
// and leaking faint blurred page content into the empty columns. The header
|
||||
// lifts to this same colour when the menu opens (see _layout.css) so bar and
|
||||
// panel read as one continuous surface.
|
||||
// Relight 2026-07: a cool snow step BELOW bg — anything brighter read as
|
||||
// paper-white against the dimmed page; extra chroma keeps it icy.
|
||||
mega_menu_bg: ["set", "bg", 0.972, 8e-3],
|
||||
// Mega-menu curtain: a real dimming layer. A near-page-light grey (the
|
||||
// earlier #e8e8ed attempt) only blurred without darkening, so the mask read
|
||||
// as absent. Black at a modest alpha actually dims the page; the now-opaque
|
||||
// panel + its shadow give a clean edge, so this no longer bands the way the
|
||||
// old translucent panel over a heavy scrim did. Lighter than the 0.6 modal
|
||||
// scrim — it's a menu backdrop, not a dialog.
|
||||
// Relight 2026-07: pure black over the snow-white page greyed it dirty;
|
||||
// a cool blue-black dim is the "blue hour" of the light theme.
|
||||
mega_menu_scrim: ["const", "oklch(0.22 0.035 250 / 0.38)"]
|
||||
},
|
||||
dark: {
|
||||
text_muted: ["mix", "text", "bg", 0.62],
|
||||
text_subtle: ["mix", "text", "bg", 0.42],
|
||||
surface_sunken: ["shade", "surface", -0.045],
|
||||
surface_overlay: ["shade", "surface", 0.02],
|
||||
hairline: ["alpha", "text", 0.1],
|
||||
hover_faint: ["alpha", "text", 0.05],
|
||||
brand_hover: ["shade", "brand", -0.05],
|
||||
brand_subtle: ["mix", "brand", "bg", 0.16],
|
||||
brand_subtle_hover: ["shade", "brand_subtle", 0.04],
|
||||
focus_ring: ["alpha", "brand", 0.6],
|
||||
// Derived from brand like light — not hardcoded — so the bar tracks the
|
||||
// active brand colour instead of a frozen teal.
|
||||
progress_start: ["mix", "brand", "surface_sunken", 0.65],
|
||||
progress_end: ["const", "var:brand"],
|
||||
info_surface: ["set", "info", 0.32, 0.05],
|
||||
warning_surface: ["set", "warning", 0.33, 0.06],
|
||||
success_surface: ["set", "success", 0.3, 0.05],
|
||||
danger_surface: ["set", "danger", 0.32, 0.08],
|
||||
danger_surface_hover: ["shade", "danger_surface", 0.04],
|
||||
// See light: same semantic token, recessed below the card in dark.
|
||||
control_bg: ["const", "var:surface_sunken"],
|
||||
scrim: ["const", "oklch(0 0 0 / 0.6)"],
|
||||
// Deeper than surface_overlay (23%): surface_sunken (16.5%) tracks Apple's
|
||||
// opened-panel #161617 (~18.5%). Fully opaque — a clean solid panel with no
|
||||
// curtain bleed; the header lifts to this colour on open (see _layout.css)
|
||||
// so bar and panel are one continuous surface.
|
||||
mega_menu_bg: ["alpha", "surface_sunken", 1],
|
||||
// Dark curtain stays a near-black dim (Apple's rgba(0,0,0,.4)); the dark
|
||||
// panel and dark page already share a tone, so this only needs to deepen.
|
||||
mega_menu_scrim: ["const", "oklch(0 0 0 / 0.5)"]
|
||||
}
|
||||
};
|
||||
|
||||
// aurora/defaults.js
|
||||
var DEFAULTS = {
|
||||
light: {
|
||||
bg: "oklch(0.984 0.004 235)",
|
||||
surface: "oklch(1 0 0)",
|
||||
text: "oklch(0.215 0.02 250)",
|
||||
brand: "oklch(0.58 0.14 233)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.58 0.14 233)",
|
||||
// intentionally equals brand
|
||||
info: "oklch(0.45 0.12 250)",
|
||||
warning: "oklch(0.4 0.09 72)",
|
||||
success: "oklch(0.38 0.1 172)",
|
||||
danger: "oklch(0.42 0.15 22)"
|
||||
},
|
||||
dark: {
|
||||
bg: "oklch(0.13 0.018 264)",
|
||||
surface: "oklch(0.21 0.02 264)",
|
||||
text: "oklch(0.985 0.002 264)",
|
||||
brand: "oklch(0.6 0.13 188.745)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.6 0.13 188.745)",
|
||||
// intentionally equals brand
|
||||
info: "oklch(0.8 0.11 255)",
|
||||
warning: "oklch(0.82 0.13 80)",
|
||||
success: "oklch(0.72 0.13 158)",
|
||||
danger: "oklch(0.7 0.16 22)"
|
||||
}
|
||||
};
|
||||
|
||||
// shim-color-global.js
|
||||
var shim_color_global_default = globalThis.Color;
|
||||
|
||||
// operators.js
|
||||
var C = (v) => v instanceof shim_color_global_default ? v : new shim_color_global_default(v);
|
||||
var mix = (a, b, p) => shim_color_global_default.mix(C(a), C(b), 1 - p, { space: "oklab", outputSpace: "oklch" });
|
||||
var shade = (a, dl) => {
|
||||
const c = C(a).to("oklch");
|
||||
c.coords[0] += dl;
|
||||
return c;
|
||||
};
|
||||
var set = (a, L, Ch) => {
|
||||
const c = C(a).to("oklch");
|
||||
c.coords[0] = L;
|
||||
const achromatic = c.coords[1] < 1e-3 || Number.isNaN(c.coords[2]);
|
||||
c.coords[1] = achromatic ? 0 : Ch;
|
||||
return c;
|
||||
};
|
||||
var alpha = (a, p) => {
|
||||
const c = C(a).to("oklch");
|
||||
c.alpha = p;
|
||||
return c;
|
||||
};
|
||||
var konst = (s) => C(s).to("oklch");
|
||||
var toOklch = (v) => C(v).to("oklch").toString({ precision: 4, format: "oklch" });
|
||||
|
||||
// resolve.js
|
||||
function createResolver(derivations) {
|
||||
return function resolveTokens2(mode, inputs) {
|
||||
const derivs = derivations[mode];
|
||||
if (!derivs) throw new Error(`unknown mode: ${mode}`);
|
||||
const resolved = { ...inputs };
|
||||
const ref = (name) => {
|
||||
if (resolved[name] === void 0) compute(name);
|
||||
return resolved[name];
|
||||
};
|
||||
function compute(name) {
|
||||
const rule = derivs[name];
|
||||
if (!rule) throw new Error(`unknown derived token: ${name}`);
|
||||
const [op, ...args] = rule;
|
||||
let color;
|
||||
switch (op) {
|
||||
case "mix":
|
||||
color = mix(ref(args[0]), ref(args[1]), args[2]);
|
||||
break;
|
||||
case "shade":
|
||||
color = shade(ref(args[0]), args[1]);
|
||||
break;
|
||||
case "set":
|
||||
color = set(ref(args[0]), args[1], args[2]);
|
||||
break;
|
||||
case "alpha":
|
||||
color = alpha(ref(args[0]), args[1]);
|
||||
break;
|
||||
case "const":
|
||||
if (args[0].startsWith("var:")) {
|
||||
resolved[name] = ref(args[0].slice(4));
|
||||
return;
|
||||
}
|
||||
color = konst(args[0]);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown op: ${op}`);
|
||||
}
|
||||
resolved[name] = toOklch(color);
|
||||
}
|
||||
for (const name of Object.keys(derivs)) compute(name);
|
||||
return resolved;
|
||||
};
|
||||
}
|
||||
|
||||
// aurora/presets.js
|
||||
var statusKeys = ["info", "warning", "success", "danger"];
|
||||
var STATUS = {
|
||||
light: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.light[k]])),
|
||||
dark: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.dark[k]]))
|
||||
};
|
||||
var PRESETS = {
|
||||
default: {
|
||||
light: { ...DEFAULTS.light },
|
||||
dark: { ...DEFAULTS.dark }
|
||||
},
|
||||
"sage-green": {
|
||||
light: {
|
||||
bg: "oklch(0.9761 0.0041 91.4461)",
|
||||
surface: "oklch(1 0 0)",
|
||||
text: "oklch(0.2417 0.0298 269.8827)",
|
||||
brand: "oklch(0.6333 0.0309 154.9039)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.6333 0.0309 154.9039)",
|
||||
...STATUS.light
|
||||
},
|
||||
dark: {
|
||||
bg: "oklch(0.1448 0 0)",
|
||||
surface: "oklch(0.1822 0 0)",
|
||||
text: "oklch(0.9702 0 0)",
|
||||
brand: "oklch(0.6333 0.0309 154.9039)",
|
||||
on_brand: "oklch(0 0 0)",
|
||||
link: "oklch(0.6333 0.0309 154.9039)",
|
||||
...STATUS.dark
|
||||
}
|
||||
},
|
||||
"amber-sand": {
|
||||
light: {
|
||||
bg: "oklch(0.9818 0.0054 95.0986)",
|
||||
surface: "oklch(0.9818 0.0054 95.0986)",
|
||||
text: "oklch(0.3438 0.0269 95.7226)",
|
||||
brand: "oklch(0.6171 0.1375 39.0427)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.6171 0.1375 39.0427)",
|
||||
...STATUS.light
|
||||
},
|
||||
dark: {
|
||||
bg: "oklch(0.2679 0.0036 106.6427)",
|
||||
surface: "oklch(0.3085 0.0035 106.6039)",
|
||||
text: "oklch(0.8074 0.0142 93.0137)",
|
||||
brand: "oklch(0.6724 0.1308 38.7559)",
|
||||
on_brand: "oklch(0 0 0)",
|
||||
link: "oklch(0.6724 0.1308 38.7559)",
|
||||
...STATUS.dark
|
||||
}
|
||||
},
|
||||
monochrome: {
|
||||
light: {
|
||||
bg: "oklch(0.9900 0 0)",
|
||||
surface: "oklch(1 0 0)",
|
||||
text: "oklch(0 0 0)",
|
||||
brand: "oklch(0 0 0)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0 0 0)",
|
||||
...STATUS.light
|
||||
},
|
||||
dark: {
|
||||
bg: "oklch(0 0 0)",
|
||||
surface: "oklch(0.1400 0 0)",
|
||||
text: "oklch(1 0 0)",
|
||||
brand: "oklch(1 0 0)",
|
||||
on_brand: "oklch(0 0 0)",
|
||||
link: "oklch(1 0 0)",
|
||||
...STATUS.dark
|
||||
}
|
||||
},
|
||||
"sky-blue": {
|
||||
light: {
|
||||
bg: "oklch(1 0 0)",
|
||||
surface: "oklch(0.9784 0.0011 197.1387)",
|
||||
text: "oklch(0.1884 0.0128 248.5103)",
|
||||
brand: "oklch(0.6723 0.1606 244.9955)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.6723 0.1606 244.9955)",
|
||||
...STATUS.light
|
||||
},
|
||||
dark: {
|
||||
bg: "oklch(0 0 0)",
|
||||
surface: "oklch(0.2097 0.0080 274.5332)",
|
||||
text: "oklch(0.9328 0.0025 228.7857)",
|
||||
brand: "oklch(0.6692 0.1607 245.0110)",
|
||||
on_brand: "oklch(1 0 0)",
|
||||
link: "oklch(0.6692 0.1607 245.0110)",
|
||||
...STATUS.dark
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// aurora/index.js
|
||||
var resolveTokens = createResolver(DERIVATIONS);
|
||||
|
||||
// global.js
|
||||
var INPUTS = Object.keys(DEFAULTS.light);
|
||||
var DERIVED_KEYS = Object.keys(DERIVATIONS.light);
|
||||
var resolve = resolveTokens;
|
||||
return __toCommonJS(global_exports);
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
// These assertions run against the bytes the router actually serves, not
|
||||
// against the source they were made from. That is the whole point: the source
|
||||
// is checked by every other test in here, and the interesting failures live in
|
||||
// the gap between the two.
|
||||
const OUT = repo("htdocs/luci-static/resources");
|
||||
|
||||
async function collect(dir, base = dir) {
|
||||
const found = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) found.push(...(await collect(full, base)));
|
||||
else if (entry.name.endsWith(".js")) found.push(full.slice(base.length + 1));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const shipped = await collect(OUT);
|
||||
|
||||
test("every shipped view is parseable JavaScript", async () => {
|
||||
assert.ok(shipped.length > 0, "found no artifacts -- run `pnpm build`");
|
||||
for (const rel of shipped) {
|
||||
const code = await readFile(join(OUT, rel), "utf8");
|
||||
// A LuCI view is evaluated as a function body, so a top-level return is
|
||||
// legal there and only there -- new Function reproduces that context.
|
||||
assert.doesNotThrow(() => new Function(code), `${rel} does not parse`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every shipped file keeps the require directives its source declared", async () => {
|
||||
for (const rel of shipped) {
|
||||
const source = await readFile(srcPath(rel), "utf8");
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
const declared = [...source.matchAll(/^"(require [^"]+)";$/gm)].map((m) => m[1]);
|
||||
for (const directive of declared)
|
||||
assert.ok(
|
||||
built.includes(`"${directive}"`),
|
||||
`${rel} lost its "${directive}" declaration -- LuCI will not load its dependency`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every shipped view keeps the top-level return LuCI reads it for", async () => {
|
||||
for (const rel of shipped) {
|
||||
const source = await readFile(srcPath(rel), "utf8");
|
||||
const match = /^return (view|baseclass|L\.Class)\.extend\(/m.exec(source);
|
||||
if (!match) continue;
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
assert.ok(
|
||||
built.includes(`return ${match[1]}.extend(`),
|
||||
`${rel} lost its module export -- LuCI would load an undefined view`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the *.global.js artifacts still publish their globals", async () => {
|
||||
// These two exist only to define a global for the page. mangle.toplevel or
|
||||
// compress.toplevel would delete the declaration and terser would still
|
||||
// report success -- the failure is a white page with nothing in the console.
|
||||
for (const [rel, name] of [
|
||||
["utils/color.global.js", "Color"],
|
||||
["utils/tokens.global.js", "AuroraTokens"],
|
||||
]) {
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
assert.match(
|
||||
built,
|
||||
new RegExp(`var ${name}\\s*=`),
|
||||
`${rel} no longer declares the global ${name} -- is toplevel mangling on?`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("vendored code ships the licence it is distributed under", async () => {
|
||||
// MIT requires the notice to travel with every copy, and comments:false
|
||||
// strips the plain // blocks the vendored file carries. Sidecars re-attach it.
|
||||
let checked = 0;
|
||||
for (const rel of shipped) {
|
||||
let sidecar = null;
|
||||
try {
|
||||
sidecar = await readFile(`${srcPath(rel)}.license`, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
assert.ok(built.startsWith("/*!"), `${rel} lost its licence banner`);
|
||||
for (const line of sidecar.trimEnd().split("\n"))
|
||||
assert.ok(built.includes(line), `${rel} banner is missing: ${line}`);
|
||||
}
|
||||
assert.ok(
|
||||
checked > 0,
|
||||
"no .license sidecar was found -- colorjs ships under MIT and must carry its notice",
|
||||
);
|
||||
});
|
||||
|
||||
test("no shipped file points at a sourcemap that is not installed", async () => {
|
||||
for (const rel of shipped) {
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
assert.ok(
|
||||
!built.includes("sourceMappingURL"),
|
||||
`${rel} would cost every visitor a 404 for a map the package does not ship`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("translatable strings survive minification byte-for-byte", async () => {
|
||||
// gen-pot reads the source; the runtime looks the string up in the artifact.
|
||||
// If the two ever disagree the translation silently falls back to English.
|
||||
const msgid = /\b_\(\s*"((?:[^"\\]|\\.)*)"/g;
|
||||
// Comment-only lines are dropped first: asset-upload.js documents its own
|
||||
// options with `// badgeHeader, // _("Slot") or _("Type")`, and a string the
|
||||
// compressor removes along with its comment was never looked up at runtime.
|
||||
// (gen-pot does record such lines as references -- harmless, since the msgid
|
||||
// is also used for real elsewhere -- but that is its business, not this
|
||||
// test's.)
|
||||
const code = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
||||
.join("\n");
|
||||
for (const rel of shipped) {
|
||||
const source = code(await readFile(srcPath(rel), "utf8"));
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
const fromSource = new Set([...source.matchAll(msgid)].map((m) => m[1]));
|
||||
const fromBuilt = new Set([...built.matchAll(msgid)].map((m) => m[1]));
|
||||
for (const id of fromSource)
|
||||
assert.ok(
|
||||
fromBuilt.has(id),
|
||||
`${rel}: msgid ${JSON.stringify(id)} was rewritten -- gen-pot reads the ` +
|
||||
`source, so the runtime would look up a key no .po file contains`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("no hidden file was shipped", async () => {
|
||||
// .DS_Store rode along the first time the build ran and would have been
|
||||
// installed onto every router that upgraded.
|
||||
const all = [];
|
||||
const walk = async (dir) => {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await walk(join(dir, entry.name));
|
||||
else all.push(entry.name);
|
||||
}
|
||||
};
|
||||
await walk(OUT);
|
||||
const hidden = all.filter((name) => name.startsWith("."));
|
||||
assert.deepEqual(hidden, [], `hidden files must not be installed: ${hidden}`);
|
||||
});
|
||||
|
||||
// Every resource this package fetches by a URL it builds itself must carry a
|
||||
// version, or browsers keep the copy they have: uhttpd dates our installed
|
||||
// files at the epoch and sends no Cache-Control, so the heuristic freshness a
|
||||
// browser computes runs to years and it never revalidates. This is how the
|
||||
// Marketplace spent months rendering built-in cards from a presets.json whose
|
||||
// shape had changed underneath it.
|
||||
//
|
||||
// Resources LuCI itself `require`s are out of scope -- LuCI appends its own
|
||||
// ?v=, taken from luci-base's version, which we cannot influence.
|
||||
test("self-fetched resources carry a cache-busting version", async () => {
|
||||
const sentinel = /__ASSET_HASH\(/;
|
||||
for (const rel of shipped) {
|
||||
const built = await readFile(join(OUT, rel), "utf8");
|
||||
assert.ok(
|
||||
!sentinel.test(built),
|
||||
`${rel} still contains an unresolved __ASSET_HASH sentinel`,
|
||||
);
|
||||
}
|
||||
|
||||
const theme = await readFile(join(OUT, "view/aurora/studio.js"), "utf8");
|
||||
// The version is the loader's second argument; the "?v=" concatenation lives
|
||||
// inside loadGlobalScript, so assert on both halves.
|
||||
assert.match(
|
||||
theme,
|
||||
/L\.resource\([^)]*\)\+\([^)]*\?"\?v="\+/,
|
||||
"loadGlobalScript no longer appends the version it is given",
|
||||
);
|
||||
for (const asset of ["color.global.js", "tokens.global.js"])
|
||||
assert.match(
|
||||
theme,
|
||||
new RegExp(`"utils/${asset.replace(/\./g, "\\.")}",\\s*"[^"]+"`),
|
||||
`theme.js loads ${asset} without a version argument -- a browser that ` +
|
||||
`already has it will never ask for it again`,
|
||||
);
|
||||
|
||||
const gallery = await readFile(join(OUT, "view/aurora/marketplace.js"), "utf8");
|
||||
assert.match(
|
||||
gallery,
|
||||
/aurora\/presets\.json"\)\s*\+\s*"\?v=[^"]+"/,
|
||||
"marketplace.js fetches presets.json with no ?v=",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("utils/asset-upload.js");
|
||||
|
||||
test("asset-upload module exposes the shared surface", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /^"require baseclass";/m);
|
||||
assert.match(src, /^"require rpc";/m);
|
||||
assert.match(src, /^"require ui";/m);
|
||||
assert.match(src, /return baseclass\.extend\(/);
|
||||
for (const name of [
|
||||
"MAX_UPLOAD",
|
||||
"formatSize",
|
||||
"extOf",
|
||||
"checkFile",
|
||||
"uploadToRouter",
|
||||
"createAssetManager",
|
||||
"confirmDelete",
|
||||
])
|
||||
assert.ok(src.includes(name), `missing export: ${name}`);
|
||||
|
||||
assert.ok(!src.includes("createDropzone"), "dead export must stay deleted");
|
||||
assert.ok(!src.includes("createProgressRow"), "dead export must stay deleted");
|
||||
});
|
||||
|
||||
test("module owns the only cgi-upload pipeline", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /\/cgi-bin\/cgi-upload/);
|
||||
assert.match(src, /filemode.*0600/);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const BUILD = fileURLToPath(new URL("../scripts/build-js.mjs", import.meta.url));
|
||||
|
||||
const VIEW = `"use strict";
|
||||
"require view";
|
||||
"require form";
|
||||
|
||||
const LABEL = _("A Translatable Label");
|
||||
|
||||
return view.extend({
|
||||
render() {
|
||||
const unusedButNamed = 1;
|
||||
return LABEL + unusedButNamed;
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
function scratch(files) {
|
||||
const root = mkdtempSync(join(tmpdir(), "aurora-build-"));
|
||||
const src = join(root, "src");
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(src, rel);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
}
|
||||
return { root, src, out: join(root, "out") };
|
||||
}
|
||||
|
||||
const run = (args) =>
|
||||
execFileSync(process.execPath, [BUILD, ...args], { encoding: "utf8" });
|
||||
|
||||
test("a LuCI view keeps its directive prologue, its top-level return and its _() calls", () => {
|
||||
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
|
||||
try {
|
||||
run(["--src", src, "--out", out]);
|
||||
const built = readFileSync(join(out, "view/aurora/studio.js"), "utf8");
|
||||
assert.ok(
|
||||
built.startsWith('"use strict";"require view";"require form";'),
|
||||
`directive prologue was dropped: ${built.slice(0, 80)}`,
|
||||
);
|
||||
assert.match(built, /return view\.extend/, "top-level return was dropped");
|
||||
assert.match(built, /_\("A Translatable Label"\)/, "_() msgid was rewritten");
|
||||
assert.ok(built.length < VIEW.length, "output is not smaller than the source");
|
||||
assert.ok(
|
||||
!built.includes("sourceMappingURL"),
|
||||
"a sourceMappingURL comment costs every visitor a 404",
|
||||
);
|
||||
assert.ok(!built.includes("unusedButNamed"), "locals were not mangled");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("json is compacted and stays parseable", () => {
|
||||
const pretty =
|
||||
JSON.stringify({ presets: { a: { light: { bg: "#fff" } } } }, null, 2) + "\n";
|
||||
const { root, src, out } = scratch({ "aurora/presets.json": pretty });
|
||||
try {
|
||||
run(["--src", src, "--out", out]);
|
||||
const built = readFileSync(join(out, "aurora/presets.json"), "utf8");
|
||||
assert.ok(built.length < pretty.length, "json was not compacted");
|
||||
assert.deepEqual(JSON.parse(built), JSON.parse(pretty), "json content changed");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("--check exits non-zero and names the stale file", () => {
|
||||
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
|
||||
try {
|
||||
run(["--src", src, "--out", out]);
|
||||
run(["--check", "--src", src, "--out", out]); // fresh: must not throw
|
||||
writeFileSync(join(out, "view/aurora/studio.js"), "stale");
|
||||
assert.throws(
|
||||
() => run(["--check", "--src", src, "--out", out]),
|
||||
(error) => {
|
||||
assert.equal(error.status, 1, "--check must exit 1 when stale");
|
||||
assert.match(
|
||||
String(error.stdout) + String(error.stderr),
|
||||
/view\/aurora\/studio\.js/,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("build removes artifacts whose source is gone", () => {
|
||||
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
|
||||
try {
|
||||
run(["--src", src, "--out", out]);
|
||||
mkdirSync(join(out, "utils"), { recursive: true });
|
||||
writeFileSync(join(out, "utils/orphan.js"), "// no source produces this");
|
||||
run(["--src", src, "--out", out]);
|
||||
assert.throws(
|
||||
() => readFileSync(join(out, "utils/orphan.js")),
|
||||
"a stale artifact would keep shipping after its source was deleted",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a top-level global survives, and its .license sidecar rides along", () => {
|
||||
const lib = `var Color = (function () {
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2024 Somebody
|
||||
var helperName = 1;
|
||||
return helperName;
|
||||
})();
|
||||
`;
|
||||
const { root, src, out } = scratch({
|
||||
"utils/color.global.js": lib,
|
||||
"utils/color.global.js.license":
|
||||
"colorjs.io -- MIT\nCopyright (c) 2024 Somebody",
|
||||
});
|
||||
try {
|
||||
run(["--src", src, "--out", out]);
|
||||
const built = readFileSync(join(out, "utils/color.global.js"), "utf8");
|
||||
// The whole point of this file is the global it publishes. mangle.toplevel
|
||||
// or compress.toplevel would delete the declaration outright, and terser
|
||||
// would report success.
|
||||
assert.match(built, /var Color=/, "the top-level global was mangled or dropped");
|
||||
assert.ok(!built.includes("helperName"), "inner locals should still mangle");
|
||||
assert.ok(
|
||||
built.startsWith("/*!"),
|
||||
"a vendored file must ship its licence: MIT requires the notice in every copy",
|
||||
);
|
||||
assert.match(built, /Copyright \(c\) 2024 Somebody/);
|
||||
assert.throws(
|
||||
() => readFileSync(join(out, "utils/color.global.js.license")),
|
||||
"the sidecar is a source, not an artifact",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a syntax error in a source file fails the build loudly", () => {
|
||||
const { root, src, out } = scratch({ "utils/broken.js": "const = ;" });
|
||||
try {
|
||||
assert.throws(
|
||||
() => run(["--src", src, "--out", out]),
|
||||
(error) => {
|
||||
assert.notEqual(error.status, 0, "a broken source must not build");
|
||||
assert.match(String(error.stderr), /broken\.js/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
// A built-in preset is a whole look, not a palette. Before this, all five
|
||||
// templates carried nothing but 62 colour options, so "Sage Green" and
|
||||
// "Monochrome" differed by hue alone while the theme's navigation shape,
|
||||
// corner radius, spacing, content width and typography -- everything the
|
||||
// settings page can actually configure -- stayed wherever the user had left
|
||||
// them. These tests hold the three halves of that fix together: the templates
|
||||
// carry the structure, rpcd applies it, and the Marketplace's offline copy
|
||||
// says the same thing the templates do.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
|
||||
const read = (rel) => readFileSync(path.join(repoRoot, rel), "utf8");
|
||||
|
||||
const PRESET_IDS = ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"];
|
||||
|
||||
// Duplicated from PRESET_STRUCT_KEYS in root/usr/libexec/rpcd/luci.aurora and
|
||||
// from LAYOUT_KEYS/TYPOGRAPHY_KEYS in scripts/gen-presets.mjs. A test below
|
||||
// asserts the shell list matches this one.
|
||||
const LAYOUT_KEYS = [
|
||||
"nav_type",
|
||||
"struct_spacing",
|
||||
"struct_radius_base",
|
||||
"struct_content_width_centered",
|
||||
"toolbar_enabled",
|
||||
];
|
||||
const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"];
|
||||
const STRUCT_KEYS = LAYOUT_KEYS.concat(TYPOGRAPHY_KEYS);
|
||||
|
||||
const templateOf = (id) => read(`root/usr/share/aurora/${id}.template`);
|
||||
|
||||
const readOption = (source, key) => {
|
||||
const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const structOf = (id) => {
|
||||
const source = templateOf(id);
|
||||
return STRUCT_KEYS.reduce((acc, key) => {
|
||||
acc[key] = readOption(source, key);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const toolbarOf = (source) => {
|
||||
const items = [];
|
||||
let current = null;
|
||||
for (const raw of source.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (/^config\s+toolbar_item\s*$/.test(line)) {
|
||||
if (current) items.push(current);
|
||||
current = {};
|
||||
continue;
|
||||
}
|
||||
if (/^config\s/.test(line)) {
|
||||
if (current) items.push(current);
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const m = line.match(/^option\s+(title|url|icon|enabled)\s+'([^']*)'/);
|
||||
if (m && m[2] !== "") current[m[1]] = m[2];
|
||||
}
|
||||
if (current) items.push(current);
|
||||
return items.map((it) =>
|
||||
it.icon
|
||||
? { title: it.title, url: it.url, icon: it.icon, enabled: it.enabled }
|
||||
: { title: it.title, url: it.url, enabled: it.enabled },
|
||||
);
|
||||
};
|
||||
|
||||
const rpcd = read("root/usr/libexec/rpcd/luci.aurora");
|
||||
const gallery = read(".dev/src/resource/view/aurora/marketplace.js");
|
||||
const browserPresets = JSON.parse(
|
||||
read(".dev/src/resource/aurora/presets.json"),
|
||||
).presets;
|
||||
|
||||
test("every preset template carries the full structural block", () => {
|
||||
for (const id of PRESET_IDS) {
|
||||
const struct = structOf(id);
|
||||
for (const key of STRUCT_KEYS)
|
||||
assert.ok(
|
||||
struct[key],
|
||||
`${id}.template is missing option ${key} -- load_preset_struct_snapshot ` +
|
||||
`rejects a partial preset, so this template would fail to apply at all`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The same bounds validate_and_apply_hub_payload enforces on an inbound hub
|
||||
// configuration. A preset that could write a value the hub rejects would mean
|
||||
// the package held two different definitions of a valid look.
|
||||
test("preset structure values stay inside the bounds the hub path enforces", () => {
|
||||
const rem = (value) => {
|
||||
assert.match(value, /^[0-9]+(\.[0-9]+)?rem$/);
|
||||
return parseFloat(value);
|
||||
};
|
||||
|
||||
for (const id of PRESET_IDS) {
|
||||
const s = structOf(id);
|
||||
assert.ok(
|
||||
["mega-menu", "dropdown", "sidebar"].includes(s.nav_type),
|
||||
`${id}: unknown nav_type ${s.nav_type}`,
|
||||
);
|
||||
assert.ok(["0", "1"].includes(s.toolbar_enabled), `${id}: bad toolbar_enabled`);
|
||||
|
||||
const spacing = rem(s.struct_spacing);
|
||||
assert.ok(spacing >= 0.05 && spacing <= 1, `${id}: spacing out of range`);
|
||||
|
||||
const radius = rem(s.struct_radius_base);
|
||||
assert.ok(radius >= 0 && radius <= 2, `${id}: radius out of range`);
|
||||
|
||||
const width = rem(s.struct_content_width_centered);
|
||||
assert.ok(width >= 40 && width <= 160, `${id}: content width out of range`);
|
||||
|
||||
// The settings page writes these through range inputs, so a preset landing
|
||||
// between two stops would snap to a different value the first time anyone
|
||||
// touches the slider -- and the config would silently stop matching the
|
||||
// preset it says is active.
|
||||
assert.equal(
|
||||
Math.round(spacing * 100) % 5,
|
||||
0,
|
||||
`${id}: spacing must sit on the 0.05rem slider step`,
|
||||
);
|
||||
assert.equal(
|
||||
Math.round(radius * 1000) % 125,
|
||||
0,
|
||||
`${id}: radius must sit on the 0.125rem slider step`,
|
||||
);
|
||||
assert.equal(width % 1, 0, `${id}: content width must sit on the 1rem slider step`);
|
||||
|
||||
// Written into a single-quoted `uci batch` line; is_valid_font_stack
|
||||
// rejects a literal quote, and so does this.
|
||||
for (const key of TYPOGRAPHY_KEYS) {
|
||||
assert.ok(s[key].length <= 200, `${id}: ${key} too long`);
|
||||
assert.match(s[key], /^[A-Za-z0-9 ,"-]+$/, `${id}: ${key} has illegal characters`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The whole point of the change. Colour-only presets were five paint jobs on
|
||||
// one layout; if a new preset ships without pulling any other lever, the store
|
||||
// is back to selling hues.
|
||||
test("the five presets are five different looks, not five palettes", () => {
|
||||
const structs = PRESET_IDS.map(structOf);
|
||||
|
||||
const distinct = (key) => new Set(structs.map((s) => s[key])).size;
|
||||
|
||||
assert.ok(distinct("nav_type") >= 3, "all three navigation shapes should be on offer");
|
||||
assert.equal(distinct("struct_radius_base"), 5, "every preset should own its corner radius");
|
||||
assert.equal(distinct("struct_font_sans"), 5, "every preset should own its sans typeface");
|
||||
assert.ok(distinct("struct_spacing") >= 3, "all three spacing stops should be on offer");
|
||||
assert.ok(distinct("struct_content_width_centered") >= 3, "content width should vary");
|
||||
assert.equal(distinct("toolbar_enabled"), 2, "at least one preset should turn the toolbar off");
|
||||
|
||||
// No two presets may agree on their entire structure: that would be two
|
||||
// names for one look.
|
||||
const signatures = structs.map((s) => JSON.stringify(s));
|
||||
assert.equal(new Set(signatures).size, PRESET_IDS.length);
|
||||
});
|
||||
|
||||
// Spacing scales the padding and gaps of every component at once, so it is the
|
||||
// lever with the least room to be wrong: three stops are usable and one
|
||||
// combination is not. A looser scale needs somewhere to breathe, and the
|
||||
// sidebar shell has none -- its first column already eats the horizontal
|
||||
// margin and the page runs full-width, so 0.3rem there reads as the layout
|
||||
// having come apart rather than as a roomier theme. It belongs on a centred
|
||||
// layout with a wide content column.
|
||||
const SPACING_STOPS = ["0.2rem", "0.25rem", "0.3rem"];
|
||||
|
||||
test("spacing stays on its three stops, and the loose one avoids the sidebar", () => {
|
||||
assert.equal(
|
||||
structOf("default").struct_spacing,
|
||||
"0.25rem",
|
||||
"the default preset ships the middle stop",
|
||||
);
|
||||
|
||||
const defaultWidth = parseFloat(structOf("default").struct_content_width_centered);
|
||||
|
||||
for (const id of PRESET_IDS) {
|
||||
const s = structOf(id);
|
||||
assert.ok(
|
||||
SPACING_STOPS.includes(s.struct_spacing),
|
||||
`${id}: ${s.struct_spacing} is not one of ${SPACING_STOPS.join(" / ")}`,
|
||||
);
|
||||
|
||||
if (s.struct_spacing !== "0.3rem") continue;
|
||||
|
||||
assert.notEqual(
|
||||
s.nav_type,
|
||||
"sidebar",
|
||||
`${id}: the loose spacing must not ride on the sidebar shell`,
|
||||
);
|
||||
assert.ok(
|
||||
parseFloat(s.struct_content_width_centered) > defaultWidth,
|
||||
`${id}: the loose spacing needs a content column wider than the default ` +
|
||||
`${defaultWidth}rem to sit in`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The font stack is how the device reverse-maps a configuration back to a
|
||||
// roster entry (resolve_font_preset_id / get_font_preset_by_stack). A stack
|
||||
// retyped by hand instead of copied would resolve to nothing: the settings
|
||||
// page would show the wrong font, and sync_and_cache_fonts_from_uci would
|
||||
// never fetch the woff2 files, so the preset would silently render its
|
||||
// fallback family.
|
||||
test("every preset font stack is a verbatim font-presets.conf roster stack", () => {
|
||||
const roster = { sans: new Map(), mono: new Map() };
|
||||
const files = new Set();
|
||||
for (const line of read("root/usr/share/aurora/font-presets.conf").split("\n")) {
|
||||
const parts = line.split("|");
|
||||
if (parts[0] === "font") roster[parts[1]]?.set(parts[6], parts[2]);
|
||||
if (parts[0] === "file") files.add(`${parts[1]}/${parts[2]}`);
|
||||
}
|
||||
|
||||
for (const id of PRESET_IDS) {
|
||||
const s = structOf(id);
|
||||
for (const slot of ["sans", "mono"]) {
|
||||
const fontId = roster[slot].get(s[`struct_font_${slot}`]);
|
||||
assert.ok(fontId, `${id}: struct_font_${slot} matches no ${slot} roster entry`);
|
||||
|
||||
// marketplace.js reads typography.font_* to decide whether to warn that the
|
||||
// typeface has to be downloaded first, treating exactly "default" and
|
||||
// "system" as already on the router. That rule only holds while those
|
||||
// two ids are the ones with no woff2 files to fetch.
|
||||
const bundled = fontId === "default" || fontId === "system";
|
||||
assert.equal(
|
||||
files.has(`${slot}/${fontId}`),
|
||||
!bundled,
|
||||
`${slot}/${fontId}: BUNDLED_FONT_IDS in marketplace.js assumes only ` +
|
||||
`default/system need no download`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rpcd applies the same structural keys the templates carry", () => {
|
||||
const declared = /readonly PRESET_STRUCT_KEYS="([^"]+)"/.exec(rpcd);
|
||||
assert.ok(declared, "PRESET_STRUCT_KEYS is not declared");
|
||||
assert.deepEqual(
|
||||
declared[1].split(/\s+/).filter(Boolean).sort(),
|
||||
STRUCT_KEYS.slice().sort(),
|
||||
);
|
||||
|
||||
// The colour snapshot alone is not enough any more: apply_theme_preset
|
||||
// writes one flat list, and the structural half has to be in it.
|
||||
assert.match(
|
||||
rpcd,
|
||||
/struct=\$\(load_preset_struct_snapshot "\$template"\) \|\| \{ PRESET_SNAPSHOT=""; return 1; \}/,
|
||||
"load_preset_snapshot must fail the whole preset when its structure is unusable",
|
||||
);
|
||||
|
||||
const start = rpcd.indexOf('"apply_theme_preset")');
|
||||
const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start));
|
||||
assert.match(applyBlock, /backup_if_mine/, "删之前必须先留底");
|
||||
assert.match(
|
||||
applyBlock,
|
||||
/delete aurora\.@toolbar_item\[0\]/,
|
||||
"applying a built-in preset replaces the shortcuts with its own",
|
||||
);
|
||||
assert.match(applyBlock, /load_preset_toolbar "\$template"/);
|
||||
});
|
||||
|
||||
// PRESET_SNAPSHOT is one flat list now that it carries structure as well as
|
||||
// colour. get_theme_preset publishes it grouped the way a hub payload groups
|
||||
// the same fields -- an object called "colors" holding nav_type would sit
|
||||
// undetected until something iterated it, because the settings UI only ever
|
||||
// looks colours up by key.
|
||||
test("get_theme_preset publishes the snapshot grouped, not dumped into colors", () => {
|
||||
const start = rpcd.indexOf("json_add_theme_preset_snapshot() {");
|
||||
assert.ok(start !== -1, "json_add_theme_preset_snapshot not found");
|
||||
// The top-level dispatcher, not one of the `case "$1" in` lines inside a
|
||||
// function -- those all sit above this one and would slice to nothing.
|
||||
const fn = rpcd.slice(start, rpcd.indexOf('\ncase "$1" in'));
|
||||
assert.ok(fn.length, "the function body is empty");
|
||||
|
||||
for (const group of ["colors", "layout", "typography"])
|
||||
assert.match(fn, new RegExp(`json_add_object "${group}"`), `missing ${group} group`);
|
||||
|
||||
assert.match(
|
||||
fn,
|
||||
/light_\*\|dark_\*\) json_add_string/,
|
||||
"the colors object must take only the colour keys",
|
||||
);
|
||||
assert.match(fn, /struct_font_sans\|struct_font_mono\) json_add_string/);
|
||||
assert.ok(
|
||||
!/\[ -n "\$key" \] && json_add_string/.test(fn),
|
||||
"the unfiltered dump would put struct_* keys inside colors",
|
||||
);
|
||||
});
|
||||
|
||||
test("applying a preset fetches the typeface it names", () => {
|
||||
assert.match(rpcd, /sync_and_cache_fonts_from_uci\(\)\s*\{/);
|
||||
assert.match(
|
||||
rpcd,
|
||||
/cache_font_slot "sans" "\$sans_preset"/,
|
||||
"the helper must download, not merely rewrite the CSS",
|
||||
);
|
||||
// Detached: it talks to the network and the rpc call must not wait on it.
|
||||
assert.match(rpcd, /\) <\/dev\/null >\/dev\/null 2>&1 &/);
|
||||
|
||||
const start = rpcd.indexOf('"apply_theme_preset")');
|
||||
const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start));
|
||||
assert.ok(
|
||||
applyBlock.indexOf('uci -q commit aurora') <
|
||||
applyBlock.indexOf("sync_and_cache_fonts_from_uci"),
|
||||
"apply_theme_preset must resync fonts after committing",
|
||||
);
|
||||
});
|
||||
|
||||
test("the browser copy of the presets matches the templates", () => {
|
||||
assert.deepEqual(Object.keys(browserPresets).sort(), PRESET_IDS.slice().sort());
|
||||
|
||||
for (const id of PRESET_IDS) {
|
||||
const entry = browserPresets[id];
|
||||
const struct = structOf(id);
|
||||
|
||||
for (const key of LAYOUT_KEYS)
|
||||
assert.equal(entry.layout[key], struct[key], `${id}: layout.${key} drifted`);
|
||||
for (const key of TYPOGRAPHY_KEYS)
|
||||
assert.equal(entry.typography[key], struct[key], `${id}: typography.${key} drifted`);
|
||||
|
||||
// The hub's payload shape, so the store reads a built-in preset with the
|
||||
// accessors it already uses for a shared configuration.
|
||||
assert.ok(Array.isArray(entry.toolbar), `${id}: toolbar must be an array`);
|
||||
assert.deepEqual(
|
||||
entry.toolbar,
|
||||
toolbarOf(templateOf(id)),
|
||||
`${id}: toolbar drifted from the template`,
|
||||
);
|
||||
assert.ok(entry.toolbar.length > 0, `${id}: every preset ships its own shortcuts`);
|
||||
for (const key of Object.keys(entry.colors))
|
||||
assert.equal(
|
||||
entry.colors[key],
|
||||
readOption(templateOf(id), key),
|
||||
`${id}: colors.${key} drifted from the template`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The browser copy is downloaded by every visitor to the store; the templates
|
||||
// are not. So it carries only what the store reads, and the store reads only
|
||||
// the swatch keys -- applying a built-in preset goes through
|
||||
// apply_theme_preset, which reads the full template on the router. Shipping all
|
||||
// 62 colours cost 7,968 bytes that nothing looked at.
|
||||
test("the browser copy carries exactly the swatch keys the store reads", () => {
|
||||
const declared = /const SWATCH_KEYS = \[([^\]]*)\]/.exec(gallery);
|
||||
assert.ok(declared, "marketplace.js no longer declares SWATCH_KEYS");
|
||||
const swatchKeys = [...declared[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.ok(swatchKeys.length > 0, "SWATCH_KEYS parsed empty");
|
||||
|
||||
const expected = ["light", "dark"]
|
||||
.flatMap((mode) => swatchKeys.map((key) => `${mode}_${key}`))
|
||||
.sort();
|
||||
|
||||
for (const id of PRESET_IDS) {
|
||||
assert.deepEqual(
|
||||
Object.keys(browserPresets[id]).includes("colors")
|
||||
? Object.keys(browserPresets[id].colors).sort()
|
||||
: null,
|
||||
expected,
|
||||
`${id}: the browser copy must carry the store's swatch keys and nothing else ` +
|
||||
`-- add a key to SWATCH_KEYS and gen-presets.mjs must follow, or the ` +
|
||||
`card renders a blank swatch`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the Marketplace no longer promises that a preset only repaints", () => {
|
||||
assert.ok(
|
||||
!gallery.includes("Layout (unchanged by this preset)"),
|
||||
"the built-in drawer must show the preset's own layout, not the current one",
|
||||
);
|
||||
assert.ok(
|
||||
!/Presets set the light and dark colors only/.test(gallery),
|
||||
"the apply confirmation must stop claiming a preset changes colours alone",
|
||||
);
|
||||
assert.match(
|
||||
gallery,
|
||||
/const buildLayoutRows = /,
|
||||
"built-in and community drawers must share one layout table",
|
||||
);
|
||||
assert.match(
|
||||
gallery,
|
||||
/buildLayoutRows\(preset\.preview\.layout \|\| \{\}, typography\)/,
|
||||
"the built-in drawer must render that table",
|
||||
);
|
||||
// The card advertises "Works offline"; a preset naming a Fontsource family
|
||||
// is not fully offline until the woff2 files land.
|
||||
assert.match(gallery, /const needsFontDownload = /);
|
||||
assert.match(gallery, /const BUNDLED_FONT_IDS = \["default", "system"\]/);
|
||||
});
|
||||
|
||||
// The shape of presets.json changed once already (151e50a moved it from
|
||||
// {light,dark} to {colors,layout,typography,toolbar}), and browsers kept
|
||||
// serving the old one for months: uhttpd reports Last-Modified as the epoch
|
||||
// and sends no Cache-Control, so a browser's heuristic freshness works out to
|
||||
// roughly 5.6 years and it never revalidates. A cached copy of the old shape
|
||||
// renders every built-in card with the fallback palette and a top nav bar --
|
||||
// no colours, no sidebar, no font chip -- and nothing in the UI says why.
|
||||
//
|
||||
// So the fetch carries a content hash, the way theme.js does for the token
|
||||
// engine. gen-presets.mjs stamps it; this checks the two agree.
|
||||
test("the store cache-busts presets.json with a hash of its contents", () => {
|
||||
const data = read(".dev/src/resource/aurora/presets.json");
|
||||
const expected = createHash("sha256").update(data).digest("hex").slice(0, 8);
|
||||
|
||||
const stamped = /const PRESETS_VERSION = "([^"]+)";/.exec(gallery)?.[1];
|
||||
assert.ok(stamped, "marketplace.js declares PRESETS_VERSION");
|
||||
assert.equal(
|
||||
stamped,
|
||||
expected,
|
||||
"PRESETS_VERSION is stale -- rerun `pnpm gen-presets`",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
gallery,
|
||||
/L\.resource\("aurora\/presets\.json"\)\s*\+\s*"\?v="\s*\+\s*PRESETS_VERSION/,
|
||||
"the fetch must carry the stamp, or browsers keep the copy they already have",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("utils/feed-check.js");
|
||||
|
||||
// These are pure functions, so the test actually runs them rather than
|
||||
// pattern-matching the source: strip LuCI's "require" directives and hand the
|
||||
// remaining function body a stub baseclass.
|
||||
async function load() {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const body = src
|
||||
.replace(/^"use strict";$/m, "")
|
||||
.replace(/^"require [^"]+";$/gm, "");
|
||||
return new Function("baseclass", body)({ extend: (obj) => obj });
|
||||
}
|
||||
|
||||
test("parseRevision pulls the rYYYYMMDD stamp", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.parseRevision("1.1.3-r20260803"), 20260803);
|
||||
assert.equal(m.parseRevision("1.1.13-r20260802"), 20260802);
|
||||
});
|
||||
|
||||
test("parseRevision returns null for anything else", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.parseRevision("26.192.49224.6b21ba7"), null);
|
||||
assert.equal(m.parseRevision("1.1.3"), null);
|
||||
assert.equal(m.parseRevision(""), null);
|
||||
assert.equal(m.parseRevision(undefined), null);
|
||||
assert.equal(m.parseRevision(null), null);
|
||||
});
|
||||
|
||||
test("isNewer never orders versions it cannot parse", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4-r20260901"), true);
|
||||
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.3-r20260803"), false);
|
||||
// Measured on a live router: the installed config was newer than the feed's
|
||||
// snapshot build. Claiming an update there would be a lie.
|
||||
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.2-r20260727"), false);
|
||||
// install.sh: opkg and apk version schemes are not comparable -- never order.
|
||||
assert.equal(m.isNewer("1.1.3", "1.1.4"), false);
|
||||
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4"), false);
|
||||
});
|
||||
|
||||
test("findManifestVersion reads the live manifest shape", async () => {
|
||||
const m = await load();
|
||||
// Shape confirmed against https://openwrt.eamonxg.fun/manifest.json
|
||||
const manifest = {
|
||||
generated: "...",
|
||||
channels: {
|
||||
snapshots: {
|
||||
apk: [
|
||||
{ pkg: "luci-theme-aurora", version: "1.1.13-r20260802" },
|
||||
{ pkg: "luci-app-aurora-config", version: "1.1.2-r20260727" },
|
||||
],
|
||||
opkg: [{ pkg: "luci-theme-aurora", version: "1.1.13-r20260802" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
m.findManifestVersion(manifest, "snapshots", "apk", "luci-app-aurora-config"),
|
||||
"1.1.2-r20260727",
|
||||
);
|
||||
assert.equal(m.findManifestVersion(manifest, "releases", "apk", "x"), null);
|
||||
assert.equal(m.findManifestVersion(manifest, "snapshots", "apk", "nope"), null);
|
||||
assert.equal(m.findManifestVersion(null, "snapshots", "apk", "x"), null);
|
||||
});
|
||||
|
||||
test("pickPackageManagerPath reads the live menu instead of guessing", async () => {
|
||||
const m = await load();
|
||||
const tree = (children) => ({
|
||||
children: { admin: { children: { system: { children } } } },
|
||||
});
|
||||
assert.equal(
|
||||
m.pickPackageManagerPath(tree({ "package-manager": {} })),
|
||||
"admin/system/package-manager",
|
||||
);
|
||||
assert.equal(m.pickPackageManagerPath(tree({ opkg: {} })), "admin/system/opkg");
|
||||
});
|
||||
|
||||
test("pickPackageManagerPath skips nodes the user may not open", async () => {
|
||||
const m = await load();
|
||||
const tree = (children) => ({
|
||||
children: { admin: { children: { system: { children } } } },
|
||||
});
|
||||
// ui.menu keeps unauthorised nodes but flags them; linking there would 403.
|
||||
assert.equal(
|
||||
m.pickPackageManagerPath(tree({ "package-manager": { satisfied: false } })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
m.pickPackageManagerPath(
|
||||
tree({ "package-manager": { satisfied: false }, opkg: {} }),
|
||||
),
|
||||
"admin/system/opkg",
|
||||
);
|
||||
});
|
||||
|
||||
test("pickPackageManagerPath yields null when there is no software page", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.pickPackageManagerPath({ children: {} }), null);
|
||||
assert.equal(m.pickPackageManagerPath(null), null);
|
||||
assert.equal(
|
||||
m.pickPackageManagerPath({
|
||||
children: { admin: { children: { system: { children: { reboot: {} } } } } },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
|
||||
|
||||
test("rpcd: the package manager is detected from its database, like install.sh", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
const block = src.slice(
|
||||
src.indexOf("detect_package_manager()"),
|
||||
src.indexOf("get_system_lang()"),
|
||||
);
|
||||
assert.match(block, /\/lib\/apk\/db\/installed/);
|
||||
assert.match(block, /\/usr\/lib\/opkg\/status/);
|
||||
assert.ok(
|
||||
!/command -v (opkg|apk)/.test(block),
|
||||
"binary probing can disagree with install.sh on the same machine",
|
||||
);
|
||||
});
|
||||
|
||||
test("rpcd: apk versions are read from the database, not by spawning apk", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
// Match a command substitution, not the words: the comment above the awk
|
||||
// block names the call it replaced, and that is worth keeping.
|
||||
assert.ok(
|
||||
!/\$\(apk list -I/.test(src),
|
||||
"spawning apk parses the whole installed db on every page load",
|
||||
);
|
||||
// Verified on a live snapshot router: records are P:<name> then V:<version>.
|
||||
assert.match(src, /\^P:/, "records are keyed by the P: field");
|
||||
assert.match(src, /\^V:/, "versions come from the V: field");
|
||||
});
|
||||
|
||||
test("rpcd: feed status rides along on get_init_data", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.match(src, /json_add_feed_status\(\)/, "missing the status helper");
|
||||
assert.match(
|
||||
src,
|
||||
/json_add_installed_versions[\s\S]{0,200}json_add_feed_status/,
|
||||
"feed status must be emitted inside the existing init-data call",
|
||||
);
|
||||
assert.ok(
|
||||
!/"get_feed_status"/.test(src),
|
||||
"a separate method would add a round trip on every page load",
|
||||
);
|
||||
});
|
||||
|
||||
test("rpcd: feed constants match the spec", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.match(src, /AURORA_FEED_HOST=["']?openwrt\.eamonxg\.fun/);
|
||||
assert.match(src, /AURORA_FEED_FPR=["']?82d72f5ededb6163/);
|
||||
assert.match(src, /AURORA_FEED_CHANNEL=["']?snapshots/);
|
||||
});
|
||||
|
||||
test("rpcd: add_feed drops its own lines before appending", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
const block = src.slice(src.indexOf("aurora_add_feed()"));
|
||||
assert.match(block, /aurora_drop_feed_lines/, "old lines must be removed first");
|
||||
assert.match(
|
||||
block,
|
||||
/https:\/\/\$AURORA_FEED_HOST\//,
|
||||
"apk lines are matched by the host prefix",
|
||||
);
|
||||
assert.match(block, /src\/gz eamonxg /, "opkg lines are matched by the feed name");
|
||||
});
|
||||
|
||||
test("rpcd: add_feed distinguishes a write failure from an index failure", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.match(src, /json_add_boolean "index_refreshed"/);
|
||||
});
|
||||
|
||||
test("rpcd: add_feed installs the keys where each manager looks for them", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.match(src, /\/etc\/opkg\/keys\/\$AURORA_FEED_FPR/);
|
||||
assert.match(src, /\/etc\/apk\/keys\/eamonxg\.pem/);
|
||||
});
|
||||
|
||||
test("rpcd: add_feed is registered on the ubus object", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.match(src, /"add_feed"/);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("view/aurora/studio.js");
|
||||
|
||||
test("feed ui: no manifest request when the feed is not configured", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(
|
||||
src,
|
||||
/if \(!feedStatus\.configured\) return Promise\.resolve\(null\)/,
|
||||
"an unconfigured router must not reach out to the network at all",
|
||||
);
|
||||
});
|
||||
|
||||
test("feed ui: the manifest is fetched by the browser, never through rpcd", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /const FEED_HOST = "openwrt\.eamonxg\.fun"/);
|
||||
assert.match(src, /const MANIFEST_URL = `https:\/\/\$\{FEED_HOST\}\/manifest\.json`/);
|
||||
assert.match(src, /fetch\(MANIFEST_URL/);
|
||||
assert.ok(
|
||||
!/callFetchManifest|hub_http|fetch_url/.test(src),
|
||||
"the router must not proxy the manifest",
|
||||
);
|
||||
});
|
||||
|
||||
test("feed ui: the result is cached for the session", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /sessionStorage/);
|
||||
});
|
||||
|
||||
test("feed ui: the update-source prompt left Studio for the Marketplace inbox", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
for (const gone of ["buildFeedNotice", "FEED_NOTICE_KEY", "feed_notice_dismissed", "callAddFeed", "Add update source", "Upgrading Aurora needs"])
|
||||
assert.ok(!src.includes(gone), gone);
|
||||
assert.ok(
|
||||
!/uci\.set\("aurora", "theme", "feed_/.test(src),
|
||||
"nothing about the feed is a uci change",
|
||||
);
|
||||
// "A newer build exists" is a different thing and stays in Studio.
|
||||
assert.match(src, /_\("%s available"\)/);
|
||||
});
|
||||
|
||||
test("feed ui: nothing is said when there is no update", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!/Up to date|已是最新/.test(src), "silence is the up-to-date state");
|
||||
});
|
||||
|
||||
test("feed ui: add_feed is the only rpcd method, and status still rides on get_init_data", async () => {
|
||||
const hubApi = await readFile(srcPath("utils/hub-api.js"), "utf8");
|
||||
assert.match(hubApi, /method: "add_feed"/, "add_feed must be declared");
|
||||
for (const file of [hubApi, await readFile(SRC, "utf8")])
|
||||
assert.ok(!/method: "get_feed_status"/.test(file), "feed status rides on get_init_data");
|
||||
});
|
||||
|
||||
test("feed ui: a network failure degrades in silence", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const block = src.slice(
|
||||
src.indexOf("const checkForUpdates ="),
|
||||
src.indexOf("const showUpdateCapsule ="),
|
||||
);
|
||||
assert.match(block, /\.catch\(\(\) => null\)/, "no error toast, no console noise");
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const confPath = fileURLToPath(
|
||||
new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url),
|
||||
);
|
||||
const lines = readFileSync(confPath, "utf8").trim().split("\n");
|
||||
|
||||
test("header declares v2 generated format", () => {
|
||||
assert.match(lines[0], /^v2\|generated-by-gen-font-presets\|do-not-edit$/);
|
||||
});
|
||||
|
||||
const fonts = lines.filter((l) => l.startsWith("font|")).map((l) => l.split("|"));
|
||||
const files = lines.filter((l) => l.startsWith("file|")).map((l) => l.split("|"));
|
||||
|
||||
test("font lines have 7 fields and known slots", () => {
|
||||
assert.ok(fonts.length >= 8);
|
||||
for (const f of fonts) {
|
||||
assert.equal(f.length, 7, `bad font line: ${f.join("|")}`);
|
||||
assert.ok(["sans", "mono"].includes(f[1]));
|
||||
assert.ok(/^[a-z0-9-]+$/.test(f[2]), `bad name: ${f[2]}`);
|
||||
assert.ok(f[6].length > 0, "stack required");
|
||||
}
|
||||
});
|
||||
|
||||
test("file lines carry valid sha256 and pinned dual-source urls", () => {
|
||||
assert.ok(files.length > 0);
|
||||
for (const f of files) {
|
||||
const [, slot, name, weight, sha256, url1, url2] = f;
|
||||
assert.equal(f.length, 7, `bad file line: ${f.join("|")}`);
|
||||
assert.ok(["sans", "mono"].includes(slot));
|
||||
assert.ok(/^(400|500|600|700)$/.test(weight));
|
||||
assert.match(sha256, /^[0-9a-f]{64}$/);
|
||||
assert.match(url1, /^https:\/\/cdn\.jsdelivr\.net\/npm\/@fontsource\/[a-z0-9-]+@\d+\.\d+\.\d+\/files\/.+\.woff2$/);
|
||||
assert.match(url2, /^https:\/\/registry\.npmmirror\.com\/@fontsource\/[a-z0-9-]+\/\d+\.\d+\.\d+\/files\/files\/.+\.woff2$/);
|
||||
assert.ok(fonts.some((x) => x[1] === slot && x[2] === name), `orphan file line for ${slot}/${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every webfont preset stays within 4 files, non-webfont has none", () => {
|
||||
for (const f of fonts) {
|
||||
const [, slot, name, , , family] = f;
|
||||
const n = files.filter((x) => x[1] === slot && x[2] === name).length;
|
||||
if (name === "default" || name === "system") assert.equal(n, 0);
|
||||
else {
|
||||
assert.ok(n >= 2 && n <= 4, `${slot}/${name} has ${n} files`);
|
||||
assert.ok(files.some((x) => x[1] === slot && x[2] === name && x[3] === "400"), `${slot}/${name} missing weight 400`);
|
||||
assert.ok(family.length > 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,745 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("utils/hub-api.js");
|
||||
|
||||
test("hub-api module exposes the shared surface", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /^"require baseclass";/m);
|
||||
assert.match(src, /^"require rpc";/m);
|
||||
assert.ok(src.includes("aurora.hub.list"), "missing cache key");
|
||||
assert.ok(src.includes("callHubList"), "missing callHubList");
|
||||
assert.ok(src.includes("callHubGet"), "missing callHubGet");
|
||||
assert.ok(src.includes("getStale"), "missing getStale");
|
||||
assert.match(src, /return baseclass\.extend\(/);
|
||||
});
|
||||
|
||||
test("hub-api list/detail go straight to the hub from the browser", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /const HUB_BASE = "https:\/\/themes\.eamonxg\.fun"/);
|
||||
assert.match(src, /fetch\(HUB_BASE \+ path/, "list/get must use fetch, not ubus");
|
||||
assert.match(src, /AbortController/, "missing fetch timeout");
|
||||
assert.match(src, /hub_unreachable/, "network failures must map to the rpcd envelope");
|
||||
assert.match(src, /invalid_id/, "bad ids must short-circuit without a request");
|
||||
assert.match(src, /result: 0, data/, "success must keep the rpcd envelope");
|
||||
// list/get 不得再走 rpc.declare
|
||||
assert.ok(
|
||||
!/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_list"/.test(src),
|
||||
"hub_list still declared over ubus",
|
||||
);
|
||||
assert.ok(
|
||||
!/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_get"/.test(src),
|
||||
"hub_get still declared over ubus",
|
||||
);
|
||||
});
|
||||
|
||||
// 两份缓存共享一个语义:只作首帧种子,不设 TTL。fetchSort / refreshMyShares
|
||||
// 每次渲染都会覆盖它们,所以"过期"没有意义 —— 而一份永远可用的旧数据,正是
|
||||
// 首屏不必等待 205ms 跨太平洋往返的前提。
|
||||
test("hub-api: caches are first-paint seeds, not TTL'd stores", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!src.includes("CACHE_TTL"), "CACHE_TTL had no live reader; it should be gone");
|
||||
assert.ok(
|
||||
!/^\s+get\(\)\s*\{/m.test(src),
|
||||
"listCache.get() had no caller; it should be gone",
|
||||
);
|
||||
assert.match(src, /localStorage\.getItem\(/);
|
||||
assert.match(src, /localStorage\.setItem\(/);
|
||||
assert.match(src, /localStorage\.removeItem\(/);
|
||||
});
|
||||
|
||||
test("hub-api exposes meCache alongside listCache", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(src.includes("meCache"), "missing meCache");
|
||||
assert.ok(src.includes("aurora.hub.list"), "missing list cache key");
|
||||
assert.ok(src.includes("aurora.hub.me"), "missing me cache key");
|
||||
});
|
||||
|
||||
test("hub-api module exposes the apply/status/restore declares (Task 6)", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(src.includes("callHubApply"), "missing callHubApply");
|
||||
assert.match(src, /method:\s*"hub_apply"/);
|
||||
assert.match(src, /params:\s*\["id"\]/);
|
||||
assert.ok(src.includes("callGetHubStatus"), "missing callGetHubStatus");
|
||||
assert.match(src, /method:\s*"get_hub_status"/);
|
||||
assert.match(src, /params:\s*\["job_id"\]/);
|
||||
assert.ok(src.includes("callHubRestore"), "missing callHubRestore");
|
||||
assert.match(src, /method:\s*"hub_restore_backup"/);
|
||||
});
|
||||
|
||||
test("hub-api module exposes the publish/my-shares/delete declares (Task 8)", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(src.includes("callHubShareBegin"), "missing callHubShareBegin");
|
||||
assert.ok(src.includes("callHubShareCommit"), "missing callHubShareCommit");
|
||||
assert.ok(src.includes("callHubMe"), "missing callHubMe");
|
||||
assert.match(src, /method:\s*"hub_me"/);
|
||||
assert.ok(src.includes("callHubDelete"), "missing callHubDelete");
|
||||
assert.match(src, /method:\s*"hub_delete"/);
|
||||
// 单请求的老路必须彻底消失:它把整张登录背景 base64 塞进一个 1.6MB 的
|
||||
// body,而 uclient-fetch 走 TLS 推不动 —— 留着它就是留着那个 bug。
|
||||
assert.ok(!/method:\s*"hub_share"/.test(src), "hub_share must not be re-declared");
|
||||
assert.ok(!/method:\s*"hub_update"/.test(src), "hub_update must not be re-declared");
|
||||
});
|
||||
|
||||
// hubAssetUrl is pure, so these tests actually run it rather than
|
||||
// pattern-matching the source. Same harness as feed-check-module.test.mjs:
|
||||
// strip LuCI's "require" directives and hand the body stub globals.
|
||||
const strip = (src) =>
|
||||
src.replace(/^"use strict";$/m, "").replace(/^"require [^"]+";$/gm, "");
|
||||
|
||||
// hub-api requires utils.notices; hand it the real module rather than a stub,
|
||||
// so the refresh tests below exercise the sanitizer the router will run.
|
||||
async function load(rpc) {
|
||||
const notices = new Function(
|
||||
"baseclass",
|
||||
strip(await readFile(srcPath("utils/notices.js"), "utf8")),
|
||||
)({ extend: (obj) => obj });
|
||||
return new Function("baseclass", "rpc", "notices", strip(await readFile(SRC, "utf8")))(
|
||||
{ extend: (obj) => obj },
|
||||
rpc || { declare: () => () => Promise.resolve(null) },
|
||||
notices,
|
||||
);
|
||||
}
|
||||
|
||||
test("hubAssetUrl makes the hub's relative asset path absolute", async () => {
|
||||
const m = await load();
|
||||
assert.equal(
|
||||
m.hubAssetUrl("/assets/6zcxcg07/logo_svg"),
|
||||
"https://themes.eamonxg.fun/assets/6zcxcg07/logo_svg",
|
||||
);
|
||||
assert.equal(
|
||||
m.hubAssetUrl("/assets/abc12345/favicon_png"),
|
||||
"https://themes.eamonxg.fun/assets/abc12345/favicon_png",
|
||||
);
|
||||
});
|
||||
|
||||
test("hubAssetUrl rejects anything that is not a hub asset path", async () => {
|
||||
const m = await load();
|
||||
// The path is hub-supplied and therefore untrusted: everything that is not
|
||||
// the one shape the hub can legitimately produce yields "", and the caller
|
||||
// draws its plain-colour fallback instead.
|
||||
assert.equal(m.hubAssetUrl(""), "");
|
||||
assert.equal(m.hubAssetUrl(null), "");
|
||||
assert.equal(m.hubAssetUrl(undefined), "");
|
||||
assert.equal(m.hubAssetUrl("assets/abc/logo_svg"), "");
|
||||
assert.equal(m.hubAssetUrl("/assets/abc/../../etc/passwd"), "");
|
||||
assert.equal(m.hubAssetUrl("/assets/abc/logo_svg?x=1"), "");
|
||||
assert.equal(m.hubAssetUrl("//evil.example/assets/abc/logo_svg"), "");
|
||||
assert.equal(m.hubAssetUrl("https://evil.example/assets/abc/logo_svg"), "");
|
||||
assert.equal(m.hubAssetUrl("javascript:alert(1)"), "");
|
||||
assert.equal(m.hubAssetUrl({ toString: () => "/assets/abc/logo_svg" }), "");
|
||||
});
|
||||
|
||||
test("hub-api: publishing drops the author parameter, nickname gets its own call", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
// 署名是账号属性,由 hub 从 device_token 解析 —— 不是每次发布可以挑的字段。
|
||||
assert.match(
|
||||
src,
|
||||
/method:\s*"hub_share_begin",\s*\n\s*params:\s*\["name", "description", "target_id"\]/,
|
||||
);
|
||||
assert.ok(src.includes("callHubSetNickname"), "missing callHubSetNickname");
|
||||
assert.match(src, /method:\s*"hub_set_nickname"/);
|
||||
assert.match(src, /params:\s*\["nickname"\]/);
|
||||
});
|
||||
|
||||
test("hub-api exposes the creator key export/import declares", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(src.includes("callHubExportKey"), "missing callHubExportKey");
|
||||
assert.match(src, /method:\s*"hub_export_key"/);
|
||||
assert.ok(src.includes("callHubImportKey"), "missing callHubImportKey");
|
||||
assert.match(src, /method:\s*"hub_import_key"/);
|
||||
assert.match(src, /params:\s*\["key"\]/);
|
||||
});
|
||||
|
||||
test("hub-api exposes callHubMe and drops callHubMyShares", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(src.includes("callHubMe"), "missing callHubMe");
|
||||
assert.ok(!src.includes("callHubMyShares"), "callHubMyShares must be gone");
|
||||
assert.match(src, /method:\s*"hub_me"/);
|
||||
});
|
||||
|
||||
// localStorage 在 node 里不存在。模块只在方法体内引用它,因此调用前把桩挂到
|
||||
// globalThis 即可 —— 与上面 hubAssetUrl 那两个测试一样,真的把代码跑起来,
|
||||
// 而不是对源码做模式匹配。
|
||||
function withLocalStorage(fn) {
|
||||
const store = new Map();
|
||||
const had = "localStorage" in globalThis;
|
||||
const prev = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
try {
|
||||
return fn(store);
|
||||
} finally {
|
||||
if (had) globalThis.localStorage = prev;
|
||||
else delete globalThis.localStorage;
|
||||
}
|
||||
}
|
||||
|
||||
test("meCache round-trips the creator profile and clear() empties it", async () => {
|
||||
const m = await load();
|
||||
withLocalStorage(() => {
|
||||
assert.equal(m.meCache.getStale(), null);
|
||||
m.meCache.set({ id: "abc12345", nickname: "eamon", configs: [{ id: "x" }] });
|
||||
assert.deepEqual(m.meCache.getStale(), {
|
||||
id: "abc12345",
|
||||
nickname: "eamon",
|
||||
configs: [{ id: "x" }],
|
||||
});
|
||||
m.meCache.clear();
|
||||
assert.equal(m.meCache.getStale(), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("meCache.getStale never expires -- it is a first-paint seed", async () => {
|
||||
const m = await load();
|
||||
withLocalStorage((store) => {
|
||||
// 一条 1970 年的信封仍必须返回。过期没有意义:refreshMyShares 每次渲染
|
||||
// 都会覆盖它,而扔掉它只会换来一次 0.9s 的白屏。
|
||||
store.set(
|
||||
"aurora.hub.me",
|
||||
JSON.stringify({ timestamp: 0, value: { id: "old", nickname: null, configs: [] } }),
|
||||
);
|
||||
assert.deepEqual(m.meCache.getStale(), {
|
||||
id: "old",
|
||||
nickname: null,
|
||||
configs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("both caches survive corrupt localStorage", async () => {
|
||||
const m = await load();
|
||||
withLocalStorage((store) => {
|
||||
store.set("aurora.hub.me", "{not json");
|
||||
store.set("aurora.hub.list", "{not json");
|
||||
assert.equal(m.meCache.getStale(), null);
|
||||
assert.equal(m.listCache.getStale(), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("the two caches are independent -- clearing one keeps the other", async () => {
|
||||
const m = await load();
|
||||
withLocalStorage(() => {
|
||||
m.listCache.set({ items: [{ id: "a" }] });
|
||||
m.meCache.set({ id: "me", nickname: null, configs: [] });
|
||||
m.meCache.clear();
|
||||
assert.equal(m.meCache.getStale(), null);
|
||||
assert.deepEqual(m.listCache.getStale(), { items: [{ id: "a" }] });
|
||||
});
|
||||
});
|
||||
|
||||
// 三段式发布:两端走 ubus(device_token 留在路由器),中间那段字节由浏览器
|
||||
// 直接 PUT 到 hub —— uclient-fetch 走 TLS 推不动 1.2MB。
|
||||
test("hub-api declares the three-step publish", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /method: "hub_share_begin"/);
|
||||
assert.match(src, /params: \["name", "description", "target_id"\]/);
|
||||
assert.match(src, /method: "hub_share_commit"/);
|
||||
assert.match(src, /params: \["draft_id"\]/);
|
||||
});
|
||||
|
||||
test("publishCurrentConfig uploads asset bytes from the browser", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /publishCurrentConfig/);
|
||||
assert.match(src, /XMLHttpRequest/, "needs upload progress events");
|
||||
assert.match(src, /upload\.onprogress/);
|
||||
assert.match(src, /"Bearer " \+/, "asset PUT authenticates with the ticket");
|
||||
assert.match(src, /HUB_BASE \+ entry\.url/);
|
||||
// 票据是浏览器唯一拿到的凭证。断言只针对代码 —— 注释里解释"为什么浏览器
|
||||
// 拿不到 device_token"正是该留下的东西,把它一起禁掉会逼人删掉理由。
|
||||
const code = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
assert.ok(
|
||||
!/device_token/.test(code),
|
||||
"the device token must never appear in browser code",
|
||||
);
|
||||
});
|
||||
|
||||
// 调用方要据此决定发布成功那句话提不提审核 —— 这个数它已经在手里(begun.assets),
|
||||
// 为一句文案再往 hub 跑一趟是白跑。
|
||||
test("publishCurrentConfig reports how many assets it uploaded", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(
|
||||
src,
|
||||
/result:\s*0,\s*id:\s*done\.id,\s*assets:\s*entries\.length/,
|
||||
"the caller cannot word the review notice without knowing whether anything was uploaded",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema compat + notices
|
||||
|
||||
// fetch 同样只在方法体内被引用,所以和 localStorage 一样挂到 globalThis 上。
|
||||
// respond 收到 URL,返回 { status, body } 或抛错(断网)。
|
||||
async function withFetch(respond, fn) {
|
||||
const calls = [];
|
||||
const prev = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
const { status, body } = await respond(String(url));
|
||||
return { status, ok: status >= 200 && status < 300, json: async () => body };
|
||||
};
|
||||
try {
|
||||
return await fn(calls);
|
||||
} finally {
|
||||
globalThis.fetch = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// withLocalStorage 在 fn 返回的那一刻就拆桩,等不了 promise。
|
||||
async function withStorage(fn) {
|
||||
const store = new Map();
|
||||
const had = "localStorage" in globalThis;
|
||||
const prev = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
try {
|
||||
return await fn(store);
|
||||
} finally {
|
||||
if (had) globalThis.localStorage = prev;
|
||||
else delete globalThis.localStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// refreshNotices 用到 L.resolveDefault;LuCI 里它是全局的。
|
||||
async function withLuci(fn) {
|
||||
const had = "L" in globalThis;
|
||||
const prev = globalThis.L;
|
||||
globalThis.L = {
|
||||
resolveDefault: (promise, fallback) => Promise.resolve(promise).catch(() => fallback),
|
||||
};
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (had) globalThis.L = prev;
|
||||
else delete globalThis.L;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录每一次 ubus 调用;me 是 hub_me 的应答。
|
||||
const recordingRpc = (me, init) => {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
declare: (spec) => () => {
|
||||
calls.push(spec.method);
|
||||
return Promise.resolve(
|
||||
spec.method === "hub_me" ? me : spec.method === "get_init_data" ? init || null : null,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const feedNotice = (over) => ({
|
||||
id: "n1",
|
||||
level: "warning",
|
||||
audience: "all",
|
||||
title: "Heads up",
|
||||
body: "Something changed.",
|
||||
url: "",
|
||||
i18n: {},
|
||||
at: "",
|
||||
...over,
|
||||
});
|
||||
|
||||
test("the client schema is one named constant, and the host string is not repeated", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /^const CLIENT_SCHEMA = 1;$/m);
|
||||
assert.equal((await load()).CLIENT_SCHEMA, 1);
|
||||
assert.equal(src.split("themes.eamonxg.fun").length - 1, 1, "HUB_BASE must stay the only copy");
|
||||
for (const rel of ["utils/notices.js", "preload/aurora-notices.js"])
|
||||
assert.ok(
|
||||
!(await readFile(srcPath(rel), "utf8")).includes("eamonxg"),
|
||||
`${rel} must reach the hub through hub-api`,
|
||||
);
|
||||
});
|
||||
|
||||
test("callHubList tells the hub which schema this client reads", async () => {
|
||||
const m = await load();
|
||||
await withFetch(
|
||||
() => ({ status: 200, body: { items: [] } }),
|
||||
async (calls) => {
|
||||
await m.callHubList("new", 3);
|
||||
await m.callHubList("bogus", -1);
|
||||
assert.deepEqual(calls, [
|
||||
"https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=new&page=3&schema=1",
|
||||
"https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=hot&page=1&schema=1",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("callHubNotices reads the public feed straight from the browser", async () => {
|
||||
const m = await load();
|
||||
await withFetch(
|
||||
() => ({ status: 200, body: { notices: [feedNotice()] } }),
|
||||
async (calls) => {
|
||||
const res = await m.callHubNotices();
|
||||
assert.deepEqual(calls, ["https://themes.eamonxg.fun/api/v1/notices?theme=aurora&schema=1"]);
|
||||
assert.deepEqual(res, { result: 0, data: { notices: [feedNotice()] } });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a hub that has not deployed the feed yet means no notices, not an error", async () => {
|
||||
const m = await load();
|
||||
await withFetch(
|
||||
() => ({ status: 404, body: { error: "not_found" } }),
|
||||
async () => {
|
||||
assert.deepEqual(await m.callHubNotices(), { result: 0, data: { notices: [] } });
|
||||
// The same 404 still means "gone" everywhere else.
|
||||
assert.deepEqual(await m.callHubGet("abc123"), { result: 1, error: "invalid_id" });
|
||||
},
|
||||
);
|
||||
await withFetch(
|
||||
() => ({ status: 500, body: {} }),
|
||||
async () =>
|
||||
assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }),
|
||||
);
|
||||
await withFetch(
|
||||
() => {
|
||||
throw new TypeError("network down");
|
||||
},
|
||||
async () =>
|
||||
assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("inbox state lives under its documented keys; the dismissed list is gone", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!src.includes("noticesDismissed"), "replaced by inboxRead / inboxDone");
|
||||
const m = await load();
|
||||
withLocalStorage((store) => {
|
||||
assert.equal(m.noticesCache.getStale(), null);
|
||||
assert.equal(m.noticesCheckedAt(), 0);
|
||||
assert.deepEqual(m.inboxState(), { read: [], done: [] });
|
||||
|
||||
m.noticesCache.set({ notices: [] });
|
||||
m.markNoticesChecked(1790000000000);
|
||||
m.setInboxState({ read: ["n1", "rejected:c1"], done: ["n2"] });
|
||||
|
||||
assert.deepEqual([...store.keys()].sort(), [
|
||||
"aurora.hub.inboxDone",
|
||||
"aurora.hub.inboxRead",
|
||||
"aurora.hub.notices",
|
||||
"aurora.hub.noticesChecked",
|
||||
]);
|
||||
assert.deepEqual(m.noticesCache.getStale(), { notices: [] });
|
||||
assert.equal(m.noticesCheckedAt(), 1790000000000);
|
||||
assert.deepEqual(m.inboxState(), { read: ["n1", "rejected:c1"], done: ["n2"] });
|
||||
});
|
||||
});
|
||||
|
||||
test("inbox state survives corrupt, hostile and unavailable storage", async () => {
|
||||
const m = await load();
|
||||
withLocalStorage((store) => {
|
||||
store.set("aurora.hub.noticesChecked", "{not json");
|
||||
store.set("aurora.hub.inboxRead", "{not json");
|
||||
store.set("aurora.hub.inboxDone", '{"length":1,"0":"n1"}');
|
||||
assert.equal(m.noticesCheckedAt(), 0);
|
||||
assert.deepEqual(m.inboxState(), { read: [], done: [] });
|
||||
|
||||
store.set("aurora.hub.noticesChecked", '"1790000000000"');
|
||||
store.set("aurora.hub.inboxRead", '["n1",7,null,{"id":"x"},"n2"]');
|
||||
assert.equal(m.noticesCheckedAt(), 0);
|
||||
assert.deepEqual(m.inboxState().read, ["n1", "n2"]);
|
||||
});
|
||||
|
||||
// Safari private mode, a full quota, storage disabled by policy: every
|
||||
// accessor throws. None of it may reach the page.
|
||||
const had = "localStorage" in globalThis;
|
||||
const prev = globalThis.localStorage;
|
||||
const boom = () => {
|
||||
throw new Error("SecurityError");
|
||||
};
|
||||
globalThis.localStorage = { getItem: boom, setItem: boom, removeItem: boom };
|
||||
const prevError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
assert.equal(m.noticesCheckedAt(), 0);
|
||||
assert.deepEqual(m.inboxState(), { read: [], done: [] });
|
||||
assert.equal(m.noticesCache.getStale(), null);
|
||||
assert.doesNotThrow(() => m.markNoticesChecked(1));
|
||||
assert.doesNotThrow(() => m.setInboxState({ read: ["n1"], done: [] }));
|
||||
assert.doesNotThrow(() => m.noticesCache.set({ notices: [] }));
|
||||
assert.doesNotThrow(() => m.noticesCache.clear());
|
||||
assert.doesNotThrow(() => m.setNoticesLocal(null));
|
||||
} finally {
|
||||
console.error = prevError;
|
||||
if (had) globalThis.localStorage = prev;
|
||||
else delete globalThis.localStorage;
|
||||
}
|
||||
|
||||
// With no localStorage at all the reference itself throws.
|
||||
assert.equal(m.noticesCheckedAt(), 0);
|
||||
assert.doesNotThrow(() => m.markNoticesChecked(1));
|
||||
});
|
||||
|
||||
const meReply = (...configs) => ({ result: 0, data: { id: "me", nickname: "eamon", configs } });
|
||||
const ownShare = (over) => ({ id: "c1", name: "Nord Night", status: "active", ...over });
|
||||
|
||||
test("refreshNotices caches a sanitized feed, stamps the check and prunes read/done keys", async () => {
|
||||
const rpc = recordingRpc(null);
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
notices: [
|
||||
feedNotice({ id: "keep" }),
|
||||
feedNotice({ id: "bad id" }),
|
||||
feedNotice({ id: "info1", level: "info", junk: "<script>" }),
|
||||
],
|
||||
},
|
||||
}),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.setInboxState({ read: ["keep", "longGone"], done: ["info1", "alsoGone", "rejected:c9"] });
|
||||
const before = Date.now();
|
||||
const snapshot = await m.refreshNotices();
|
||||
|
||||
assert.deepEqual(snapshot.notices.map((n) => n.id), ["keep", "info1"]);
|
||||
assert.equal("junk" in snapshot.notices[1], false);
|
||||
assert.equal("muted" in snapshot, false);
|
||||
assert.deepEqual(m.noticesCache.getStale(), snapshot);
|
||||
assert.ok(m.noticesCheckedAt() >= before);
|
||||
// hub_me was not asked, so nothing is known about derived items and
|
||||
// their keys stay put.
|
||||
assert.deepEqual(m.inboxState(), { read: ["keep"], done: ["info1", "rejected:c9"] });
|
||||
assert.deepEqual(rpc.calls, ["get_init_data"]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("refreshNotices asks hub_me only for a creators notice or a router known to have shares", async () => {
|
||||
const creatorsFeed = { notices: [feedNotice({ id: "cr", audience: "creators" })] };
|
||||
const plainFeed = { notices: [feedNotice()] };
|
||||
const cases = [
|
||||
{ feed: plainFeed, cachedMe: null, asked: false },
|
||||
{ feed: plainFeed, cachedMe: { id: "me", configs: [] }, asked: false },
|
||||
{ feed: creatorsFeed, cachedMe: null, asked: true },
|
||||
{ feed: plainFeed, cachedMe: { id: "me", configs: [ownShare()] }, asked: true },
|
||||
];
|
||||
for (const { feed, cachedMe, asked } of cases) {
|
||||
const fresh = meReply(ownShare({ id: "c2", status: "removed" }));
|
||||
const rpc = recordingRpc(fresh);
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: feed }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
if (cachedMe) m.meCache.set(cachedMe);
|
||||
await m.refreshNotices();
|
||||
assert.deepEqual(rpc.calls, asked ? ["get_init_data", "hub_me"] : ["get_init_data"]);
|
||||
// The reply lands in the cache the preload derives its items from.
|
||||
assert.deepEqual(m.meCache.getStale(), asked ? fresh.data : cachedMe);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed hub_me keeps the cached profile and every derived key", async () => {
|
||||
for (const reply of [null, { result: 1, error: "hub_unreachable" }, { result: 0 }]) {
|
||||
const rpc = recordingRpc(reply);
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: { notices: [] } }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
const cached = { id: "me", configs: [ownShare({ assets_status: "rejected" })] };
|
||||
m.meCache.set(cached);
|
||||
m.setInboxState({ read: ["gone"], done: ["rejected:c1", "removed:c7"] });
|
||||
await m.refreshNotices();
|
||||
assert.deepEqual(rpc.calls, ["get_init_data", "hub_me"]);
|
||||
assert.deepEqual(m.meCache.getStale(), cached);
|
||||
assert.deepEqual(m.inboxState(), { read: [], done: ["rejected:c1", "removed:c7"] });
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a caller already running hub_me hands it over: no second call, and its answer prunes", async () => {
|
||||
// Thunks: a rejected promise built up front is reported as unhandled before
|
||||
// refreshNotices ever gets to adopt it.
|
||||
const cases = [
|
||||
[() => meReply(ownShare({ assets_status: "rejected" })).data, ["rejected:c1"]],
|
||||
[() => Promise.resolve(meReply().data), []],
|
||||
[() => null, ["rejected:c1", "removed:c7"]],
|
||||
[() => Promise.reject(new Error("hub_me failed")), ["rejected:c1", "removed:c7"]],
|
||||
];
|
||||
for (const [makeMe, doneAfter] of cases) {
|
||||
const rpc = recordingRpc(meReply(ownShare()));
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: { notices: [feedNotice({ id: "cr", audience: "creators" })] } }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.setInboxState({ read: [], done: ["rejected:c1", "removed:c7"] });
|
||||
await m.refreshNotices({ me: makeMe() });
|
||||
assert.deepEqual(rpc.calls, ["get_init_data"]);
|
||||
assert.deepEqual(m.inboxState().done, doneAfter);
|
||||
// The view owns its own meCache write; this path must not clobber it.
|
||||
assert.equal(m.meCache.getStale(), null);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed refresh keeps the previous feed and state, and still counts as a check", async () => {
|
||||
const rpc = recordingRpc(meReply(ownShare()));
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => {
|
||||
throw new TypeError("network down");
|
||||
},
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.noticesCache.set({ notices: [feedNotice({ id: "old" })], muted: true });
|
||||
m.meCache.set({ id: "me", configs: [ownShare()] });
|
||||
m.setInboxState({ read: ["old", "gone"], done: ["removed:c7"] });
|
||||
const before = Date.now();
|
||||
// A 1.2.x opt-out flag in the cache is dropped, not carried forward.
|
||||
const kept = { notices: [feedNotice({ id: "old" })], local: null };
|
||||
assert.deepEqual(await m.refreshNotices(), kept);
|
||||
assert.deepEqual(m.noticesCache.getStale(), kept);
|
||||
assert.deepEqual(m.inboxState(), { read: ["old", "gone"], done: ["removed:c7"] });
|
||||
assert.deepEqual(rpc.calls, ["get_init_data"], "an unreachable hub is not asked a second question");
|
||||
// One attempt per interval whether or not the hub answers: a browser
|
||||
// that cannot reach it must not retry on every page.
|
||||
assert.ok(m.noticesCheckedAt() >= before);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("a malformed feed body degrades to an empty list", async () => {
|
||||
for (const body of [null, [], "notices", { notices: "x" }, { notices: { 0: feedNotice() } }]) {
|
||||
const m = await load();
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
assert.deepEqual(await m.refreshNotices(), { notices: [], local: null });
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const initReply = (configured) => ({
|
||||
feed: { pm: "apk", configured, channel: configured ? "snapshots" : "" },
|
||||
versions: { theme: { installed_version: "1.3.8" }, config: { installed_version: "1.2.0" } },
|
||||
});
|
||||
|
||||
test("refreshNotices probes the update source once and caches the verdict with the feed", async () => {
|
||||
const rpc = recordingRpc(null, initReply(false));
|
||||
const m = await load(rpc);
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: { notices: [] } }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.setInboxState({ read: ["feed:missing"], done: [] });
|
||||
const snapshot = await m.refreshNotices();
|
||||
assert.deepEqual(snapshot.local, { feedMissing: true, theme: "1.3.8", app: "1.2.0" });
|
||||
assert.deepEqual(m.noticesCache.getStale().local, snapshot.local);
|
||||
assert.deepEqual(m.inboxState().read, ["feed:missing"]);
|
||||
assert.deepEqual(rpc.calls, ["get_init_data"]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("once the source is configured the item's keys are pruned; an unanswered probe keeps everything", async () => {
|
||||
for (const [init, local, read] of [
|
||||
[initReply(true), { feedMissing: false, theme: "1.3.8", app: "1.2.0" }, []],
|
||||
[null, { feedMissing: true, theme: "", app: "" }, ["feed:missing"]],
|
||||
[{ feed: { pm: "unknown" } }, { feedMissing: true, theme: "", app: "" }, ["feed:missing"]],
|
||||
]) {
|
||||
const m = await load(recordingRpc(null, init));
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: { notices: [] } }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.noticesCache.set({ notices: [], local: { feedMissing: true, theme: "", app: "" } });
|
||||
m.setInboxState({ read: ["feed:missing"], done: [] });
|
||||
assert.deepEqual((await m.refreshNotices()).local, local);
|
||||
assert.deepEqual(m.inboxState().read, read);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the local verdict survives a dead hub, and can be set on its own", async () => {
|
||||
const m = await load(recordingRpc(null, initReply(false)));
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => {
|
||||
throw new TypeError("network down");
|
||||
},
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
// The router answers even when the hub does not.
|
||||
assert.equal((await m.refreshNotices()).local.feedMissing, true);
|
||||
const after = m.setNoticesLocal({ feedMissing: false, theme: "1.3.8", app: "1.2.0" });
|
||||
assert.deepEqual(after, {
|
||||
notices: [],
|
||||
local: { feedMissing: false, theme: "1.3.8", app: "1.2.0" },
|
||||
});
|
||||
assert.deepEqual(m.noticesCache.getStale(), after);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("hub-api declares the two local calls the inbox needs, and nothing new on the router", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /callGetInitData: rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "get_init_data",/);
|
||||
assert.match(src, /callAddFeed: rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "add_feed",/);
|
||||
const acl = JSON.parse(await readFile(repo("root/usr/share/rpcd/acl.d/luci-app-aurora.json"), "utf8"))[
|
||||
"luci-app-aurora"
|
||||
];
|
||||
assert.ok(acl.read.ubus["luci.aurora"].includes("get_init_data"));
|
||||
assert.ok(acl.write.ubus["luci.aurora"].includes("add_feed"));
|
||||
});
|
||||
|
||||
test("the opt-out is gone: no muting API, no muted flag written, a stale one dropped", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
for (const gone of ["muted", "muteNotices", "setNoticesMuted", "hub_notices"]) assert.ok(!src.includes(gone), gone);
|
||||
const m = await load(recordingRpc(null, null));
|
||||
await withLuci(() =>
|
||||
withFetch(
|
||||
() => ({ status: 200, body: { notices: [feedNotice()] } }),
|
||||
() =>
|
||||
withStorage(async () => {
|
||||
m.noticesCache.set({ notices: [], muted: true, local: null });
|
||||
assert.equal("muted" in m.setNoticesLocal(null), false);
|
||||
m.noticesCache.set({ notices: [], muted: true, local: null });
|
||||
const snapshot = await m.refreshNotices();
|
||||
assert.deepEqual(Object.keys(snapshot).sort(), ["local", "notices"]);
|
||||
assert.equal("muted" in m.noticesCache.getStale(), false);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
|
||||
const rpcd = readFileSync(
|
||||
path.join(repoRoot, "root/usr/libexec/rpcd/luci.aurora"),
|
||||
"utf8",
|
||||
);
|
||||
const acl = readFileSync(
|
||||
path.join(repoRoot, "root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
|
||||
"utf8",
|
||||
);
|
||||
const hubApi = readFileSync(
|
||||
path.join(repoRoot, ".dev/src/resource/utils/hub-api.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Phase 1 把列表与详情改成浏览器直连时,rpcd 的两个 handler 被留作回滚路径。
|
||||
// 直连已稳定运行,这里兑现当时写下的 Phase 4。
|
||||
test("rpcd script: the hub_list and hub_get handlers are gone", () => {
|
||||
assert.ok(!rpcd.includes('"hub_list")'), "hub_list handler should be gone");
|
||||
assert.ok(!rpcd.includes('"hub_get")'), "hub_get handler should be gone");
|
||||
assert.ok(
|
||||
!rpcd.includes('json_add_object "hub_list"'),
|
||||
"hub_list should not be declared in the list branch",
|
||||
);
|
||||
assert.ok(
|
||||
!rpcd.includes('json_add_object "hub_get"'),
|
||||
"hub_get should not be declared in the list branch",
|
||||
);
|
||||
// 只有 hub_list 构造带 sort/page 的浏览 URL,可以拿它当判据。
|
||||
assert.ok(
|
||||
!rpcd.includes("/api/v1/themes/aurora/configs?sort="),
|
||||
"the browse URL should only be built in the browser now",
|
||||
);
|
||||
});
|
||||
|
||||
// ⚠️ 陷阱:被删的 hub_get 与仍在的 hub_apply_worker 使用的是逐字相同的一行
|
||||
// —— hub_http_get "/api/v1/themes/aurora/configs/$id"。所以「详情 URL 是否
|
||||
// 消失」不能作为删除判据,上面那条测试只认 case 标签 '"hub_get")'。反过来,
|
||||
// 这行的存在正好证明 apply 路径没被误删。
|
||||
//
|
||||
// 删 handler 不等于删这个工具函数:apply 仍然必须由路由器自己去取 payload,
|
||||
// 因为它随后要在本地校验并写进 uci —— 那条路径不经过浏览器。
|
||||
test("rpcd script: hub_http_get survives, apply still needs it", () => {
|
||||
assert.match(rpcd, /hub_http_get\(\)\s*\{/);
|
||||
assert.match(rpcd, /hub_apply_worker\(\)\s*\{/);
|
||||
assert.match(
|
||||
rpcd,
|
||||
/hub_http_get "\/api\/v1\/themes\/aurora\/configs\/\$id"/,
|
||||
"the apply worker must still fetch the payload from the router",
|
||||
);
|
||||
});
|
||||
|
||||
test("acl: hub_list and hub_get are no longer granted", () => {
|
||||
const readMethods = JSON.parse(acl)["luci-app-aurora"].read.ubus["luci.aurora"];
|
||||
assert.ok(!readMethods.includes("hub_list"), "hub_list should be gone from read");
|
||||
assert.ok(!readMethods.includes("hub_get"), "hub_get should be gone from read");
|
||||
});
|
||||
|
||||
test("acl: the methods the page still calls are kept", () => {
|
||||
const acljson = JSON.parse(acl)["luci-app-aurora"];
|
||||
const read = acljson.read.ubus["luci.aurora"];
|
||||
const write = acljson.write.ubus["luci.aurora"];
|
||||
assert.ok(read.includes("hub_me"), "hub_me is still called on every page load");
|
||||
assert.ok(read.includes("get_hub_status"), "apply polling still needs get_hub_status");
|
||||
assert.ok(write.includes("hub_apply"));
|
||||
assert.ok(write.includes("hub_share_begin"));
|
||||
assert.ok(write.includes("hub_share_commit"));
|
||||
assert.ok(write.includes("hub_delete"));
|
||||
assert.ok(write.includes("hub_import_key"));
|
||||
});
|
||||
|
||||
test("hub-api: browse and detail are browser-direct, so nothing regressed to ubus", () => {
|
||||
assert.match(hubApi, /fetch\(HUB_BASE \+ path/);
|
||||
assert.ok(!/method:\s*"hub_list"/.test(hubApi), "hub_list must not be re-declared");
|
||||
assert.ok(!/method:\s*"hub_get"/.test(hubApi), "hub_get must not be re-declared");
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("utils/markdown.js");
|
||||
|
||||
async function load() {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const body = src
|
||||
.replace(/^"use strict";$/m, "")
|
||||
.replace(/^"require [^"]+";$/gm, "");
|
||||
return new Function("baseclass", body)({ extend: (obj) => obj });
|
||||
}
|
||||
|
||||
// A document that can only do what the renderer is allowed to do. There is no
|
||||
// innerHTML here to assign to: markup can only come out as element nodes the
|
||||
// renderer chose to create.
|
||||
const makeDoc = () => {
|
||||
const node = (tag) => ({
|
||||
tag,
|
||||
attrs: {},
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = String(value);
|
||||
},
|
||||
});
|
||||
return {
|
||||
createDocumentFragment: () => node("#fragment"),
|
||||
createElement: (tag) => node(tag),
|
||||
createTextNode: (text) => ({ text: String(text) }),
|
||||
};
|
||||
};
|
||||
|
||||
const show = (n) => {
|
||||
if (n.text !== undefined) return n.text;
|
||||
const attrs = Object.keys(n.attrs)
|
||||
.map((k) => ` ${k}="${n.attrs[k]}"`)
|
||||
.join("");
|
||||
const inner = n.children.map(show).join("");
|
||||
return n.tag === "#fragment" ? inner : `<${n.tag}${attrs}>${inner}</${n.tag}>`;
|
||||
};
|
||||
|
||||
const allow = (url) =>
|
||||
url.startsWith("https://") ? url : url.startsWith("admin/") ? "/cgi-bin/luci/" + url : "";
|
||||
|
||||
const html = (m, src, safeUrl) => show(m.render(src, makeDoc(), safeUrl || allow));
|
||||
|
||||
const tags = (n, out = []) => {
|
||||
if (n.tag && n.tag !== "#fragment") out.push(n.tag);
|
||||
(n.children || []).forEach((child) => tags(child, out));
|
||||
return out;
|
||||
};
|
||||
|
||||
test("markdown.js: one dependency, a marked pure core, and no way to parse markup", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
|
||||
assert.deepEqual(requires, ["baseclass"]);
|
||||
const open = src.indexOf("// <markdown-core>\n");
|
||||
const close = src.indexOf("// </markdown-core>\n");
|
||||
assert.ok(open > 0 && close > open, "the hub's admin console syncs the text between the markers");
|
||||
const core = src.slice(open, close);
|
||||
assert.match(core, /^function renderMarkdown\(src, doc, safeUrl\) \{$/m);
|
||||
assert.match(core, /^function markdownToText\(src\) \{$/m);
|
||||
for (const forbidden of ["innerHTML", "outerHTML", "insertAdjacentHTML", "DOMParser", "document.", "window", "L.", "baseclass", "_("])
|
||||
assert.ok(!core.includes(forbidden), `the core must not mention ${forbidden}`);
|
||||
// The core stands alone: evaluated with nothing in scope it still works.
|
||||
const standalone = new Function(core + "; return { renderMarkdown, markdownToText };")();
|
||||
assert.equal(show(standalone.renderMarkdown("**hi**", makeDoc(), allow)), "<p><strong>hi</strong></p>");
|
||||
assert.equal(standalone.markdownToText("# Title"), "Title");
|
||||
});
|
||||
|
||||
test("markdown: paragraphs, headings, emphasis, inline code", async () => {
|
||||
const m = await load();
|
||||
assert.equal(html(m, "One\ntwo.\n\nThree."), "<p>One two.</p><p>Three.</p>");
|
||||
// The pane title is the only big heading, so # starts at h4.
|
||||
assert.equal(html(m, "# A\n## B\n### C\n#### D"), "<h4>A</h4><h5>B</h5><h6>C</h6><p>#### D</p>");
|
||||
assert.equal(html(m, "#nospace"), "<p>#nospace</p>");
|
||||
assert.equal(
|
||||
html(m, "**bold** and *italic* and `code <b>`"),
|
||||
"<p><strong>bold</strong> and <em>italic</em> and <code>code <b></code></p>",
|
||||
);
|
||||
assert.equal(html(m, "**bold with *italic* inside**"), "<p><strong>bold with <em>italic</em> inside</strong></p>");
|
||||
assert.equal(html(m, "2 * 3 * 4 and a ** b"), "<p>2 * 3 * 4 and a ** b</p>");
|
||||
assert.equal(html(m, "`*not italic*`"), "<p><code>*not italic*</code></p>");
|
||||
});
|
||||
|
||||
test("markdown: fenced code, lists, blockquote, rule", async () => {
|
||||
const m = await load();
|
||||
assert.equal(
|
||||
html(m, "```\nuci set a.b='1'\n**raw** <b>\n\nstill code\n```\nafter"),
|
||||
"<pre><code>uci set a.b='1'\n**raw** <b>\n\nstill code</code></pre><p>after</p>",
|
||||
);
|
||||
assert.equal(html(m, "```sh\nopkg update"), "<pre><code>opkg update</code></pre>", "an open fence runs to the end");
|
||||
assert.equal(html(m, "- one\n- **two**\n* three"), "<ul><li>one</li><li><strong>two</strong></li><li>three</li></ul>");
|
||||
assert.equal(html(m, "1. one\n2. two\n\n3. three"), "<ol><li>one</li><li>two</li></ol><ol><li>three</li></ol>");
|
||||
assert.equal(html(m, "- one\n wrapped\n- two"), "<ul><li>one wrapped</li><li>two</li></ul>");
|
||||
// Single level: an indented marker is just another item, never a nested list.
|
||||
assert.equal(html(m, "- one\n - inner"), "<ul><li>one</li><li>inner</li></ul>");
|
||||
assert.equal(html(m, "> kept\n> together\n\ntext"), "<blockquote>kept together</blockquote><p>text</p>");
|
||||
assert.equal(html(m, "above\n\n---\n\nbelow"), "<p>above</p><hr></hr><p>below</p>");
|
||||
});
|
||||
|
||||
test("markdown: links go through the caller's url rule, and nothing else becomes a link", async () => {
|
||||
const m = await load();
|
||||
assert.equal(
|
||||
html(m, "Read the [migration notes](https://themes.eamonxg.fun/notes)."),
|
||||
'<p>Read the <a href="https://themes.eamonxg.fun/notes" target="_blank" rel="noreferrer">migration notes</a>.</p>',
|
||||
);
|
||||
assert.equal(
|
||||
html(m, "[Open the store](admin/system/aurora/marketplace)"),
|
||||
'<p><a href="/cgi-bin/luci/admin/system/aurora/marketplace">Open the store</a></p>',
|
||||
);
|
||||
for (const url of ["javascript:alert`1`", "http://example.com/", "data:text/html,x", "//evil.example", "JAVASCRIPT:x"])
|
||||
assert.equal(html(m, `[click](${url})`), "<p>click</p>", url);
|
||||
// A parenthesis never belongs to a url here, so this is not a link at all.
|
||||
assert.equal(html(m, "[click](javascript:alert(1))"), "<p>[click](javascript:alert(1))</p>");
|
||||
// A rule that throws nothing but returns junk is still a refusal.
|
||||
for (const rule of [() => null, () => undefined, () => 1, () => ({}), () => "", null, undefined, "https://x"])
|
||||
assert.equal(show(m.render("[a](https://ok.example/)", makeDoc(), rule)), "<p>a</p>");
|
||||
// Bare urls, images and reference links are outside the subset.
|
||||
assert.equal(html(m, "https://example.com"), "<p>https://example.com</p>");
|
||||
assert.equal(html(m, ""), '<p>!<a href="https://example.com/x.png" target="_blank" rel="noreferrer">alt</a></p>');
|
||||
assert.deepEqual(tags(m.render("", makeDoc(), allow)).includes("img"), false);
|
||||
assert.equal(html(m, "[a [b](https://ok.example/) c]"), '<p>[a <a href="https://ok.example/" target="_blank" rel="noreferrer">b</a> c]</p>');
|
||||
assert.equal(html(m, "[a](https://ok.example/ title)"), "<p>[a](https://ok.example/ title)</p>");
|
||||
});
|
||||
|
||||
test("markdown: raw HTML is text", async () => {
|
||||
const m = await load();
|
||||
const hostile = '<img src=x onerror="alert(1)"><script>alert(1)</script>';
|
||||
const tree = m.render(hostile + "\n\n<b>bold?</b> & <!-- c -->", makeDoc(), allow);
|
||||
assert.deepEqual(tags(tree), ["p", "p"]);
|
||||
assert.equal(show(tree), `<p>${hostile}</p><p><b>bold?</b> & <!-- c --></p>`);
|
||||
const allowed = ["p", "h4", "h5", "h6", "strong", "em", "code", "pre", "ul", "ol", "li", "blockquote", "hr", "a"];
|
||||
const everything = "# h\n\n**b** *i* `c` [l](https://a.b/)\n\n- x\n\n1. y\n\n> q\n\n---\n\n```\nz\n```\n\n| a | b |\n|---|---|";
|
||||
for (const tag of tags(m.render(everything, makeDoc(), allow))) assert.ok(allowed.includes(tag), tag);
|
||||
assert.equal(html(m, "| a | b |"), "<p>| a | b |</p>", "tables are outside the subset");
|
||||
});
|
||||
|
||||
test("markdown: control characters are dropped, other input is coerced to nothing", async () => {
|
||||
const m = await load();
|
||||
const dirty = "a" + String.fromCharCode(0) + "b" + String.fromCharCode(27) + "[31mc" + String.fromCharCode(127) + "\r\nd\te";
|
||||
assert.equal(html(m, dirty), "<p>ab[31mc d\te</p>");
|
||||
for (const junk of [null, undefined, 5, {}, ["**x**"], { toString: () => "**x**" }]) {
|
||||
assert.equal(html(m, junk), "");
|
||||
assert.equal(m.toText(junk), "");
|
||||
}
|
||||
assert.equal(html(m, ""), "");
|
||||
assert.equal(html(m, "\n\n \n"), "");
|
||||
});
|
||||
|
||||
test("markdown: unterminated markers fall back to the characters that were typed", async () => {
|
||||
const m = await load();
|
||||
assert.equal(html(m, "**never closed"), "<p>**never closed</p>");
|
||||
assert.equal(html(m, "*never closed"), "<p>*never closed</p>");
|
||||
assert.equal(html(m, "`never closed"), "<p>`never closed</p>");
|
||||
assert.equal(html(m, "[never closed"), "<p>[never closed</p>");
|
||||
assert.equal(html(m, "[text](never closed"), "<p>[text](never closed</p>");
|
||||
assert.equal(html(m, "[](https://a.b/)"), "<p>[](https://a.b/)</p>");
|
||||
assert.equal(html(m, "[x]()"), "<p>[x]()</p>");
|
||||
assert.equal(html(m, "****"), "<p>****</p>");
|
||||
assert.equal(html(m, "``"), "<p>``</p>");
|
||||
assert.equal(html(m, "***x***"), "<p><strong>*x</strong>*</p>");
|
||||
// Emphasis nests to a fixed depth; past it the markers are text.
|
||||
assert.equal(html(m, "**a *b `c` **d** e* f**").includes("<script"), false);
|
||||
});
|
||||
|
||||
test("markdown: hostile input costs time in proportion to its length", async () => {
|
||||
const m = await load();
|
||||
const N = 200000;
|
||||
const cases = {
|
||||
"open brackets": "[".repeat(N),
|
||||
"nested brackets": "[a".repeat(N / 2) + "](https://a.b/)",
|
||||
"bracket pairs": "[]".repeat(N / 2),
|
||||
"half links": "[a](".repeat(N / 4),
|
||||
stars: "*".repeat(N),
|
||||
"spaced stars": "* a".repeat(N / 3),
|
||||
"star pairs": "**a ".repeat(N / 4),
|
||||
"unclosed strong then singles": "**" + "*a".repeat(N / 2),
|
||||
backticks: "`".repeat(N),
|
||||
"backtick words": "`a ".repeat(N / 3) ,
|
||||
"one huge line": "x".repeat(N),
|
||||
"many lines": "- x\n".repeat(N / 4),
|
||||
"many fences": "```\n".repeat(N / 4),
|
||||
"many quotes": "> ".repeat(N / 2),
|
||||
hashes: "#".repeat(N),
|
||||
"nested emphasis": "**a *b ".repeat(N / 8) + " b* a**".repeat(N / 8),
|
||||
};
|
||||
for (const [name, input] of Object.entries(cases)) {
|
||||
const started = process.hrtime.bigint();
|
||||
m.render(input, makeDoc(), allow);
|
||||
m.toText(input);
|
||||
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
// Linear work on 200 KB is a few milliseconds; a quadratic path is minutes.
|
||||
assert.ok(ms < 1500, `${name}: ${ms.toFixed(0)} ms`);
|
||||
}
|
||||
});
|
||||
|
||||
test("markdownToText: the first line that says something, without its markers", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.toText("Browsing keeps working; **publishing** is paused.\n\nMore."), "Browsing keeps working; publishing is paused.");
|
||||
assert.equal(m.toText("\n\n## Heads *up*\n\nbody"), "Heads up");
|
||||
assert.equal(m.toText("- first item\n- second"), "first item");
|
||||
assert.equal(m.toText("3. third"), "third");
|
||||
assert.equal(m.toText("> quoted `code`"), "quoted code");
|
||||
assert.equal(m.toText("---\n\nafter the rule"), "after the rule");
|
||||
assert.equal(m.toText("```\n\nuci show\n```"), "uci show");
|
||||
assert.equal(m.toText("See [the notes](https://a.b/) now"), "See the notes now");
|
||||
assert.equal(m.toText("[x](javascript:alert)"), "x");
|
||||
assert.equal(m.toText("line one\nline two"), "line one");
|
||||
assert.equal(m.toText("<b>raw</b>"), "<b>raw</b>");
|
||||
assert.equal(m.toText(" "), "");
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,749 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("utils/notices.js");
|
||||
|
||||
// Same harness as feed-check-module.test.mjs: the module is pure, so the tests
|
||||
// run it instead of pattern-matching its source.
|
||||
async function load() {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const body = src
|
||||
.replace(/^"use strict";$/m, "")
|
||||
.replace(/^"require [^"]+";$/gm, "");
|
||||
return new Function("baseclass", body)({ extend: (obj) => obj });
|
||||
}
|
||||
|
||||
const notice = (over) => ({
|
||||
id: "n1",
|
||||
level: "warning",
|
||||
audience: "all",
|
||||
title: "Schema 1 is going away",
|
||||
body: "Update your shares before the sunset date.",
|
||||
url: "https://themes.eamonxg.fun/notices/n1",
|
||||
i18n: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
test("notices.js is pure: baseclass is its only dependency, no DOM, no network, no storage", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
|
||||
assert.deepEqual(requires, ["baseclass"]);
|
||||
for (const forbidden of ["document", "window", "localStorage", "fetch(", "rpc", "innerHTML"])
|
||||
assert.ok(!src.includes(forbidden), `notices.js must not touch ${forbidden}`);
|
||||
});
|
||||
|
||||
test("localize prefers the exact language, then its base, then the English defaults", async () => {
|
||||
const m = await load();
|
||||
const n = notice({
|
||||
i18n: {
|
||||
"zh-cn": { title: "简体标题", body: "简体正文" },
|
||||
zh: { title: "中文标题", body: "中文正文" },
|
||||
de: { title: "Titel" },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(m.localize(n, "zh-cn"), { title: "简体标题", body: "简体正文" });
|
||||
assert.deepEqual(m.localize(n, "zh-tw"), { title: "中文标题", body: "中文正文" });
|
||||
assert.deepEqual(m.localize(n, "en"), { title: n.title, body: n.body });
|
||||
assert.deepEqual(m.localize(n, "fr"), { title: n.title, body: n.body });
|
||||
// Per field: a locale that only translated the title keeps the English body.
|
||||
assert.deepEqual(m.localize(n, "de"), { title: "Titel", body: n.body });
|
||||
});
|
||||
|
||||
test("localize accepts LuCI's own spelling of a language code", async () => {
|
||||
const m = await load();
|
||||
const n = notice({ i18n: { "zh-cn": { title: "简体标题", body: "简体正文" } } });
|
||||
// dispatcher.lang, and therefore <html lang>, is "zh_cn".
|
||||
assert.equal(m.localize(n, "zh_cn").title, "简体标题");
|
||||
assert.equal(m.localize(n, "ZH_CN").title, "简体标题");
|
||||
for (const lang of [undefined, null, "", 42, {}])
|
||||
assert.deepEqual(m.localize(n, lang), { title: n.title, body: n.body });
|
||||
});
|
||||
|
||||
test("localize ignores values that are not strings", async () => {
|
||||
const m = await load();
|
||||
const n = notice({
|
||||
i18n: {
|
||||
"zh-cn": { title: { toString: () => "<b>x</b>" }, body: 7 },
|
||||
zh: { title: "", body: ["x"] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(m.localize(n, "zh-cn"), { title: n.title, body: n.body });
|
||||
assert.deepEqual(m.localize(notice({ i18n: "zh-cn" }), "zh-cn"), {
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
});
|
||||
assert.deepEqual(m.localize(notice({ i18n: null }), "zh-cn"), { title: n.title, body: n.body });
|
||||
assert.deepEqual(m.localize(null, "en"), { title: "", body: "" });
|
||||
assert.deepEqual(m.localize({ title: 1, body: {} }, "en"), { title: "", body: "" });
|
||||
// A language code that names an Object.prototype member must not resolve to it.
|
||||
assert.deepEqual(m.localize(n, "constructor"), { title: n.title, body: n.body });
|
||||
assert.deepEqual(m.localize(n, "__proto__"), { title: n.title, body: n.body });
|
||||
});
|
||||
|
||||
test("safeUrl lets through https URLs and LuCI-relative admin/ paths", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.safeUrl("https://example.com/a?b=1#c"), "https://example.com/a?b=1#c");
|
||||
assert.equal(m.safeUrl("https://example.com"), "https://example.com");
|
||||
assert.equal(m.safeUrl("admin/system/aurora/marketplace"), "admin/system/aurora/marketplace");
|
||||
assert.equal(
|
||||
m.safeUrl("admin/system/aurora/marketplace?share=1"),
|
||||
"admin/system/aurora/marketplace?share=1",
|
||||
);
|
||||
assert.equal(m.isExternalUrl("https://example.com"), true);
|
||||
assert.equal(m.isExternalUrl("admin/system"), false);
|
||||
assert.equal(m.isExternalUrl(""), false);
|
||||
assert.equal(m.isExternalUrl(null), false);
|
||||
});
|
||||
|
||||
test("safeUrl rejects everything else", async () => {
|
||||
const m = await load();
|
||||
const hostile = [
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
" javascript:alert(1)",
|
||||
"java\nscript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"http://example.com/",
|
||||
"HTTPS://example.com/",
|
||||
"//evil.example/x",
|
||||
"/admin/system",
|
||||
"\\\\evil.example\\x",
|
||||
"https:/example.com",
|
||||
"https:example.com",
|
||||
"https://",
|
||||
"https://user:pass@example.com/",
|
||||
"https://user@example.com/",
|
||||
"https://:pass@example.com/",
|
||||
"https://exa mple.com/",
|
||||
"https://example.com/\u0000",
|
||||
"https://example.com/\n",
|
||||
"https://example.com\\@evil.example/",
|
||||
"admin/../../etc/passwd",
|
||||
"admin//evil.example",
|
||||
"admin/x\\y",
|
||||
"admin/<script>",
|
||||
'admin/"onclick="x',
|
||||
"admin/system aurora",
|
||||
"admin",
|
||||
"administrator/x",
|
||||
"ftp://example.com/",
|
||||
"",
|
||||
];
|
||||
for (const url of hostile) assert.equal(m.safeUrl(url), "", JSON.stringify(url));
|
||||
for (const url of [null, undefined, 1, {}, [], { toString: () => "https://example.com" }])
|
||||
assert.equal(m.safeUrl(url), "", String(url));
|
||||
});
|
||||
|
||||
test("sanitize keeps well-formed notices and only the fields the client reads", async () => {
|
||||
const m = await load();
|
||||
const out = m.sanitize([
|
||||
notice({
|
||||
i18n: { "zh-cn": { title: "标题", body: "正文", html: "<b>x</b>" } },
|
||||
starts_at: null,
|
||||
expires_at: "2026-12-01 00:00:00",
|
||||
__proto__: { polluted: true },
|
||||
extra: { nested: "junk" },
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(out, [
|
||||
{
|
||||
id: "n1",
|
||||
level: "warning",
|
||||
audience: "all",
|
||||
title: "Schema 1 is going away",
|
||||
body: "Update your shares before the sunset date.",
|
||||
url: "https://themes.eamonxg.fun/notices/n1",
|
||||
i18n: { "zh-cn": { title: "标题", body: "正文" } },
|
||||
at: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitize keeps one timestamp: when the notice went live", async () => {
|
||||
const m = await load();
|
||||
const at = (over) => m.sanitize([notice(over)])[0].at;
|
||||
assert.equal(at({ created_at: "2026-09-19 08:00:00" }), "2026-09-19 08:00:00");
|
||||
assert.equal(
|
||||
at({ created_at: "2026-09-01 00:00:00", starts_at: "2026-09-19 08:00:00" }),
|
||||
"2026-09-19 08:00:00",
|
||||
"a scheduled notice is as old as its start, not as its draft",
|
||||
);
|
||||
assert.equal(at({ created_at: "2026-09-19T08:00:00Z" }), "2026-09-19T08:00:00");
|
||||
// Round trip: what the cache hands back sanitizes to the same thing.
|
||||
assert.equal(at({ at: "2026-09-19 08:00:00" }), "2026-09-19 08:00:00");
|
||||
for (const junk of [null, 5, "yesterday", "<b>2026</b>", {}, ["2026-09-19 08:00:00"]])
|
||||
assert.equal(at({ created_at: junk }), "", String(junk));
|
||||
assert.equal(at({ created_at: "2026-09-19 08:00:00<script>" }), "2026-09-19 08:00:00");
|
||||
});
|
||||
|
||||
test("sanitize drops malformed entries", async () => {
|
||||
const m = await load();
|
||||
const bad = [
|
||||
null,
|
||||
undefined,
|
||||
"n1",
|
||||
7,
|
||||
[],
|
||||
notice({ id: "" }),
|
||||
notice({ id: "has-dash" }),
|
||||
notice({ id: 'x"]<img src=x>' }),
|
||||
notice({ id: "a".repeat(33) }),
|
||||
notice({ id: 12345 }),
|
||||
notice({ id: ["n1"] }),
|
||||
notice({ level: "fatal" }),
|
||||
notice({ level: undefined }),
|
||||
notice({ level: ["warning"] }),
|
||||
notice({ audience: "everyone" }),
|
||||
notice({ audience: null }),
|
||||
notice({ title: "" }),
|
||||
notice({ title: " " }),
|
||||
notice({ title: "x".repeat(121) }),
|
||||
notice({ title: 5 }),
|
||||
notice({ title: { toString: () => "title" } }),
|
||||
notice({ body: "x".repeat(4001) }),
|
||||
notice({ body: null }),
|
||||
notice({ body: ["x"] }),
|
||||
];
|
||||
assert.deepEqual(m.sanitize(bad), []);
|
||||
for (const notList of [null, undefined, {}, "[]", 3, { length: 1, 0: notice() }])
|
||||
assert.deepEqual(m.sanitize(notList), []);
|
||||
});
|
||||
|
||||
test("sanitize measures length in characters, not UTF-16 units", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.sanitize([notice({ title: "😀".repeat(120) })]).length, 1);
|
||||
assert.equal(m.sanitize([notice({ title: "😀".repeat(121) })]).length, 0);
|
||||
assert.equal(m.sanitize([notice({ body: "" })]).length, 1);
|
||||
assert.equal(m.sanitize([notice({ body: "x".repeat(4000) })]).length, 1);
|
||||
assert.equal(m.sanitize([notice({ body: "😀".repeat(4000) })]).length, 1);
|
||||
});
|
||||
|
||||
test("sanitize keeps markup as the plain string it is, and tolerates a junk url", async () => {
|
||||
const m = await load();
|
||||
const title = '<img src=x onerror="alert(1)">';
|
||||
const [kept] = m.sanitize([notice({ title, body: "<script>alert(1)</script>", url: 42 })]);
|
||||
assert.equal(kept.title, title);
|
||||
assert.equal(kept.body, "<script>alert(1)</script>");
|
||||
assert.equal(kept.url, "");
|
||||
// The url is judged where it is rendered, so a hostile one survives sanitize
|
||||
// and dies in safeUrl.
|
||||
const [hostile] = m.sanitize([notice({ url: "javascript:alert(1)" })]);
|
||||
assert.equal(m.safeUrl(hostile.url), "");
|
||||
});
|
||||
|
||||
test("sanitize filters i18n down to valid language keys and string fields", async () => {
|
||||
const m = await load();
|
||||
const [kept] = m.sanitize([
|
||||
notice({
|
||||
i18n: {
|
||||
"zh-cn": { title: "标题" },
|
||||
ZH: { title: "大写键" },
|
||||
"zh_cn": { title: "下划线键" },
|
||||
constructor: { title: "x" },
|
||||
fr: { title: 5, body: { a: 1 } },
|
||||
de: "Titel",
|
||||
ja: { title: "x".repeat(121), body: "本文" },
|
||||
ko: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(kept.i18n, { "zh-cn": { title: "标题" }, ja: { body: "本文" } });
|
||||
assert.deepEqual(m.sanitize([notice({ i18n: [] })])[0].i18n, {});
|
||||
assert.deepEqual(m.sanitize([notice({ i18n: "x" })])[0].i18n, {});
|
||||
assert.deepEqual(m.sanitize([notice({ i18n: undefined })])[0].i18n, {});
|
||||
});
|
||||
|
||||
test("sanitize caps the list and drops repeated ids", async () => {
|
||||
const m = await load();
|
||||
const many = Array.from({ length: 500 }, (_, i) => notice({ id: "n" + i }));
|
||||
const out = m.sanitize(many);
|
||||
assert.equal(out.length, m.MAX_NOTICES);
|
||||
assert.equal(m.MAX_NOTICES, 20);
|
||||
// Newest first on the wire, so the cap keeps the head.
|
||||
assert.equal(out[0].id, "n0");
|
||||
assert.equal(out[19].id, "n19");
|
||||
|
||||
const dupes = m.sanitize([notice({ title: "first" }), notice({ title: "second" })]);
|
||||
assert.deepEqual(dupes.map((n) => n.title), ["first"]);
|
||||
// An id that names an Object.prototype member is still just an id.
|
||||
assert.equal(m.sanitize([notice({ id: "constructor" })]).length, 1);
|
||||
|
||||
// Malformed entries do not count towards the cap.
|
||||
const padded = [...Array.from({ length: 100 }, () => null), ...many];
|
||||
assert.equal(m.sanitize(padded).length, 20);
|
||||
});
|
||||
|
||||
test("isDue throttles to one check per twelve hours", async () => {
|
||||
const m = await load();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
assert.equal(m.POLL_INTERVAL_MS, 12 * HOUR);
|
||||
const now = Date.UTC(2026, 8, 19, 12);
|
||||
assert.equal(m.isDue(now - 1, now), false);
|
||||
assert.equal(m.isDue(now - 11 * HOUR, now), false);
|
||||
assert.equal(m.isDue(now - 12 * HOUR + 1, now), false);
|
||||
assert.equal(m.isDue(now - 12 * HOUR, now), true);
|
||||
assert.equal(m.isDue(now - 48 * HOUR, now), true);
|
||||
});
|
||||
|
||||
test("isDue treats a missing, corrupt or future timestamp as due", async () => {
|
||||
const m = await load();
|
||||
const now = Date.UTC(2026, 8, 19, 12);
|
||||
for (const last of [0, -5, NaN, Infinity, null, undefined, "1700000000000", {}])
|
||||
assert.equal(m.isDue(last, now), true, String(last));
|
||||
// A clock that was set back must not silence the check forever.
|
||||
assert.equal(m.isDue(now + 1000, now), true);
|
||||
assert.equal(m.isDue(now - 1000, NaN), true);
|
||||
});
|
||||
|
||||
test("creatorAffected reads compat_summary", async () => {
|
||||
const m = await load();
|
||||
const summary = (deprecated, unsupported) => ({
|
||||
compat_summary: { current_schema: 2, deprecated, unsupported },
|
||||
configs: [],
|
||||
});
|
||||
assert.equal(m.creatorAffected(summary(0, 0)), false);
|
||||
assert.equal(m.creatorAffected(summary(1, 0)), true);
|
||||
assert.equal(m.creatorAffected(summary(0, 3)), true);
|
||||
assert.equal(m.creatorAffected(summary("1", "x")), false);
|
||||
assert.equal(m.creatorAffected(summary(NaN, null)), false);
|
||||
assert.equal(m.creatorAffected(summary(-1, 0)), false);
|
||||
});
|
||||
|
||||
test("creatorAffected falls back to the per-config compat state", async () => {
|
||||
const m = await load();
|
||||
const me = (...states) => ({
|
||||
configs: states.map((state, i) => ({ id: "c" + i, status: "active", compat: { state } })),
|
||||
});
|
||||
assert.equal(m.creatorAffected(me("ok", "ok")), false);
|
||||
assert.equal(m.creatorAffected(me("ok", "deprecated")), true);
|
||||
assert.equal(m.creatorAffected(me("unsupported")), true);
|
||||
// Anything the hub may call it later still means "not fine".
|
||||
assert.equal(m.creatorAffected(me("retired")), true);
|
||||
// A share that was taken down has no update button to send its author to.
|
||||
assert.equal(
|
||||
m.creatorAffected({ configs: [{ status: "removed", compat: { state: "deprecated" } }] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("creatorAffected tolerates an old hub that sends none of the new fields", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.creatorAffected({ id: "abc", nickname: "eamon", configs: [{ id: "x" }] }), false);
|
||||
assert.equal(m.creatorAffected({ id: "abc", nickname: null, configs: [] }), false);
|
||||
assert.equal(m.creatorAffected({}), false);
|
||||
for (const junk of [null, undefined, "me", 1, [], { configs: "x" }, { compat_summary: [] }])
|
||||
assert.equal(m.creatorAffected(junk), false, String(junk));
|
||||
assert.equal(
|
||||
m.creatorAffected({ configs: [null, 1, { compat: null }, { compat: "deprecated" }, { compat: { state: 1 } }] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("compatBadge names the two states a share card has to mention", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.compatBadge({ compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }), "deprecated");
|
||||
assert.equal(m.compatBadge({ compat: { state: "unsupported" } }), "unsupported");
|
||||
assert.equal(m.compatBadge({ compat: { state: "ok" } }), null);
|
||||
assert.equal(m.compatBadge({ compat: { state: "retired" } }), null);
|
||||
assert.equal(m.compatBadge({ compat: { state: ["deprecated"] } }), null);
|
||||
assert.equal(m.compatBadge({ compat: "deprecated" }), null);
|
||||
assert.equal(m.compatBadge({ id: "x" }), null);
|
||||
assert.equal(m.compatBadge({}), null);
|
||||
assert.equal(m.compatBadge(null), null);
|
||||
assert.equal(m.compatBadge(undefined), null);
|
||||
});
|
||||
|
||||
test("sunsetDate shows the day and nothing else the hub may have put there", async () => {
|
||||
const m = await load();
|
||||
const at = (sunset_at) => ({ compat: { state: "deprecated", sunset_at } });
|
||||
assert.equal(m.sunsetDate(at("2026-12-01 00:00:00")), "2026-12-01");
|
||||
assert.equal(m.sunsetDate(at("2026-12-01T00:00:00Z")), "2026-12-01");
|
||||
assert.equal(m.sunsetDate(at("2026-12-01")), "2026-12-01");
|
||||
for (const junk of [
|
||||
null,
|
||||
undefined,
|
||||
"",
|
||||
"soon",
|
||||
"2026-12-01<script>",
|
||||
"2026-12-011",
|
||||
" 2026-12-01",
|
||||
"12/01/2026",
|
||||
20261201,
|
||||
{ toString: () => "2026-12-01" },
|
||||
])
|
||||
assert.equal(m.sunsetDate(at(junk)), "", String(junk));
|
||||
assert.equal(m.sunsetDate({}), "");
|
||||
assert.equal(m.sunsetDate(null), "");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbox: derivation, keys, read/done bookkeeping, pruning, counts
|
||||
|
||||
const share = (over) => ({
|
||||
id: "c1",
|
||||
name: "Nord Night",
|
||||
downloads: 3,
|
||||
status: "active",
|
||||
assets_status: "none",
|
||||
compat: { state: "ok", sunset_at: null },
|
||||
...over,
|
||||
});
|
||||
|
||||
const profile = (...configs) => ({ id: "me", nickname: "eamon", configs });
|
||||
|
||||
test("present localizes, keeps the body as the Markdown it is, and vets the link", async () => {
|
||||
const m = await load();
|
||||
assert.deepEqual(
|
||||
m.present(notice({ body: "One.\n\n- **two**", i18n: { zh: { title: "标题" } } }), "zh_cn"),
|
||||
{
|
||||
title: "标题",
|
||||
body: "One.\n\n- **two**",
|
||||
url: "https://themes.eamonxg.fun/notices/n1",
|
||||
external: true,
|
||||
},
|
||||
);
|
||||
assert.equal(m.present(notice({ i18n: { zh: { body: "**正文**" } } }), "zh-cn").body, "**正文**");
|
||||
assert.equal(m.present(notice({ url: "javascript:alert(1)" }), "en").url, "");
|
||||
assert.equal(m.present(notice({ url: "admin/system" }), "en").external, false);
|
||||
assert.deepEqual(m.present(null, "en"), { title: "", body: "", url: "", external: false });
|
||||
});
|
||||
|
||||
test("compatCounts trusts the hub's summary, counts for itself without one, and finds the first deadline", async () => {
|
||||
const m = await load();
|
||||
const me = profile(
|
||||
share({ id: "a", compat: { state: "deprecated", sunset_at: "2027-01-15 00:00:00" } }),
|
||||
share({ id: "b", compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }),
|
||||
share({ id: "c", compat: { state: "unsupported", sunset_at: "2026-01-01 00:00:00" } }),
|
||||
share({ id: "d", status: "removed", compat: { state: "unsupported" } }),
|
||||
share({ id: "e" }),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(m.compatCounts(me), { deprecated: 2, unsupported: 1, sunset: "2026-12-01" });
|
||||
assert.deepEqual(
|
||||
m.compatCounts({ ...me, compat_summary: { current_schema: 2, deprecated: 5, unsupported: 0 } }),
|
||||
{ deprecated: 5, unsupported: 0, sunset: "2026-12-01" },
|
||||
);
|
||||
for (const junk of [
|
||||
null,
|
||||
undefined,
|
||||
"me",
|
||||
[],
|
||||
{},
|
||||
{ configs: "x" },
|
||||
{ compat_summary: { deprecated: "3", unsupported: -1 } },
|
||||
{ compat_summary: { deprecated: 1.5, unsupported: NaN } },
|
||||
])
|
||||
assert.deepEqual(m.compatCounts(junk), { deprecated: 0, unsupported: 0, sunset: "" });
|
||||
});
|
||||
|
||||
test("hasShares is what tells the preload this router belongs to a creator", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.hasShares(profile(share())), true);
|
||||
assert.equal(m.hasShares(profile(share({ status: "removed" }))), true);
|
||||
assert.equal(m.hasShares(profile()), false);
|
||||
assert.equal(m.hasShares(profile({ id: "bad id" }, null, "x")), false);
|
||||
for (const junk of [null, undefined, {}, { configs: {} }, "me"]) assert.equal(m.hasShares(junk), false);
|
||||
});
|
||||
|
||||
test("derive: one summary item per compat state, keyed by the set of affected shares", async () => {
|
||||
const m = await load();
|
||||
const me = profile(
|
||||
share({ id: "b2", name: "Warm Paper", compat: { state: "deprecated", sunset_at: "2027-01-01 00:00:00" } }),
|
||||
share({ id: "a1", name: "Nord Night", compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }),
|
||||
share({ id: "u9", name: "Old One", compat: { state: "unsupported" } }),
|
||||
share({ id: "ok" }),
|
||||
);
|
||||
const items = m.derive(me);
|
||||
assert.deepEqual(items, [
|
||||
{
|
||||
key: "compat:unsupported:" + m.shortHash("u9"),
|
||||
group: "action",
|
||||
source: "shares",
|
||||
kind: "compat",
|
||||
state: "unsupported",
|
||||
level: "critical",
|
||||
names: ["Old One"],
|
||||
sunset: "",
|
||||
},
|
||||
{
|
||||
key: "compat:deprecated:" + m.shortHash("a1,b2"),
|
||||
group: "action",
|
||||
source: "shares",
|
||||
kind: "compat",
|
||||
state: "deprecated",
|
||||
level: "critical",
|
||||
names: ["Warm Paper", "Nord Night"],
|
||||
sunset: "2026-12-01",
|
||||
},
|
||||
]);
|
||||
assert.match(items[0].key, /^compat:unsupported:[0-9a-z]{1,7}$/);
|
||||
|
||||
// Same set in another order: same key. A share fixed, or a new one affected:
|
||||
// a new key, so a Done'd summary comes back.
|
||||
const reordered = profile(me.configs[1], me.configs[0], me.configs[2]);
|
||||
assert.equal(m.derive(reordered)[1].key, items[1].key);
|
||||
const oneFixed = profile(me.configs[0], me.configs[2]);
|
||||
assert.notEqual(m.derive(oneFixed)[1].key, items[1].key);
|
||||
assert.equal(m.derive(oneFixed)[0].key, items[0].key);
|
||||
});
|
||||
|
||||
test("derive: rejected assets carry the reviewer's words, takedowns stand alone", async () => {
|
||||
const m = await load();
|
||||
const reason = 'login background is 640×360 <img src=x onerror="alert(1)">';
|
||||
const items = m.derive(
|
||||
profile(
|
||||
share({ id: "r1", name: "Warm Paper", assets_status: "rejected", assets_reject_reason: reason }),
|
||||
share({ id: "r2", name: "No Note", assets_status: "rejected" }),
|
||||
share({ id: "p1", assets_status: "pending" }),
|
||||
share({ id: "t1", name: "Taken", status: "removed", assets_status: "rejected", compat: { state: "unsupported" } }),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(items, [
|
||||
{ key: "rejected:r1", group: "action", source: "shares", kind: "rejected", level: "warning", name: "Warm Paper", reason },
|
||||
{ key: "rejected:r2", group: "action", source: "shares", kind: "rejected", level: "warning", name: "No Note", reason: "" },
|
||||
{ key: "removed:t1", group: "action", source: "shares", kind: "removed", level: "warning", name: "Taken" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("derive treats the hub_me reply as untrusted", async () => {
|
||||
const m = await load();
|
||||
for (const junk of [null, undefined, "me", 7, [], {}, { configs: "x" }, { configs: [null, 1, "c"] }])
|
||||
assert.deepEqual(m.derive(junk), []);
|
||||
const items = m.derive(
|
||||
profile(
|
||||
share({ id: 'x"]<b>', status: "removed" }),
|
||||
share({ id: "a".repeat(33), assets_status: "rejected" }),
|
||||
share({ id: "ok1", name: { toString: () => "<b>" }, status: "removed" }),
|
||||
share({ id: "ok2", assets_status: "rejected", assets_reject_reason: "x".repeat(1001) }),
|
||||
share({ id: "ok3", assets_status: "rejected", assets_reject_reason: ["nope"] }),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(items.map((item) => item.key), ["rejected:ok2", "rejected:ok3", "removed:ok1"]);
|
||||
assert.equal(items[2].name, "");
|
||||
assert.equal(items[0].reason, "");
|
||||
assert.equal(items[1].reason, "");
|
||||
});
|
||||
|
||||
test("inbox: Needs action first, Updates after, creators broadcasts only for affected routers", async () => {
|
||||
const m = await load();
|
||||
const snapshot = {
|
||||
notices: [
|
||||
notice({ id: "w1" }),
|
||||
notice({ id: "cr1", audience: "creators" }),
|
||||
notice({ id: "i1", level: "info", created_at: "2026-09-19 08:00:00" }),
|
||||
notice({ id: "bad id" }),
|
||||
],
|
||||
};
|
||||
const affected = profile(share({ id: "a1", compat: { state: "deprecated" } }));
|
||||
const keys = (items) => items.map((item) => item.group + "/" + item.key);
|
||||
|
||||
assert.deepEqual(keys(m.inbox(snapshot, affected, null)), [
|
||||
"action/compat:deprecated:" + m.shortHash("a1"),
|
||||
"updates/w1",
|
||||
"updates/cr1",
|
||||
"updates/i1",
|
||||
]);
|
||||
assert.deepEqual(keys(m.inbox(snapshot, profile(share()), null)), ["updates/w1", "updates/i1"]);
|
||||
assert.deepEqual(keys(m.inbox(snapshot, null, null)), ["updates/w1", "updates/i1"]);
|
||||
|
||||
const info = m.inbox(snapshot, null, null)[1];
|
||||
assert.equal(info.kind, "notice");
|
||||
assert.equal(info.level, "info");
|
||||
assert.equal(info.at, "2026-09-19 08:00:00");
|
||||
assert.equal(info.notice.id, "i1");
|
||||
|
||||
for (const junk of [null, undefined, "x", [], {}, { notices: "x" }])
|
||||
assert.deepEqual(m.inbox(junk, null, null), []);
|
||||
});
|
||||
|
||||
test("inbox: read and done are per key; done hides, and a changed key brings the item back", async () => {
|
||||
const m = await load();
|
||||
const snapshot = { notices: [notice({ id: "w1" }), notice({ id: "w2" })] };
|
||||
const before = profile(share({ id: "a1", compat: { state: "unsupported" } }));
|
||||
const compatKey = "compat:unsupported:" + m.shortHash("a1");
|
||||
|
||||
const items = m.inbox(snapshot, before, { read: ["w1"], done: ["w2", compatKey] });
|
||||
assert.deepEqual(items.map((item) => [item.key, item.unread]), [["w1", false]]);
|
||||
assert.equal(m.unreadCount(items), 0);
|
||||
|
||||
const after = profile(before.configs[0], share({ id: "b2", compat: { state: "unsupported" } }));
|
||||
const again = m.inbox(snapshot, after, { read: ["w1"], done: ["w2", compatKey] });
|
||||
assert.deepEqual(again.map((item) => [item.key, item.unread]), [
|
||||
["compat:unsupported:" + m.shortHash("a1,b2"), true],
|
||||
["w1", false],
|
||||
]);
|
||||
assert.equal(m.unreadCount(again), 1);
|
||||
|
||||
// A corrupt store is an empty store.
|
||||
const loose = m.inbox(snapshot, null, { read: "w1", done: { 0: "w2" } });
|
||||
assert.deepEqual(loose.map((item) => [item.key, item.unread]), [
|
||||
["w1", true],
|
||||
["w2", true],
|
||||
]);
|
||||
assert.equal(m.unreadCount(null), 0);
|
||||
assert.equal(m.unreadCount([null, { unread: true }, { unread: false }]), 1);
|
||||
});
|
||||
|
||||
test("badgeCount: unread Needs-action plus unread warning/critical broadcasts, never info", async () => {
|
||||
const m = await load();
|
||||
const snapshot = {
|
||||
notices: [
|
||||
notice({ id: "w1" }),
|
||||
notice({ id: "c1", level: "critical" }),
|
||||
notice({ id: "i1", level: "info" }),
|
||||
notice({ id: "i2", level: "info" }),
|
||||
],
|
||||
};
|
||||
const me = profile(
|
||||
share({ id: "r1", assets_status: "rejected" }),
|
||||
share({ id: "t1", status: "removed" }),
|
||||
);
|
||||
const none = { read: [], done: [] };
|
||||
assert.equal(m.badgeCount(snapshot, me, none), 4);
|
||||
assert.equal(m.badgeCount(snapshot, null, none), 2);
|
||||
assert.equal(m.badgeCount(snapshot, me, { read: ["w1", "rejected:r1"], done: [] }), 2);
|
||||
assert.equal(m.badgeCount(snapshot, me, { read: [], done: ["c1", "removed:t1"] }), 2);
|
||||
// 1.2.x could cache an opt-out; there is none any more, so it counts.
|
||||
assert.equal(m.badgeCount({ ...snapshot, muted: true }, me, none), 4);
|
||||
assert.equal(m.badgeCount({ notices: [notice({ level: "info" })] }, null, none), 0);
|
||||
for (const junk of [null, undefined, "x", []]) assert.equal(m.badgeCount(junk, me, none), 0);
|
||||
// No feed yet, but a creator's own items still count.
|
||||
assert.equal(m.badgeCount({}, me, none), 2);
|
||||
});
|
||||
|
||||
test("key lists: add once, remove, mark everything", async () => {
|
||||
const m = await load();
|
||||
assert.deepEqual(m.withKey(["a"], "b"), ["a", "b"]);
|
||||
assert.deepEqual(m.withKey(["a", "b"], "b"), ["a", "b"]);
|
||||
assert.deepEqual(m.withKey(null, "a"), ["a"]);
|
||||
assert.deepEqual(m.withKey(["a", 1, null], { toString: () => "x" }), ["a"]);
|
||||
assert.deepEqual(m.withoutKey(["a", "b", "a"], "a"), ["b"]);
|
||||
assert.deepEqual(m.withoutKey("a", "a"), []);
|
||||
assert.deepEqual(m.withKeys(["a"], [{ key: "b" }, { key: "a" }, null, { key: 5 }, { key: "c" }]), ["a", "b", "c"]);
|
||||
assert.deepEqual(m.withKeys(["a"], null), ["a"]);
|
||||
const input = ["a"];
|
||||
m.withKey(input, "b");
|
||||
assert.deepEqual(input, ["a"], "the stored list is never mutated in place");
|
||||
});
|
||||
|
||||
test("pruneKeys forgets keys whose item is gone -- but only for a source that actually answered", async () => {
|
||||
const m = await load();
|
||||
const keys = ["w1", "gone", "rejected:r1", "rejected:old", "compat:deprecated:abc", "w1"];
|
||||
const active = ["w1", "rejected:r1"];
|
||||
assert.deepEqual(m.pruneKeys(keys, active, { feed: true, me: true }), ["w1", "rejected:r1"]);
|
||||
// hub_me failed or was not asked: every derived key is kept, or one offline
|
||||
// page load would hand back everything the author had marked Done.
|
||||
assert.deepEqual(m.pruneKeys(keys, active, { feed: true, me: false }), [
|
||||
"w1",
|
||||
"rejected:r1",
|
||||
"rejected:old",
|
||||
"compat:deprecated:abc",
|
||||
]);
|
||||
assert.deepEqual(m.pruneKeys(keys, active, { feed: false, me: true }), ["w1", "gone", "rejected:r1"]);
|
||||
assert.deepEqual(m.pruneKeys(keys, active, null), ["w1", "gone", "rejected:r1", "rejected:old", "compat:deprecated:abc"]);
|
||||
assert.deepEqual(m.pruneKeys(["a", 1, null, {}], ["a"], { feed: true, me: true }), ["a"]);
|
||||
assert.deepEqual(m.pruneKeys(null, null, { feed: true, me: true }), []);
|
||||
// The router's own item has a source of its own: a failed hub_me says
|
||||
// nothing about it, and a failed local probe keeps it.
|
||||
const all = { feed: true, me: true, local: true };
|
||||
assert.deepEqual(m.pruneKeys(["feed:missing"], [], all), []);
|
||||
assert.deepEqual(m.pruneKeys(["feed:missing"], [], { ...all, local: false }), ["feed:missing"]);
|
||||
assert.deepEqual(m.pruneKeys(["feed:missing"], [], { ...all, me: false }), []);
|
||||
assert.deepEqual(m.pruneKeys(["feed:missing"], ["feed:missing"], all), ["feed:missing"]);
|
||||
});
|
||||
|
||||
test("stampMs reads the hub's UTC timestamps; ageOf picks the coarsest unit that fits", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.stampMs("2026-09-19 08:00:00"), Date.UTC(2026, 8, 19, 8, 0, 0));
|
||||
assert.equal(m.stampMs("2026-09-19T08:00:00Z"), Date.UTC(2026, 8, 19, 8, 0, 0));
|
||||
assert.equal(m.stampMs("2026-09-19"), Date.UTC(2026, 8, 19));
|
||||
for (const junk of ["", "soon", null, undefined, 5, {}, "19/09/2026"]) assert.equal(m.stampMs(junk), null);
|
||||
|
||||
const now = Date.UTC(2026, 8, 19, 12);
|
||||
const MIN = 60 * 1000;
|
||||
assert.deepEqual(m.ageOf(now - 30 * 1000, now), { value: 0, unit: "minute" });
|
||||
assert.deepEqual(m.ageOf(now - 5 * MIN, now), { value: -5, unit: "minute" });
|
||||
assert.deepEqual(m.ageOf(now - 125 * MIN, now), { value: -2, unit: "hour" });
|
||||
assert.deepEqual(m.ageOf(now - 26 * 60 * MIN, now), { value: -1, unit: "day" });
|
||||
assert.deepEqual(m.ageOf(now - 15 * 24 * 60 * MIN, now), { value: -2, unit: "week" });
|
||||
// A notice dated ahead of this browser's clock is "now", not "in 3 hours".
|
||||
assert.deepEqual(m.ageOf(now + 3 * 60 * MIN, now), { value: 0, unit: "minute" });
|
||||
assert.equal(m.ageOf(null, now), null);
|
||||
assert.equal(m.ageOf(now, NaN), null);
|
||||
});
|
||||
|
||||
test("shortHash is stable and short", async () => {
|
||||
const m = await load();
|
||||
assert.equal(m.shortHash("a1,b2"), m.shortHash("a1,b2"));
|
||||
assert.notEqual(m.shortHash("a1,b2"), m.shortHash("a1,b3"));
|
||||
assert.match(m.shortHash(""), /^[0-9a-z]{1,7}$/);
|
||||
assert.match(m.shortHash("x".repeat(5000)), /^[0-9a-z]{1,7}$/);
|
||||
});
|
||||
|
||||
test("localState: the feed verdict comes from get_init_data, and not knowing is not 'missing'", async () => {
|
||||
const m = await load();
|
||||
const reply = (feed, versions) => ({ active_preset: "default", feed, versions });
|
||||
const versions = {
|
||||
theme: { installed_version: "1.3.8-r20260901" },
|
||||
config: { installed_version: "1.2.0-r20260902", i18n_packages: "zh-cn" },
|
||||
};
|
||||
assert.deepEqual(m.localState(reply({ pm: "apk", configured: false, channel: "" }, versions)), {
|
||||
feedMissing: true,
|
||||
theme: "1.3.8-r20260901",
|
||||
app: "1.2.0-r20260902",
|
||||
});
|
||||
assert.equal(m.localState(reply({ pm: "opkg", configured: true, channel: "releases" }, versions)).feedMissing, false);
|
||||
// Studio's banner used the same rule: an unknown package manager says nothing.
|
||||
for (const unknown of [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ result: 1 },
|
||||
reply(null, versions),
|
||||
reply({ pm: "unknown", configured: false }, versions),
|
||||
reply({ pm: "", configured: false }, versions),
|
||||
reply({ configured: false }, versions),
|
||||
reply("apk", versions),
|
||||
])
|
||||
assert.equal(m.localState(unknown), null);
|
||||
assert.deepEqual(m.localState(reply({ pm: "apk", configured: "no" }, { theme: { installed_version: 7 }, config: "x" })), {
|
||||
feedMissing: true,
|
||||
theme: "",
|
||||
app: "",
|
||||
});
|
||||
assert.equal(m.localState(reply({ pm: "apk" }, { theme: { installed_version: "v".repeat(65) } })).theme, "");
|
||||
});
|
||||
|
||||
test("the feed item: one Needs-action entry from this router, first in the list, gone once the source exists", async () => {
|
||||
const m = await load();
|
||||
const local = { feedMissing: true, theme: "1.3.8", app: "1.2.0" };
|
||||
assert.deepEqual(m.deriveLocal(local), [
|
||||
{ key: "feed:missing", group: "action", source: "router", kind: "feed", level: "warning", theme: "1.3.8", app: "1.2.0" },
|
||||
]);
|
||||
assert.equal(m.FEED_KEY, "feed:missing");
|
||||
for (const none of [null, undefined, {}, "x", [], { feedMissing: false }, { feedMissing: "true" }, { feedMissing: 1 }])
|
||||
assert.deepEqual(m.deriveLocal(none), []);
|
||||
assert.deepEqual(m.deriveLocal({ feedMissing: true, theme: 5 })[0].theme, "");
|
||||
|
||||
const snapshot = { local, notices: [notice({ id: "w1" })] };
|
||||
const me = profile(share({ id: "r1", assets_status: "rejected" }));
|
||||
assert.deepEqual(
|
||||
m.inbox(snapshot, me, null).map((item) => item.source + "/" + item.key),
|
||||
["router/feed:missing", "shares/rejected:r1", "store/w1"],
|
||||
);
|
||||
assert.equal(m.badgeCount(snapshot, null, { read: [], done: [] }), 2);
|
||||
assert.equal(m.badgeCount(snapshot, null, { read: ["feed:missing"], done: [] }), 1);
|
||||
assert.equal(m.badgeCount(snapshot, null, { read: [], done: ["feed:missing"] }), 1);
|
||||
assert.equal(m.badgeCount({ ...snapshot, local: { ...local, feedMissing: false } }, null, null), 1);
|
||||
});
|
||||
|
||||
test("the reviewer's note keeps its own, smaller cap", async () => {
|
||||
const m = await load();
|
||||
const reasonOf = (text) =>
|
||||
m.derive(profile(share({ id: "r1", assets_status: "rejected", assets_reject_reason: text })))[0].reason;
|
||||
assert.equal(reasonOf("x".repeat(1000)).length, 1000);
|
||||
assert.equal(reasonOf("x".repeat(1001)), "");
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("preload/aurora-notices.js");
|
||||
const FEED_URL = "https://themes.eamonxg.fun/api/v1/notices?theme=aurora&schema=1";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const strip = (src) =>
|
||||
src.replace(/^"use strict";$/m, "").replace(/^"require [^"]+";$/gm, "");
|
||||
|
||||
const notice = (over) => ({
|
||||
id: "n1",
|
||||
level: "warning",
|
||||
audience: "all",
|
||||
title: "Heads up",
|
||||
body: "Something changed.",
|
||||
url: "https://themes.eamonxg.fun/notices/n1",
|
||||
i18n: { "zh-cn": { title: "请注意", body: "有变化。" } },
|
||||
...over,
|
||||
});
|
||||
|
||||
// LuCI evaluates every resources/preload/*.js before initDOM, on every page.
|
||||
// This builds the smallest world that lets the real preload, the real
|
||||
// utils/notices.js and the real utils/hub-api.js run together, and records
|
||||
// every way out of the page: fetch, ubus, L.require, localStorage.
|
||||
async function boot(options) {
|
||||
const opts = options || {};
|
||||
const log = { requires: [], fetches: [], rpc: [], storageReads: 0, storageWrites: 0, hidden: [] };
|
||||
let moves;
|
||||
const store = new Map(Object.entries(opts.storage || {}));
|
||||
const listeners = [];
|
||||
const idle = [];
|
||||
const timeouts = [];
|
||||
const indicators = [];
|
||||
const styles = [];
|
||||
|
||||
const document = {
|
||||
documentElement: { lang: "en" },
|
||||
head: { appendChild: (node) => styles.push(node) },
|
||||
addEventListener: (type, handler, flags) => listeners.push({ type, handler, flags }),
|
||||
getElementById: (id) => styles.find((node) => node.attrs.id === id) || null,
|
||||
querySelector: (selector) =>
|
||||
indicators.find((node) => selector === `span[data-indicator="${node.id}"]`) || null,
|
||||
};
|
||||
moves = log.moves = [];
|
||||
const window = {
|
||||
setTimeout: (cb, ms) => timeouts.push({ cb, ms }),
|
||||
location: { href: "" },
|
||||
getComputedStyle: () => ({ fontSize: opts.indicatorFontSize ?? "0px" }),
|
||||
};
|
||||
if (opts.idle !== false) window.requestIdleCallback = (cb) => idle.push(cb);
|
||||
|
||||
const E = (tag, attrs) => ({ tag, attrs: attrs || {}, textContent: "" });
|
||||
const translate = (msgid) => msgid;
|
||||
|
||||
// Same contract as luci-base's ui.showIndicator: one span per id, relabelled
|
||||
// in place on the second call.
|
||||
const ui = {
|
||||
showIndicator: (id, label, handler, style) => {
|
||||
let node = indicators.find((candidate) => candidate.id === id);
|
||||
if (!node) {
|
||||
const classes = new Set();
|
||||
node = {
|
||||
id,
|
||||
attrs: {},
|
||||
classes,
|
||||
classList: { add: (name) => classes.add(name) },
|
||||
setAttribute: (name, value) => (node.attrs[name] = String(value)),
|
||||
parentNode: { appendChild: (child) => moves.push(child.id) },
|
||||
};
|
||||
indicators.push(node);
|
||||
}
|
||||
Object.assign(node, { textContent: label, handler, style });
|
||||
return true;
|
||||
},
|
||||
hideIndicator: (id) => {
|
||||
log.hidden.push(id);
|
||||
const at = indicators.findIndex((candidate) => candidate.id === id);
|
||||
if (at !== -1) indicators.splice(at, 1);
|
||||
},
|
||||
};
|
||||
const uci = {
|
||||
load: (name) => {
|
||||
log.rpc.push("uci.load:" + name);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: (config, section, option) => {
|
||||
const theme = { hub_notices: "0" };
|
||||
return option === undefined ? theme : theme && theme[option];
|
||||
},
|
||||
};
|
||||
const rpc = {
|
||||
declare: (spec) => () => {
|
||||
log.rpc.push(spec.method);
|
||||
return Promise.resolve(
|
||||
spec.method === "hub_me"
|
||||
? opts.me || null
|
||||
: spec.method === "get_init_data"
|
||||
? opts.init || null
|
||||
: null,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const baseclass = { extend: (obj) => obj };
|
||||
const noticesModule = new Function(
|
||||
"baseclass",
|
||||
strip(await readFile(srcPath("utils/notices.js"), "utf8")),
|
||||
)(baseclass);
|
||||
const modules = { ui, uci, "utils.notices": noticesModule };
|
||||
|
||||
const L = {
|
||||
env: { sessionid: "session" in opts ? opts.session : "0123456789abcdef" },
|
||||
url: (path) => "/cgi-bin/luci/" + path,
|
||||
resolveDefault: (promise, fallback) => Promise.resolve(promise).catch(() => fallback),
|
||||
require: (name) => {
|
||||
log.requires.push(name);
|
||||
if (opts.requireFails === name) return Promise.reject(new Error("HTTP error 404"));
|
||||
return Promise.resolve(modules[name]);
|
||||
},
|
||||
};
|
||||
|
||||
const globals = {
|
||||
L,
|
||||
fetch: async (url) => {
|
||||
log.fetches.push(String(url));
|
||||
if (opts.fetchFails) throw new TypeError("network down");
|
||||
const status = opts.status || 200;
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => ({ notices: opts.feed || [] }),
|
||||
};
|
||||
},
|
||||
localStorage: {
|
||||
getItem: (k) => {
|
||||
log.storageReads += 1;
|
||||
return store.has(k) ? store.get(k) : null;
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
log.storageWrites += 1;
|
||||
store.set(k, String(v));
|
||||
},
|
||||
removeItem: (k) => store.delete(k),
|
||||
},
|
||||
};
|
||||
const saved = {};
|
||||
for (const key of Object.keys(globals)) {
|
||||
saved[key] = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.defineProperty(globalThis, key, {
|
||||
value: globals[key],
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
const restore = () => {
|
||||
for (const key of Object.keys(globals)) {
|
||||
if (saved[key]) Object.defineProperty(globalThis, key, saved[key]);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
};
|
||||
|
||||
modules["utils.hub-api"] = new Function(
|
||||
"baseclass",
|
||||
"rpc",
|
||||
"notices",
|
||||
strip(await readFile(srcPath("utils/hub-api.js"), "utf8")),
|
||||
)(baseclass, rpc, noticesModule);
|
||||
|
||||
// Same signature LuCI's own loader evals a class file into, plus the two
|
||||
// page globals (E, _) a browser would already have.
|
||||
const preload = new Function(
|
||||
"window",
|
||||
"document",
|
||||
"L",
|
||||
"baseclass",
|
||||
"E",
|
||||
"_",
|
||||
strip(await readFile(SRC, "utf8")),
|
||||
)(window, document, L, baseclass, E, translate);
|
||||
|
||||
return {
|
||||
log,
|
||||
store,
|
||||
listeners,
|
||||
idle,
|
||||
timeouts,
|
||||
indicators,
|
||||
styles,
|
||||
window,
|
||||
preload,
|
||||
restore,
|
||||
init: () => preload.__init__(),
|
||||
// Fire luci-loaded, then run whatever was parked for idle time.
|
||||
async loaded() {
|
||||
for (const { handler } of listeners.splice(0)) handler();
|
||||
for (const cb of idle.splice(0)) await cb();
|
||||
for (const { cb } of timeouts.splice(0)) await cb();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cached = (value) => JSON.stringify({ timestamp: Date.now() - HOUR, value });
|
||||
|
||||
const fresh = (snapshot, extra) => ({
|
||||
"aurora.hub.noticesChecked": JSON.stringify(Date.now() - HOUR),
|
||||
"aurora.hub.notices": cached(snapshot),
|
||||
...(extra || {}),
|
||||
});
|
||||
|
||||
const creator = (...configs) => ({ id: "me", nickname: "eamon", configs });
|
||||
|
||||
const run = async (options, check) => {
|
||||
const world = await boot(options);
|
||||
try {
|
||||
world.init();
|
||||
await world.loaded();
|
||||
await check(world);
|
||||
} finally {
|
||||
world.restore();
|
||||
}
|
||||
return world;
|
||||
};
|
||||
|
||||
test("the preload declares baseclass and nothing else", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
|
||||
// Every declared dependency is fetched and evaluated before initDOM, on every
|
||||
// page of LuCI -- including the ones that have nothing to do with this app.
|
||||
assert.deepEqual(requires, ["baseclass"]);
|
||||
assert.match(src, /^return baseclass\.extend\(/m);
|
||||
assert.ok(!src.includes("innerHTML"), "nothing here may be parsed as markup");
|
||||
assert.ok(!src.includes("addNotification"), "the global entry point is an indicator, not a banner");
|
||||
});
|
||||
|
||||
test("__init__ only parks a luci-loaded listener", async () => {
|
||||
const world = await boot({ storage: fresh({ notices: [notice()] }) });
|
||||
try {
|
||||
world.init();
|
||||
assert.equal(world.listeners.length, 1);
|
||||
assert.equal(world.listeners[0].type, "luci-loaded");
|
||||
assert.deepEqual(world.listeners[0].flags, { once: true });
|
||||
assert.deepEqual(world.log.requires, []);
|
||||
assert.deepEqual(world.log.fetches, []);
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
assert.equal(world.log.storageReads, 0);
|
||||
assert.equal(world.log.storageWrites, 0);
|
||||
assert.equal(world.idle.length, 0);
|
||||
assert.equal(world.indicators.length, 0);
|
||||
assert.equal(world.styles.length, 0);
|
||||
|
||||
// The event itself still does no work: it hands over to idle time.
|
||||
for (const { handler } of world.listeners) handler();
|
||||
assert.equal(world.idle.length, 1);
|
||||
assert.deepEqual(world.log.requires, []);
|
||||
assert.equal(world.log.storageReads, 0);
|
||||
} finally {
|
||||
world.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("without a session -- the login page -- nothing is scheduled at all", async () => {
|
||||
for (const session of [null, undefined, ""]) {
|
||||
const world = await boot({ session, storage: fresh({ notices: [notice()] }) });
|
||||
try {
|
||||
world.init();
|
||||
assert.equal(world.listeners.length, 0);
|
||||
await world.loaded();
|
||||
assert.deepEqual(world.log.requires, []);
|
||||
assert.equal(world.log.storageReads, 0);
|
||||
assert.equal(world.indicators.length, 0);
|
||||
} finally {
|
||||
world.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("browsers without requestIdleCallback fall back to a timeout", async () => {
|
||||
const world = await boot({ idle: false, storage: fresh({ notices: [notice()] }) });
|
||||
try {
|
||||
world.init();
|
||||
for (const { handler } of world.listeners) handler();
|
||||
assert.equal(world.timeouts.length, 1);
|
||||
await world.loaded();
|
||||
assert.equal(world.indicators.length, 1);
|
||||
} finally {
|
||||
world.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a check that is not due counts from the caches: no fetch, no ubus", async () => {
|
||||
await run(
|
||||
{
|
||||
storage: fresh(
|
||||
{
|
||||
notices: [
|
||||
notice(),
|
||||
notice({ id: "c1", level: "critical" }),
|
||||
notice({ id: "i1", level: "info" }),
|
||||
notice({ id: "w2" }),
|
||||
notice({ id: "w3" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
"aurora.hub.me": cached(creator({ id: "s1", name: "Warm Paper", status: "active", assets_status: "rejected" })),
|
||||
"aurora.hub.inboxRead": JSON.stringify(["w2"]),
|
||||
"aurora.hub.inboxDone": JSON.stringify(["w3"]),
|
||||
},
|
||||
),
|
||||
},
|
||||
(world) => {
|
||||
assert.deepEqual(world.log.fetches, []);
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
assert.deepEqual(world.log.requires, ["utils.notices", "utils.hub-api", "utils.notices", "utils.hub-api", "ui"]);
|
||||
assert.equal(world.log.storageWrites, 0);
|
||||
// n1 + c1 + the rejected share; info never counts, read and done do not either.
|
||||
const [indicator] = world.indicators;
|
||||
assert.equal(indicator.id, "aurora-inbox");
|
||||
assert.equal(indicator.textContent, "Inbox 3");
|
||||
assert.equal(indicator.attrs["data-count"], "3");
|
||||
assert.equal(indicator.style, "active");
|
||||
assert.equal(indicator.title, "Inbox 3");
|
||||
assert.equal(typeof indicator.handler, "function");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("nothing unread that matters: no indicator, no injected style", async () => {
|
||||
const quiet = [
|
||||
fresh({ notices: [] }),
|
||||
fresh({ notices: [notice({ level: "info" })] }),
|
||||
fresh({ notices: [notice()] }, { "aurora.hub.inboxRead": JSON.stringify(["n1"]) }),
|
||||
fresh({ notices: [notice()] }, { "aurora.hub.inboxDone": JSON.stringify(["n1"]) }),
|
||||
fresh({ notices: [notice({ audience: "creators" })] }),
|
||||
fresh("{corrupt"),
|
||||
{ "aurora.hub.noticesChecked": JSON.stringify(Date.now() - HOUR) },
|
||||
];
|
||||
for (const storage of quiet)
|
||||
await run({ storage }, (world) => {
|
||||
assert.deepEqual(world.log.fetches, []);
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
assert.equal(world.indicators.length, 0);
|
||||
assert.equal(world.styles.length, 0);
|
||||
assert.deepEqual(world.log.hidden, ["aurora-inbox"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("a creators broadcast counts once the cached profile shows an affected share", async () => {
|
||||
await run(
|
||||
{
|
||||
storage: fresh(
|
||||
{ notices: [notice({ audience: "creators" })] },
|
||||
{ "aurora.hub.me": cached(creator({ id: "s1", status: "active", compat: { state: "deprecated" } })) },
|
||||
),
|
||||
},
|
||||
(world) => {
|
||||
// The broadcast plus the derived "needs an update" summary.
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "2");
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("one indicator and one style per document, however often it is refreshed", async () => {
|
||||
await run({ storage: fresh({ notices: [notice(), notice({ id: "n2" })] }) }, async (world) => {
|
||||
await world.preload.update();
|
||||
await world.preload.update();
|
||||
assert.equal(world.indicators.length, 1);
|
||||
assert.equal(world.styles.length, 1);
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "2");
|
||||
|
||||
// The Marketplace marks things read and pokes the same module.
|
||||
world.store.set("aurora.hub.inboxRead", JSON.stringify(["n1"]));
|
||||
await world.preload.update();
|
||||
assert.equal(world.indicators[0].textContent, "Inbox 1");
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "1");
|
||||
|
||||
world.store.set("aurora.hub.inboxRead", JSON.stringify(["n1", "n2"]));
|
||||
await world.preload.update();
|
||||
assert.equal(world.indicators.length, 0, "the indicator leaves as soon as the last item is read");
|
||||
assert.deepEqual(world.log.fetches, []);
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the indicator opens the Marketplace on its Inbox tab", async () => {
|
||||
await run({ storage: fresh({ notices: [notice()] }) }, (world) => {
|
||||
assert.equal(world.preload.INBOX_HASH, "#inbox");
|
||||
world.indicators[0].handler();
|
||||
assert.equal(world.window.location.href, "/cgi-bin/luci/admin/system/aurora/marketplace#inbox");
|
||||
});
|
||||
});
|
||||
|
||||
test("the injected style draws the icon only where the theme hides indicator text", async () => {
|
||||
await run({ storage: fresh({ notices: [notice()] }) }, (world) => {
|
||||
const [style] = world.styles;
|
||||
assert.equal(style.tag, "style");
|
||||
const css = style.textContent;
|
||||
const rules = css.split("}").filter((rule) => rule.trim());
|
||||
assert.equal(rules.length, 3);
|
||||
for (const rule of rules)
|
||||
assert.ok(rule.startsWith('#indicators span[data-indicator="aurora-inbox"].aurora-inbox-icon'), rule.slice(0, 80));
|
||||
assert.doesNotMatch(css, /data-nav-type/);
|
||||
assert.match(rules[0], /\{position:relative;order:1;/);
|
||||
assert.match(css, /::before\{-webkit-mask:var\(--aurora-inbox-icon\) center\/cover no-repeat;mask:var\(--aurora-inbox-icon\) center\/cover no-repeat\}/);
|
||||
assert.match(css, /--aurora-inbox-icon:url\("data:image\/svg\+xml,/);
|
||||
const [indicator] = world.indicators;
|
||||
assert.deepEqual([...indicator.classes], ["aurora-inbox-icon"]);
|
||||
assert.deepEqual(world.log.moves, ["aurora-inbox"]);
|
||||
// The theme's uci-changes badge: -top-0.5 -right-0.5 min-h-3 min-w-3 px-0.5
|
||||
// text-[8px] font-bold leading-none rounded-full bg-danger text-on-brand.
|
||||
const badge = css.slice(css.indexOf("[data-count]::after{"));
|
||||
for (const declaration of [
|
||||
"content:attr(data-count)",
|
||||
"position:absolute",
|
||||
"top:-2px",
|
||||
"right:-2px",
|
||||
"z-index:10",
|
||||
"min-width:12px",
|
||||
"min-height:12px",
|
||||
"padding:0 2px",
|
||||
"border-radius:99px",
|
||||
"background:var(--danger)",
|
||||
"color:var(--on-brand)",
|
||||
"font-size:8px",
|
||||
"font-weight:700",
|
||||
"line-height:1",
|
||||
])
|
||||
assert.ok(badge.includes(declaration), declaration);
|
||||
});
|
||||
});
|
||||
|
||||
test("a theme that shows indicator text keeps LuCI's plain label", async () => {
|
||||
await run({ storage: fresh({ notices: [notice()] }), indicatorFontSize: "13px" }, (world) => {
|
||||
const [indicator] = world.indicators;
|
||||
assert.deepEqual([...indicator.classes], []);
|
||||
assert.match(indicator.textContent, /^Inbox \d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("a due check fetches the feed once, caches it and shows the count", async () => {
|
||||
const stale = { "aurora.hub.noticesChecked": JSON.stringify(Date.now() - 13 * HOUR) };
|
||||
for (const storage of [{}, stale]) {
|
||||
const before = Date.now();
|
||||
await run(
|
||||
{
|
||||
storage: { ...storage, "aurora.hub.inboxDone": JSON.stringify(["gone", "n9"]) },
|
||||
feed: [notice(), notice({ id: "n9" }), notice({ id: "bad id" })],
|
||||
},
|
||||
(world) => {
|
||||
assert.deepEqual(world.log.requires.slice(0, 2), ["utils.notices", "utils.hub-api"]);
|
||||
assert.deepEqual(world.log.rpc, ["get_init_data"]);
|
||||
assert.deepEqual(world.log.fetches, [FEED_URL]);
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "1");
|
||||
|
||||
const snapshot = JSON.parse(world.store.get("aurora.hub.notices")).value;
|
||||
assert.deepEqual(snapshot.notices.map((n) => n.id), ["n1", "n9"]);
|
||||
assert.equal("muted" in snapshot, false);
|
||||
assert.ok(JSON.parse(world.store.get("aurora.hub.noticesChecked")) >= before);
|
||||
assert.deepEqual(JSON.parse(world.store.get("aurora.hub.inboxDone")), ["n9"]);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("hub_me runs only when due, and then only for a creators notice or a router known to have shares", async () => {
|
||||
const rejected = { id: "s1", name: "Warm Paper", status: "active", assets_status: "rejected" };
|
||||
const reply = { result: 0, data: creator(rejected) };
|
||||
const cases = [
|
||||
{ feed: [notice()], rpc: ["get_init_data"], count: "1" },
|
||||
{ feed: [notice({ audience: "creators" })], rpc: ["get_init_data", "hub_me"], count: "1" },
|
||||
{
|
||||
feed: [notice()],
|
||||
storage: { "aurora.hub.me": cached(creator({ id: "s1", status: "active" })) },
|
||||
rpc: ["get_init_data", "hub_me"],
|
||||
count: "2",
|
||||
},
|
||||
];
|
||||
for (const { feed, storage, rpc, count } of cases)
|
||||
await run({ feed, storage, me: reply }, (world) => {
|
||||
assert.deepEqual(world.log.rpc, rpc);
|
||||
assert.equal(world.indicators[0].attrs["data-count"], count);
|
||||
});
|
||||
|
||||
// Not due: a creator's router still asks nothing.
|
||||
await run(
|
||||
{ storage: fresh({ notices: [] }, { "aurora.hub.me": cached(creator(rejected)) }), me: reply },
|
||||
(world) => {
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "1");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("the missing update source counts, from a probe made only when due and cached with the feed", async () => {
|
||||
const init = { feed: { pm: "apk", configured: false, channel: "" }, versions: {} };
|
||||
const world = await run({ init, feed: [] }, (w) => {
|
||||
assert.deepEqual(w.log.rpc, ["get_init_data"]);
|
||||
assert.equal(w.indicators[0].attrs["data-count"], "1");
|
||||
assert.equal(JSON.parse(w.store.get("aurora.hub.notices")).value.local.feedMissing, true);
|
||||
});
|
||||
|
||||
// The pages of the next twelve hours count it from the cache: zero ubus.
|
||||
await run({ init, storage: Object.fromEntries(world.store) }, (next) => {
|
||||
assert.deepEqual(next.log.rpc, []);
|
||||
assert.deepEqual(next.log.fetches, []);
|
||||
assert.equal(next.indicators[0].attrs["data-count"], "1");
|
||||
});
|
||||
|
||||
await run(
|
||||
{ init: { feed: { pm: "apk", configured: true, channel: "snapshots" }, versions: {} }, feed: [] },
|
||||
(w) => assert.equal(w.indicators.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
test("the preload never loads the Markdown renderer", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!/markdown/i.test(src));
|
||||
const hubApi = await readFile(srcPath("utils/hub-api.js"), "utf8");
|
||||
const noticesSrc = await readFile(srcPath("utils/notices.js"), "utf8");
|
||||
for (const dependency of [hubApi, noticesSrc]) assert.ok(!/require utils\.markdown/.test(dependency));
|
||||
await run({ feed: [notice({ body: "**bold** [x](https://a.b/)" })] }, (world) =>
|
||||
assert.ok(!world.log.requires.some((name) => name.includes("markdown"))),
|
||||
);
|
||||
});
|
||||
|
||||
test("a hub without the endpoint, a dead network, a broken module: all silent", async () => {
|
||||
const rejections = [];
|
||||
const onRejection = (reason) => rejections.push(reason);
|
||||
process.on("unhandledRejection", onRejection);
|
||||
try {
|
||||
for (const options of [
|
||||
{ status: 404 },
|
||||
{ status: 500 },
|
||||
{ fetchFails: true },
|
||||
{ requireFails: "utils.notices" },
|
||||
{ requireFails: "utils.hub-api" },
|
||||
{ requireFails: "ui", feed: [notice()] },
|
||||
]) {
|
||||
const world = await boot(options);
|
||||
try {
|
||||
world.init();
|
||||
await assert.doesNotReject(world.loaded());
|
||||
await assert.doesNotReject(world.preload.update());
|
||||
assert.equal(world.indicators.length, 0);
|
||||
} finally {
|
||||
world.restore();
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(rejections, []);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onRejection);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed poll still shows what the caches already held", async () => {
|
||||
for (const options of [{ fetchFails: true }, { status: 500 }])
|
||||
await run(
|
||||
{
|
||||
...options,
|
||||
storage: { "aurora.hub.notices": cached({ notices: [notice()] }) },
|
||||
},
|
||||
(world) => assert.equal(world.indicators[0].attrs["data-count"], "1"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the shipped preload stays small: it is parsed before every page's DOM init", async () => {
|
||||
// About 1 KB of this is the injected stylesheet -- the inbox glyph as a mask
|
||||
// data URI plus the badge -- which has to live here rather than in the theme,
|
||||
// or a new app on an old theme would show a black square.
|
||||
const BUDGET = 2560;
|
||||
const { size } = await stat(repo("htdocs/luci-static/resources/preload/aurora-notices.js"));
|
||||
assert.ok(size < BUDGET, `preload artifact is ${size} bytes; the budget is ${BUDGET}`);
|
||||
});
|
||||
|
||||
test("the count is always on: the preload never reads uci, and a 1.2.x opt-out in the cache is ignored", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!/\buci\b|hub_notices|muted/.test(src), "no opt-out is read or honoured");
|
||||
|
||||
// The world below would report hub_notices '0' if asked. Nobody asks.
|
||||
const stale = fresh({ notices: [notice()], muted: true });
|
||||
await run({ storage: stale }, (world) => {
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "1");
|
||||
assert.ok(!world.log.requires.includes("uci"));
|
||||
assert.deepEqual(world.log.rpc, []);
|
||||
});
|
||||
|
||||
// Due, with the same stale flag: fetched, shown, and the flag is gone from the cache.
|
||||
await run({ storage: { "aurora.hub.notices": cached({ notices: [], muted: true }) }, feed: [notice()] }, (world) => {
|
||||
assert.equal(world.indicators[0].attrs["data-count"], "1");
|
||||
assert.ok(!world.log.requires.includes("uci"));
|
||||
assert.ok(!world.log.rpc.some((call) => call.startsWith("uci")));
|
||||
assert.equal("muted" in JSON.parse(world.store.get("aurora.hub.notices")).value, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Test paths resolve from this file, never from process.cwd(): `pnpm test` runs
|
||||
// with cwd=.dev while most of what these tests assert about lives at the repo
|
||||
// root. `repo` addresses the repo root; `src` addresses the JS sources, which
|
||||
// are what a human edits -- assert against src, not against the built artifacts
|
||||
// under htdocs, unless the artifact itself is the thing under test.
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const repo = (relPath) =>
|
||||
fileURLToPath(new URL(`../../${relPath}`, import.meta.url));
|
||||
|
||||
// Deliberately not called `src`: almost every test in here already binds
|
||||
// `const src = await readFile(...)`, which would shadow the helper and turn a
|
||||
// path lookup into "src is not a function" halfway down a file.
|
||||
export const srcPath = (relPath) =>
|
||||
fileURLToPath(new URL(`../src/resource/${relPath}`, import.meta.url));
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
// msgfmt --check-format only inspects entries flagged `#, c-format`, and
|
||||
// merge-po.mjs writes no flags -- so running it over these catalogues proves
|
||||
// nothing at all (verified by mutating a msgstr's %d to %s: msgfmt stayed
|
||||
// silent). LuCI's translate() does the substitution itself, and a msgstr whose
|
||||
// placeholders do not match its msgid renders as a broken string on a device
|
||||
// nobody testing in English will ever look at. So the check lives here.
|
||||
|
||||
// .DS_Store 也在这个目录里,所以按"有没有 aurora-config.po"筛,而不是按名字排除。
|
||||
const LANGS = readdirSync(repo("po"), { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && d.name !== "templates")
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
|
||||
const parse = (text) =>
|
||||
[...text.matchAll(/msgid "((?:[^"\\]|\\.)*)"\nmsgstr "((?:[^"\\]|\\.)*)"\n/g)]
|
||||
.map((m) => ({ msgid: m[1], msgstr: m[2] }))
|
||||
.filter((e) => e.msgid !== "");
|
||||
|
||||
// %% is a literal percent, not a placeholder -- strip it before counting.
|
||||
const placeholders = (s) => (s.replace(/%%/g, "").match(/%[a-z]/g) || []);
|
||||
|
||||
test("po: 每个 msgstr 的占位符与 msgid 逐个对应", () => {
|
||||
const problems = [];
|
||||
for (const lang of LANGS) {
|
||||
for (const { msgid, msgstr } of parse(
|
||||
readFileSync(repo(`po/${lang}/aurora-config.po`), "utf8"),
|
||||
)) {
|
||||
if (!msgstr) continue;
|
||||
const want = placeholders(msgid);
|
||||
const got = placeholders(msgstr);
|
||||
// 顺序也要一致:LuCI 的 format() 按位置取参,换个顺序就是把体积填进个数里。
|
||||
if (want.join(",") !== got.join(",")) {
|
||||
problems.push(`${lang}: [${want}] vs [${got}] ${msgid.slice(0, 50)}`);
|
||||
}
|
||||
// %% 的个数也不能变 —— 少一个,进度条就会把 "50%" 显示成 "50"。
|
||||
if ((msgid.match(/%%/g) || []).length !== (msgstr.match(/%%/g) || []).length) {
|
||||
problems.push(`${lang}: %% count ${msgid.slice(0, 50)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test("po: 十四个语种一条不缺、一条不空", () => {
|
||||
const pot = parse(readFileSync(repo("po/templates/aurora-config.pot"), "utf8"));
|
||||
assert.equal(LANGS.length, 14);
|
||||
for (const lang of LANGS) {
|
||||
const entries = parse(readFileSync(repo(`po/${lang}/aurora-config.po`), "utf8"));
|
||||
assert.equal(entries.length, pot.length, `${lang} 条目数与模板不符`);
|
||||
const empty = entries.filter((e) => !e.msgstr).map((e) => e.msgid);
|
||||
// 空 msgstr 会原样吐出英文,看起来像"这条忘了翻",而不是"这条没有译文"。
|
||||
assert.deepEqual(empty, [], `${lang} 有未翻译条目`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,872 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
|
||||
const rpcd = readFileSync(
|
||||
path.join(repoRoot, "root/usr/libexec/rpcd/luci.aurora"),
|
||||
"utf8",
|
||||
);
|
||||
const acl = readFileSync(
|
||||
path.join(repoRoot, "root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("rpcd script: hub constants are defined", () => {
|
||||
assert.match(rpcd, /readonly HUB_BASE="https:\/\/themes\.eamonxg\.fun"/);
|
||||
assert.match(rpcd, /readonly DEVICE_DIR="\/etc\/aurora"/);
|
||||
assert.match(rpcd, /readonly HUB_JOB_PATH="\/tmp\/aurora_hub_jobs"/);
|
||||
});
|
||||
|
||||
test("rpcd script: device identity and hub HTTP helpers are defined", () => {
|
||||
assert.match(rpcd, /ensure_device_identity\(\)\s*\{/);
|
||||
assert.match(rpcd, /hub_http_get\(\)\s*\{/);
|
||||
assert.match(rpcd, /hub_http_post\(\)\s*\{/);
|
||||
});
|
||||
|
||||
test("rpcd script: ensure_device_identity generates and locks down device files", () => {
|
||||
assert.match(rpcd, /DEVICE_DIR\/device\.key/);
|
||||
assert.match(rpcd, /chmod 600 "\$DEVICE_DIR\/device\.key"/);
|
||||
assert.match(rpcd, /DEVICE_DIR\/device\.hash/);
|
||||
assert.match(rpcd, /chmod 644 "\$DEVICE_DIR\/device\.hash"/);
|
||||
assert.match(
|
||||
rpcd,
|
||||
/head -c32 \/dev\/urandom \| hexdump -v -e '\/1 "%02x"'/,
|
||||
);
|
||||
assert.match(rpcd, /DEVICE_TOKEN=\$\(cat "\$DEVICE_DIR\/device\.key"\)/);
|
||||
assert.match(rpcd, /DEVICE_HASH=\$\(cat "\$DEVICE_DIR\/device\.hash"\)/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_http_get reuses fetch_url", () => {
|
||||
assert.match(rpcd, /fetch_url "\$out" "\$HUB_BASE\$1"/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_http_post posts JSON via wget", () => {
|
||||
assert.match(rpcd, /--post-file="\$2" "\$HUB_BASE\$1"/);
|
||||
assert.match(rpcd, /Content-Type: application\/json/);
|
||||
});
|
||||
|
||||
// uclient-fetch (what /usr/bin/wget actually is on OpenWrt) treats
|
||||
// --post-file as "use the POST method with this body", so combining it with
|
||||
// --method is a contradiction: it prints its usage and exits non-zero, which
|
||||
// the caller can only surface as hub_unreachable. Update and delete were dead
|
||||
// on arrival because of this. The flag that pairs with --method is
|
||||
// --body-file.
|
||||
test("rpcd script: PUT and DELETE pair --method with --body-file, never --post-file", () => {
|
||||
assert.match(rpcd, /--method=PUT --body-file="\$2"/);
|
||||
assert.match(rpcd, /--method=DELETE --body-file="\$2"/);
|
||||
assert.ok(
|
||||
!/--method=\w+ --post-file/.test(rpcd),
|
||||
"--post-file forces POST; pairing it with --method makes uclient-fetch reject the call",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Task 5: apply job (backup + local validation + assets + rollback) ---
|
||||
|
||||
test("rpcd script: job status writer and validator functions are defined", () => {
|
||||
assert.match(rpcd, /write_hub_job_status\(\)\s*\{/);
|
||||
assert.match(rpcd, /validate_and_apply_hub_payload\(\)\s*\{/);
|
||||
assert.match(rpcd, /hub_apply_worker\(\)\s*\{/);
|
||||
});
|
||||
|
||||
test("rpcd script: write_hub_job_status prunes status files older than an hour (/tmp/aurora_hub_jobs is never otherwise cleaned up)", () => {
|
||||
const body = extractFunctionBody(rpcd, "write_hub_job_status");
|
||||
assert.match(body, /find\s+"\$HUB_JOB_PATH"\s+-name\s+'\*\.status'\s+-mmin\s+\+60\s+-delete/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_apply, get_hub_status, hub_restore_backup handlers exist", () => {
|
||||
assert.ok(rpcd.includes('"hub_apply")'));
|
||||
assert.ok(rpcd.includes('"get_hub_status")'));
|
||||
assert.ok(rpcd.includes('"hub_restore_backup")'));
|
||||
});
|
||||
|
||||
test("rpcd script: hub_apply, get_hub_status, hub_restore_backup registered in list branch", () => {
|
||||
assert.ok(rpcd.includes('json_add_object "hub_apply"'));
|
||||
assert.ok(rpcd.includes('json_add_object "get_hub_status"'));
|
||||
assert.ok(rpcd.includes('json_add_object "hub_restore_backup"'));
|
||||
});
|
||||
|
||||
test("rpcd script: get_hub_status guards job_id with the font-job charset pattern", () => {
|
||||
assert.match(rpcd, /""\|\*\[!A-Za-z0-9_\.\-\]\*\)/);
|
||||
});
|
||||
|
||||
test("rpcd script: worker takes a pre-hub-backup snapshot before touching UCI", () => {
|
||||
assert.match(rpcd, /pre-hub-backup\.conf/);
|
||||
assert.match(rpcd, /cp\s+"?\$CONFIG_FILE"?\s+"\$DEVICE_DIR\/pre-hub-backup\.conf"/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_restore_backup restores the snapshot and resyncs derived state", () => {
|
||||
assert.match(
|
||||
rpcd,
|
||||
/cp\s+"\$DEVICE_DIR\/pre-hub-backup\.conf"\s+"?\$CONFIG_FILE"?/,
|
||||
);
|
||||
assert.match(rpcd, /"no_backup"/);
|
||||
});
|
||||
|
||||
test("rpcd script: color validation uses the variable-length hex regex", () => {
|
||||
assert.match(rpcd, /\[0-9a-f\]\{3,4\}/);
|
||||
});
|
||||
|
||||
test("rpcd script: colors are applied with single-quoted uci batch values", () => {
|
||||
assert.match(rpcd, /set aurora\.theme\.%s='%s'/);
|
||||
});
|
||||
|
||||
test("rpcd script: uci commit failure reverts the batch and reports error", () => {
|
||||
assert.match(rpcd, /uci -q revert aurora/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub-applied bookkeeping is written on success", () => {
|
||||
assert.match(rpcd, /aurora\.theme\.hub_applied/);
|
||||
assert.match(rpcd, /aurora\.theme\.icon_cache_version/);
|
||||
});
|
||||
|
||||
test("rpcd script: assets are fetched with checksum verification and skip on failure", () => {
|
||||
assert.match(rpcd, /fetch_verified\s+"\$tmp_path"/);
|
||||
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.match(workerBody, /apply_hub_asset\s+"\$a_kind"\s+"\$a_sha256"/);
|
||||
});
|
||||
|
||||
test("rpcd script: login_bg asset lands as struct_login_bg", () => {
|
||||
assert.match(rpcd, /struct_login_bg/);
|
||||
});
|
||||
|
||||
// --- FINAL-REVIEW: fetch_verified used to be pointed straight at the live
|
||||
// shipped-default image file, so a corrupt/mismatched hub download deleted
|
||||
// the factory default with nothing to fall back to; and hub_restore_backup
|
||||
// restored /etc/config/aurora but never the images an apply had overwritten.
|
||||
// apply_hub_asset now fetches+verifies to a temp path and only mv's onto the
|
||||
// live target on success, backing it up first. ---
|
||||
|
||||
test("rpcd script: apply_hub_asset fetches+verifies to a temp path, never rm's the live target on failure, and mv's only on success", () => {
|
||||
const body = extractFunctionBody(rpcd, "apply_hub_asset");
|
||||
assert.match(body, /tmp_path="\$ICON_PATH\/\.hubtmp-\$\$"/);
|
||||
assert.match(body, /fetch_verified\s+"\$tmp_path"\s+"\$sha256"\s+"\$url"/);
|
||||
assert.match(body, /mv\s+"\$tmp_path"\s+"\$target_path"/);
|
||||
assert.ok(!/rm -f "\$target_path"/.test(body), "must never rm the live target file");
|
||||
});
|
||||
|
||||
// 名字现在是 sha 派生的,所以落地通常是**新增**而不是覆盖。两件事因此都要做:
|
||||
// 覆盖到的备份下来,新增的打 .new 标记 —— 少了后者,回滚会把别人的图永远留在
|
||||
// overlay 上(字体那边同样的道理,见 restore_pre_hub_fonts)。
|
||||
test("rpcd script: apply_hub_asset records what a rollback has to undo", () => {
|
||||
const body = extractFunctionBody(rpcd, "apply_hub_asset");
|
||||
assert.match(body, /backup_dir="\$DEVICE_DIR\/pre-hub-images"/);
|
||||
assert.match(body, /cp -p "\$target_path" "\$backup_dir\/\$name"/);
|
||||
assert.match(body, /: > "\$backup_dir\/\$name\.new"/);
|
||||
});
|
||||
|
||||
test("rpcd script: apply_hub_asset points the matching uci option at the applied filename on success", () => {
|
||||
const body = extractFunctionBody(rpcd, "apply_hub_asset");
|
||||
assert.match(rpcd, /hub_asset_uci_option\(\)\s*\{/);
|
||||
assert.match(body, /uci -q set "aurora\.theme\.\$opt=\$name"/);
|
||||
assert.match(body, /struct_login_bg="url\('\/luci-static\/aurora\/images\/\$name'\)"/);
|
||||
});
|
||||
|
||||
// 单槽位快照现在住在 backup_if_mine 里,worker 只是调用它 —— 因为清空上一次的
|
||||
// 图片备份和拍这次的配置快照必须同进同退:只做一半,回滚就会交还一份配置配着
|
||||
// 另一套图片。
|
||||
test("rpcd script: the single-slot snapshot clears any prior apply's image backups before taking a new one", () => {
|
||||
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.match(workerBody, /backup_if_mine/);
|
||||
const backupBody = extractFunctionBody(rpcd, "backup_if_mine");
|
||||
assert.match(backupBody, /rm -rf "\$DEVICE_DIR\/pre-hub-images"/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_restore_backup restores backed-up images alongside /etc/config/aurora", () => {
|
||||
const branchMatch = rpcd.match(/"hub_restore_backup"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(branchMatch);
|
||||
assert.match(branchMatch[1], /restore_pre_hub_images/);
|
||||
const restoreBody = extractFunctionBody(rpcd, "restore_pre_hub_images");
|
||||
assert.match(restoreBody, /pre-hub-images/);
|
||||
assert.match(restoreBody, /cp -p "\$f" "\$ICON_PATH\/\$\{f##\*\/\}"/);
|
||||
assert.match(restoreBody, /rm -rf "\$backup_dir"/);
|
||||
// 先删本次新引入的,再拷回被覆盖的 —— 顺序是正确性的一部分:一张图可以两者
|
||||
// 都是(这次覆盖掉的,正是更早一次 apply 引入的)。
|
||||
const dropIdx = restoreBody.indexOf('rm -f "$ICON_PATH/${base%.new}"');
|
||||
const copyIdx = restoreBody.indexOf('cp -p "$f" "$ICON_PATH/${f##*/}"');
|
||||
assert.ok(dropIdx >= 0, "must drop the files this apply introduced");
|
||||
assert.ok(dropIdx < copyIdx, "dropping must come before copying back");
|
||||
});
|
||||
|
||||
test("rpcd script: worker posts a download count after a successful apply", () => {
|
||||
assert.match(rpcd, /\/api\/v1\/themes\/aurora\/configs\/.*\/download/);
|
||||
assert.match(rpcd, /device_hash/);
|
||||
assert.match(rpcd, /hub_http_post/);
|
||||
});
|
||||
|
||||
test("rpcd script: bad payload is rejected before any uci write (validator returns non-zero)", () => {
|
||||
assert.match(rpcd, /bad_payload/);
|
||||
});
|
||||
|
||||
// --- FINAL-REVIEW: hub_http_get returns the hub config body FLAT (no "data"
|
||||
// wrapper -- that envelope is the frontend's, added by hubFetch in
|
||||
// utils/hub-api.js). hub_apply_worker and hub_my_shares call hub_http_get
|
||||
// directly, so a `json_select data` there always fails, meaning every
|
||||
// hub_apply used to report bad_payload unconditionally. ---
|
||||
|
||||
test("rpcd script: hub_apply_worker never selects a 'data' wrapper off the raw hub_http_get body", () => {
|
||||
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.ok(!/json_select\s+data/.test(workerBody), "hub_http_get's own response has no 'data' wrapper to select");
|
||||
// validate_and_apply_hub_payload now takes the whole flat body directly,
|
||||
// and does its own json_load/json_select internally instead of the
|
||||
// caller pre-extracting + re-json_dump'ing a "payload" subtree (json_dump
|
||||
// always dumps from the jshn root, ignoring any prior json_select, so the
|
||||
// old payload_json=$(json_dump)-after-select was silently dumping the
|
||||
// wrong subtree).
|
||||
assert.match(workerBody, /validate_and_apply_hub_payload\s+"\$body"/);
|
||||
});
|
||||
|
||||
test("rpcd script: validate_and_apply_hub_payload reads the flat body directly (json_load + json_select payload internally, no re-dump-after-select)", () => {
|
||||
const validateBody = extractFunctionBody(rpcd, "validate_and_apply_hub_payload");
|
||||
assert.match(validateBody, /json_load\s+"\$body_json"/);
|
||||
assert.match(validateBody, /json_get_var\s+hub_name\s+"name"/);
|
||||
assert.match(validateBody, /json_select\s+payload\s+2>\/dev\/null/);
|
||||
assert.ok(!/json_dump/.test(validateBody), "must navigate the loaded tree in place, never re-dump a subtree");
|
||||
});
|
||||
|
||||
test("rpcd script: hub_apply_worker selects assets at the flat body's root (not under data or payload)", () => {
|
||||
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.match(workerBody, /json_load\s+"\$body"\s+2>\/dev\/null\s*\n\s*if json_select assets 2>\/dev\/null; then/);
|
||||
});
|
||||
|
||||
// A shared configuration names a font preset this router has very likely never
|
||||
// downloaded. sync_font_css_from_uci only rewrites the CSS from whatever woff2
|
||||
// files are already cached, and every font stack ends in a built-in family, so
|
||||
// the missing webfont never fails loudly -- it silently renders as Lato and the
|
||||
// applied theme looks like only its colours arrived. Every path that swaps the
|
||||
// whole config for one from elsewhere has to fetch the files too.
|
||||
test("rpcd script: config-replacing paths cache the fonts they name, not just rewrite the CSS", () => {
|
||||
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.match(workerBody, /^\s*sync_and_cache_fonts_from_uci\s*$/m);
|
||||
assert.ok(
|
||||
!/^\s*sync_font_css_from_uci\s*$/m.test(workerBody),
|
||||
"hub_apply_worker must not stop at the cache-only resync",
|
||||
);
|
||||
|
||||
// import_config and hub_restore_backup replace the config the same way:
|
||||
// an imported file comes from another device, and a rollback target's font
|
||||
// cache may already have been cleaned up by the apply it is undoing.
|
||||
["import_config", "hub_restore_backup"].forEach((handler) => {
|
||||
const start = rpcd.indexOf(`"${handler}")`);
|
||||
assert.ok(start >= 0, `${handler} handler should exist`);
|
||||
const branch = rpcd.slice(start, start + 1200);
|
||||
assert.match(
|
||||
branch,
|
||||
/sync_and_cache_fonts_from_uci/,
|
||||
`${handler} must fetch the fonts its new config names`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("acl: hub_apply and hub_restore_backup granted under write.ubus, get_hub_status under read.ubus", () => {
|
||||
const acljson = JSON.parse(acl);
|
||||
const writeMethods = acljson["luci-app-aurora"].write.ubus["luci.aurora"];
|
||||
const readMethods = acljson["luci-app-aurora"].read.ubus["luci.aurora"];
|
||||
assert.ok(writeMethods.includes("hub_apply"));
|
||||
assert.ok(writeMethods.includes("hub_restore_backup"));
|
||||
assert.ok(readMethods.includes("get_hub_status"));
|
||||
});
|
||||
|
||||
test("acl: hub job paths are granted", () => {
|
||||
const acljson = JSON.parse(acl);
|
||||
const writeFiles = acljson["luci-app-aurora"].write.file;
|
||||
const readFiles = acljson["luci-app-aurora"].read.file;
|
||||
assert.ok(Object.keys(writeFiles).some((p) => p.startsWith("/tmp/aurora_hub_jobs")));
|
||||
assert.ok(Object.keys(readFiles).some((p) => p.startsWith("/tmp/aurora_hub_jobs")));
|
||||
});
|
||||
|
||||
// --- FINAL-REVIEW fix wave ---
|
||||
|
||||
test("acl: /etc/aurora is NOT file-ACL granted (rpcd handlers run as root and never need it; granting it would let browser JS read device.key via fs.read)", () => {
|
||||
const acljson = JSON.parse(acl);
|
||||
const writeFiles = Object.keys(acljson["luci-app-aurora"].write.file);
|
||||
const readFiles = Object.keys(acljson["luci-app-aurora"].read.file);
|
||||
assert.ok(!writeFiles.some((p) => p.startsWith("/etc/aurora")), "write.file must not grant /etc/aurora/*");
|
||||
assert.ok(!readFiles.some((p) => p.startsWith("/etc/aurora")), "read.file must not grant /etc/aurora/*");
|
||||
});
|
||||
|
||||
test("acl: the fixed /tmp/aurora_hub_share.json path is gone (share/update now use mktemp)", () => {
|
||||
const acljson = JSON.parse(acl);
|
||||
const writeFiles = Object.keys(acljson["luci-app-aurora"].write.file);
|
||||
assert.ok(!writeFiles.includes("/tmp/aurora_hub_share.json"));
|
||||
assert.ok(!rpcd.includes("/tmp/aurora_hub_share.json"), "script must no longer reference the fixed share-body path");
|
||||
});
|
||||
|
||||
test("rpcd script: the publish calls write their request body via mktemp, not a fixed path", () => {
|
||||
const beginMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t\t;;/);
|
||||
const commitMatch = rpcd.match(/"hub_share_commit"\)([\s\S]*?)\n\t\t;;/);
|
||||
assert.ok(beginMatch && commitMatch);
|
||||
assert.match(beginMatch[1], /draft_body_tmp=\$\(mktemp\)/);
|
||||
assert.match(commitMatch[1], /commit_body_tmp=\$\(mktemp\)/);
|
||||
});
|
||||
|
||||
test("sysupgrade: device identity files are preserved across firmware upgrades", () => {
|
||||
const sysupgradeFile = readFileSync(
|
||||
path.join(repoRoot, "root/etc/sysupgrade.conf.d/aurora-device"),
|
||||
"utf8",
|
||||
);
|
||||
const lines = sysupgradeFile.trim().split("\n").map((l) => l.trim());
|
||||
assert.ok(lines.includes("/etc/aurora/device.key"));
|
||||
assert.ok(lines.includes("/etc/aurora/device.hash"));
|
||||
});
|
||||
|
||||
// --- Security review round 1: embedded-newline bypass + unchecked uci batch ---
|
||||
|
||||
test("rpcd script: has_control_char helper rejects embedded control characters (incl. newline)", () => {
|
||||
assert.match(rpcd, /has_control_char\(\)\s*\{/);
|
||||
assert.match(rpcd, /\*\[\[:cntrl:\]\]\*/);
|
||||
});
|
||||
|
||||
test("rpcd script: every free-text field is guarded by has_control_char before its regex/case check", () => {
|
||||
// colors, the three rem fields, struct_font_sans/mono, toolbar url/icon,
|
||||
// and the server-supplied hub name must all be rejected outright on any
|
||||
// control character (never silently stripped) before the format regex
|
||||
// runs -- grep/case anchors are line-based, not whole-string, so a value
|
||||
// with an embedded newline can smuggle a second uci-batch command past a
|
||||
// regex/case check alone.
|
||||
const guardCount = (rpcd.match(/has_control_char\s+"\$\w+"\s*&&/g) || []).length;
|
||||
assert.ok(
|
||||
guardCount >= 8,
|
||||
`expected at least 8 has_control_char guard call sites, found ${guardCount}`,
|
||||
);
|
||||
// hub_name is guarded by has_batch_unsafe_char (control chars OR a literal
|
||||
// quote -- see the round-4 test below), a strict superset of has_control_char.
|
||||
assert.match(rpcd, /has_batch_unsafe_char\s+"\$hub_name"\s*&&/);
|
||||
});
|
||||
|
||||
test("rpcd script: uci batch exit status is checked (not just the later uci commit)", () => {
|
||||
// 允许 `>/dev/null`(见 rpcd-toolbar-handoff 那条 stdout 卫生测试):重定向
|
||||
// 只吞 uci batch 自己的 stdout,管道退出码依然是它的。
|
||||
assert.match(rpcd, /if\s+!\s*\{[\s\S]*?\}\s*\|\s*uci batch(\s+>\s*\/dev\/null)?;\s*then/);
|
||||
});
|
||||
|
||||
// --- Security review round 4: uci batch always exits 0, and a literal
|
||||
// single quote (legal per the hub's own struct_font_*/title regexes) breaks
|
||||
// the single-quoted `set foo='value'` batch line, silently dropping that
|
||||
// field instead of failing the apply. ---
|
||||
|
||||
test("rpcd script: has_batch_unsafe_char rejects control chars and a literal single quote", () => {
|
||||
assert.match(rpcd, /has_batch_unsafe_char\(\)\s*\{/);
|
||||
assert.match(rpcd, /has_batch_unsafe_char\(\)\s*\{\s*\n\s*has_control_char "\$1" && return 0\s*\n\s*case "\$1" in \*\\'\*\) return 0 ;; esac/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_applied name, struct_font_sans/mono, and toolbar title all reject a literal single quote before entering the uci batch", () => {
|
||||
assert.match(rpcd, /has_batch_unsafe_char\s+"\$hub_name"\s*&&/);
|
||||
const validateBody = extractFunctionBody(rpcd, "validate_and_apply_hub_payload");
|
||||
assert.match(validateBody, /case "\$struct_font_sans" in \*\\'\*\) rm -f "\$batch_file"; return 1 ;; esac/);
|
||||
assert.match(validateBody, /case "\$struct_font_mono" in \*\\'\*\) rm -f "\$batch_file"; return 1 ;; esac/);
|
||||
assert.match(validateBody, /case "\$trimmed_title" in\s*\n\s*\*\\'\*\) json_select \.\.; rm -f "\$batch_file"; return 1 ;;/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload and is_valid_font_stack also reject a literal single quote, for consistency with the apply side", () => {
|
||||
assert.match(rpcd, /is_valid_font_stack\(\)\s*\{[\s\S]*?case "\$v" in \*\\'\*\) return 1 ;; esac/);
|
||||
const itemBody = extractFunctionBody(rpcd, "toolbar_item_shareable");
|
||||
assert.match(itemBody, /case "\$trimmed_title" in \*\\'\*\) return 1 ;; esac/);
|
||||
});
|
||||
|
||||
test("rpcd script: require_color_token_keys guards colors validation", () => {
|
||||
assert.match(rpcd, /require_color_token_keys\s*\|\|\s*return 1/);
|
||||
});
|
||||
|
||||
// --- Security review round 2: the asset sha256 field had the same bypass ---
|
||||
|
||||
test("rpcd script: asset sha256 is guarded against control characters before its hex64 check", () => {
|
||||
// The sha256 field feeds hub_asset_target_name's login_bg filename, so it
|
||||
// needs the same has_control_char guard as every other free-text field --
|
||||
// `grep '^[0-9a-fA-F]{64}$'` alone is line-anchored, not whole-string
|
||||
// anchored, so "64 hex chars\nrm -rf /" would otherwise still satisfy it
|
||||
// on the first line.
|
||||
assert.match(
|
||||
rpcd,
|
||||
/!\s*has_control_char\s+"\$a_sha256"[\s\S]{0,80}grep -Eq '\^\[0-9a-fA-F\]\{64\}\$'/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Task 7: share backend (build payload + share/my_shares/update/delete) ---
|
||||
|
||||
test("rpcd script: hub_http_put and hub_http_delete mirror hub_http_post", () => {
|
||||
assert.match(rpcd, /hub_http_put\(\)\s*\{/);
|
||||
assert.match(rpcd, /hub_http_delete\(\)\s*\{/);
|
||||
assert.match(rpcd, /--method=PUT/);
|
||||
assert.match(rpcd, /--method=DELETE/);
|
||||
});
|
||||
|
||||
test("rpcd script: resolve_font_preset_id reverse-maps struct_font_<slot> via font-presets.conf", () => {
|
||||
assert.match(rpcd, /resolve_font_preset_id\(\)\s*\{/);
|
||||
assert.match(
|
||||
rpcd,
|
||||
/awk -F'\|' -v s="\$slot" -v k="\$stack" '\$1=="font"&&\$2==s&&\$7==k\s*\{\s*print \$3;\s*exit\s*\}'/,
|
||||
);
|
||||
assert.match(rpcd, /echo "default"|printf '%s' "default"/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload is defined and writes schema/theme", () => {
|
||||
assert.match(rpcd, /build_share_payload\(\)\s*\{/);
|
||||
assert.match(rpcd, /json_add_int "schema" 1/);
|
||||
assert.match(rpcd, /json_add_string "theme" "aurora"/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload assembles colors/layout/typography from uci", () => {
|
||||
assert.match(rpcd, /json_add_object "colors"/);
|
||||
assert.match(rpcd, /json_add_object "layout"/);
|
||||
assert.match(rpcd, /json_add_object "typography"/);
|
||||
assert.match(rpcd, /resolve_font_preset_id sans/);
|
||||
assert.match(rpcd, /resolve_font_preset_id mono/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload assembles the toolbar from anonymous toolbar_item sections", () => {
|
||||
assert.ok(
|
||||
rpcd.includes(
|
||||
String.raw`uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("rpcd script: toolbar url filtering rejects protocol-relative urls but allows http(s)/relative", () => {
|
||||
const itemBody = extractFunctionBody(rpcd, "toolbar_item_shareable");
|
||||
assert.match(itemBody, /\/\/\*\)\s*return 1/);
|
||||
assert.match(itemBody, /http:\/\/\*\|https:\/\/\*\|\/\*\)/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload skips filenames the theme itself ships", () => {
|
||||
const body = extractFunctionBody(rpcd, "is_theme_shipped_image");
|
||||
assert.match(body, /logo\.svg\|favicon\.ico\|app-icon-192x192\.png\|app-icon-512x512\.png\|apple-touch-icon\.png/);
|
||||
// 默认工具栏那四个也算主题自带:接收方的机器上本来就有,发过去只是浪费,
|
||||
// 还会让对方用作者那份副本而不是自己已经装好的那份。
|
||||
assert.match(body, /network\.svg\|overview\.svg\|software\.svg\|system\.svg/);
|
||||
assert.match(extractFunctionBody(rpcd, "local_image_name"), /is_theme_shipped_image "\$fname" && return 1/);
|
||||
});
|
||||
|
||||
// sha256/size 仍然由路由器算 —— 这是浏览器直传能安全的关键:hub 校验浏览器
|
||||
// 送上来的字节 hash 等于路由器声明的 hash,所以一个被篡改的页面换不掉这张图。
|
||||
// base64 那一步没了(见 "emits a filename sidecar" 一测)。
|
||||
test("rpcd script: build_share_payload computes sha256/size, busybox-safe", () => {
|
||||
assert.match(rpcd, /sha256sum "\$file" 2>\/dev\/null \| cut -d' ' -f1/);
|
||||
assert.match(rpcd, /wc -c < "\$file"/);
|
||||
});
|
||||
|
||||
// hub_share/hub_update 是单请求的老路:它们把整张登录背景 base64 塞进一个
|
||||
// 1.6MB 的 body,而 uclient-fetch 走 TLS 推不动 —— 任何设了登录背景的人都
|
||||
// 发布不出去。三段式取代了它们,这里钉住"不许回来"。
|
||||
test("rpcd script: hub_share_begin, hub_share_commit, hub_me, hub_delete handlers exist", () => {
|
||||
assert.ok(rpcd.includes('"hub_share_begin")'));
|
||||
assert.ok(rpcd.includes('"hub_share_commit")'));
|
||||
assert.ok(rpcd.includes('"hub_me")'));
|
||||
assert.ok(rpcd.includes('"hub_delete")'));
|
||||
assert.ok(!rpcd.includes('"hub_share")'), "the single-request publish is gone");
|
||||
assert.ok(!rpcd.includes('"hub_update")'), "the single-request update is gone");
|
||||
});
|
||||
|
||||
test("rpcd script: the hub methods are registered in the list branch", () => {
|
||||
assert.ok(rpcd.includes('json_add_object "hub_share_begin"'));
|
||||
assert.ok(rpcd.includes('json_add_object "hub_share_commit"'));
|
||||
assert.ok(rpcd.includes('json_add_object "hub_me"'));
|
||||
assert.ok(rpcd.includes('json_add_object "hub_delete"'));
|
||||
});
|
||||
|
||||
test("rpcd script: hub_share_begin validates name/description with has_control_char before use", () => {
|
||||
const shareBranchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(shareBranchMatch, "hub_share_begin branch should exist");
|
||||
const body = shareBranchMatch[1];
|
||||
assert.match(body, /has_control_char\s+"\$name"/);
|
||||
assert.match(body, /has_control_char\s+"\$description"/);
|
||||
// The signature is not a publish field any more; hub_set_nickname owns it.
|
||||
assert.ok(!/\$author/.test(body), "publishing must not handle an author");
|
||||
});
|
||||
|
||||
test("rpcd script: hub_share_begin ensures device identity, builds payload, and opens a draft", () => {
|
||||
const shareBranchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(shareBranchMatch);
|
||||
const body = shareBranchMatch[1];
|
||||
assert.match(body, /ensure_device_identity/);
|
||||
assert.match(body, /build_share_payload/);
|
||||
assert.match(body, /device_token/);
|
||||
assert.match(body, /hub_http_post\s+"\/api\/v1\/themes\/aurora\/configs\/draft"/);
|
||||
// Nothing is recorded locally: a published id is the hub's to remember, and
|
||||
// a local copy would survive a key import it no longer belongs to.
|
||||
assert.ok(!/hub_shares/.test(body), "publishing must not keep a local id list");
|
||||
});
|
||||
|
||||
// The old hub_my_shares walked a local id file and issued one hub request per
|
||||
// published config on every page load, with an elaborate rule that the file
|
||||
// must never be rewritten there (a transient failure was indistinguishable
|
||||
// from a 404, and dropping an id would have been unrecoverable). hub_me
|
||||
// deletes that whole problem: the hub answers with the config list directly,
|
||||
// so there is no local record to keep, lose, or wrongly prune.
|
||||
test("rpcd script: hub_me asks the hub once and keeps no local list", () => {
|
||||
const branchMatch = rpcd.match(/"hub_me"\)([\s\S]*?)\n\t\t;;/);
|
||||
assert.ok(branchMatch, "hub_me branch should exist");
|
||||
const body = branchMatch[1];
|
||||
assert.match(body, /ensure_device_identity/);
|
||||
assert.match(body, /device_token/);
|
||||
assert.match(body, /hub_http_post\s+"\/api\/v1\/me"/);
|
||||
assert.ok(!/hub_shares/.test(body), "hub_me must not consult a local id list");
|
||||
assert.ok(
|
||||
!/while\s+IFS=/.test(body),
|
||||
"hub_me must be a single request, not a per-id loop",
|
||||
);
|
||||
});
|
||||
|
||||
// 更新不再有自己的方法:它就是带 target_id 的发布。这条钉住 target_id 的
|
||||
// 字符集守卫 —— 注意不能写成 ''|*[A-Za-z0-9]*),那个通配只要求"含有"一个
|
||||
// 字母数字,"ab/../x" 也会过。
|
||||
test("rpcd script: hub_share_begin guards target_id charset and forwards it", () => {
|
||||
const branchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(branchMatch);
|
||||
const body = branchMatch[1];
|
||||
assert.match(body, /\*\[!A-Za-z0-9\]\*\) echo '\{ "result": 1, "error": "invalid_id" \}'/);
|
||||
assert.match(body, /"target_id":"%s"/);
|
||||
assert.match(body, /build_share_payload/);
|
||||
assert.match(body, /device_token/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_delete guards id charset and DELETEs with device_token", () => {
|
||||
const branchMatch = rpcd.match(/"hub_delete"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(branchMatch);
|
||||
const body = branchMatch[1];
|
||||
assert.match(body, /case "\$id" in ''\|\*\[!A-Za-z0-9\]\*\)/);
|
||||
assert.match(body, /hub_http_delete\s+"\/api\/v1\/themes\/aurora\/configs\/\$id"/);
|
||||
assert.match(body, /device_token/);
|
||||
// No local list to prune any more -- hub_me re-reads the truth.
|
||||
assert.ok(!/hub_shares/.test(body), "hub_delete must not maintain a local id list");
|
||||
});
|
||||
|
||||
test("acl: the publish calls are granted under write.ubus; hub_me under read.ubus", () => {
|
||||
const acljson = JSON.parse(acl);
|
||||
const writeMethods = acljson["luci-app-aurora"].write.ubus["luci.aurora"];
|
||||
const readMethods = acljson["luci-app-aurora"].read.ubus["luci.aurora"];
|
||||
assert.ok(writeMethods.includes("hub_share_begin"));
|
||||
assert.ok(writeMethods.includes("hub_share_commit"));
|
||||
assert.ok(writeMethods.includes("hub_delete"));
|
||||
assert.ok(readMethods.includes("hub_me"));
|
||||
// 老方法必须一并从 ACL 撤掉,否则一个被删掉的入口还挂在权限表上。
|
||||
assert.ok(!writeMethods.includes("hub_share"));
|
||||
assert.ok(!writeMethods.includes("hub_update"));
|
||||
});
|
||||
|
||||
// Note: the fixed /tmp/aurora_hub_share.json path was removed (see the
|
||||
// "acl: the fixed /tmp/aurora_hub_share.json path is gone" test above) --
|
||||
// the publish calls write their request bodies via mktemp instead.
|
||||
|
||||
// --- Security review round 3: asset path traversal + un-enforced hub schema ---
|
||||
|
||||
function extractFunctionBody(source, name) {
|
||||
const marker = `${name}() {`;
|
||||
const start = source.indexOf(marker);
|
||||
assert.ok(start >= 0, `${name}() should be defined`);
|
||||
let depth = 0;
|
||||
let i = start + marker.length - 1; // index of the opening brace
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return source.slice(start, i + 1);
|
||||
}
|
||||
|
||||
test("rpcd script: local_image_name rejects filenames containing '/' or '..' before ICON_PATH is joined", () => {
|
||||
const body = extractFunctionBody(rpcd, "local_image_name");
|
||||
// Guard must appear (and reject) before `$ICON_PATH/$fname` is ever built.
|
||||
const guardIdx = body.search(/case "\$fname" in \*\/\*\|\*\.\.\*\) return 1 ;; esac/);
|
||||
const fileIdx = body.indexOf('[ -f "$ICON_PATH/$fname" ]');
|
||||
assert.ok(guardIdx >= 0, "path-traversal guard on $fname should exist");
|
||||
assert.ok(fileIdx > guardIdx, "guard must run before ICON_PATH is joined");
|
||||
// build_share_payload must not have grown a second way to reach ICON_PATH.
|
||||
const shareBody = extractFunctionBody(rpcd, "build_share_payload");
|
||||
assert.match(shareBody, /fname=\$\(local_image_name "\$kind"\) \|\| continue/);
|
||||
});
|
||||
|
||||
test("rpcd script: toolbar_item_shareable validates each item against the hub's field limits", () => {
|
||||
const body = extractFunctionBody(rpcd, "toolbar_item_shareable");
|
||||
// title: control-char-trimmed then 1-30. strip_control_chars, never
|
||||
// `tr -d '[:cntrl:]'` -- BusyBox's tr reads that as the literal set
|
||||
// { [ : c n t r l } and eats those letters out of the title.
|
||||
assert.match(body, /trimmed_title=\$\(strip_control_chars "\$title"\)/);
|
||||
assert.match(body, /tlen=\$\{#trimmed_title\}/);
|
||||
assert.match(body, /\[ "\$tlen" -ge 1 \] && \[ "\$tlen" -le 30 \] \|\| return 1/);
|
||||
// url: control-char guard, scheme guard, length cap
|
||||
assert.match(body, /has_control_char "\$url" && return 1/);
|
||||
assert.match(body, /\[ "\$\{#url\}" -le 200 \] \|\| return 1/);
|
||||
// icon: control-char guard, length cap, charset
|
||||
assert.match(body, /has_control_char "\$icon" && return 1/);
|
||||
assert.match(body, /\[ "\$\{#icon\}" -le 64 \] \|\| return 1/);
|
||||
assert.match(body, /grep -Eq '\^\[A-Za-z0-9\._-\]\+\$' \|\| return 1/);
|
||||
// enabled: enum guard
|
||||
assert.match(body, /case "\$enabled" in 0\|1\) ;; \*\) return 1 ;; esac/);
|
||||
// 两个消费者,一份规则。分开判就会让一条被打包跳过的快捷方式仍然占掉一个
|
||||
// 图标槽位号,接收端从那里往后全部错位。
|
||||
assert.match(extractFunctionBody(rpcd, "build_share_payload"), /toolbar_item_shareable "\$sid" \|\| continue/);
|
||||
assert.match(extractFunctionBody(rpcd, "nth_custom_toolbar_icon"), /toolbar_item_shareable "\$sid" \|\| continue/);
|
||||
});
|
||||
|
||||
test("rpcd script: build_share_payload falls back to a canonical font stack when struct_font_* fails the hub's regex", () => {
|
||||
assert.match(rpcd, /is_valid_font_stack\(\)\s*\{/);
|
||||
assert.match(rpcd, /font_preset_stack\(\)\s*\{/);
|
||||
const body = extractFunctionBody(rpcd, "build_share_payload");
|
||||
assert.match(body, /is_valid_font_stack "\$struct_font_sans"/);
|
||||
assert.match(body, /font_preset_stack sans "\$font_sans_id"/);
|
||||
assert.match(body, /font_preset_stack sans default/);
|
||||
assert.match(body, /is_valid_font_stack "\$struct_font_mono"/);
|
||||
assert.match(body, /font_preset_stack mono "\$font_mono_id"/);
|
||||
assert.match(body, /font_preset_stack mono default/);
|
||||
});
|
||||
|
||||
test("rpcd script: is_valid_font_stack enforces length, control-char, and charset rules", () => {
|
||||
assert.match(rpcd, /\[ "\$\{#v\}" -le 200 \] \|\| return 1/);
|
||||
assert.match(rpcd, /has_control_char "\$v" && return 1/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_me replaces hub_my_shares", () => {
|
||||
assert.ok(rpcd.includes('json_add_object "hub_me"'), "hub_me not registered");
|
||||
assert.ok(!rpcd.includes("hub_my_shares"), "hub_my_shares must be gone");
|
||||
assert.match(rpcd, /hub_http_post "\/api\/v1\/me"/);
|
||||
// The local id list is no longer authoritative -- the hub is. Keeping a
|
||||
// read of it would reintroduce the failure this whole change removes: a
|
||||
// reflashed router showing an empty "my shares" for work it still owns.
|
||||
assert.ok(
|
||||
!rpcd.includes("$DEVICE_DIR/hub_shares"),
|
||||
"hub_shares must no longer be read or written",
|
||||
);
|
||||
});
|
||||
|
||||
test("rpcd script: nickname is set through its own write method", () => {
|
||||
assert.ok(rpcd.includes('"hub_set_nickname")'), "hub_set_nickname handler missing");
|
||||
assert.match(rpcd, /json_add_string "nickname" "nickname"/);
|
||||
const branchMatch = rpcd.match(/"hub_set_nickname"\)([\s\S]*?)\n\t\t;;/);
|
||||
assert.ok(branchMatch, "hub_set_nickname branch should exist");
|
||||
const body = branchMatch[1];
|
||||
assert.match(body, /has_control_char\s+"\$nickname"/);
|
||||
assert.match(body, /hub_http_post\s+"\/api\/v1\/me"/);
|
||||
// The hub answers 200 even for "that name is taken" -- wget collapses every
|
||||
// 4xx into the same failure as an unreachable host -- so the body is passed
|
||||
// through for marketplace.js to turn into a sentence.
|
||||
assert.match(body, /"result": 0, "data": %s/);
|
||||
});
|
||||
|
||||
test("rpcd script: publishing no longer carries an author", () => {
|
||||
assert.ok(!rpcd.includes('json_get_var author "author"'), "author still read from the call");
|
||||
assert.ok(!rpcd.includes("invalid_author"), "stale author validation");
|
||||
assert.ok(!/"author":"%s"/.test(rpcd), "author still sent to the hub");
|
||||
});
|
||||
|
||||
test("rpcd script: key export/import exist and validate the shape", () => {
|
||||
assert.ok(rpcd.includes('"hub_export_key")'), "hub_export_key handler missing");
|
||||
assert.ok(rpcd.includes('"hub_import_key")'), "hub_import_key handler missing");
|
||||
// Same rule as the hub's TOKEN_PATTERN: exactly 64 lowercase hex.
|
||||
assert.match(rpcd, /\[ "\$\{#key\}" -eq 64 \]/);
|
||||
assert.match(rpcd, /\*\[!a-f0-9\]\*\)/);
|
||||
assert.match(rpcd, /chmod 600 "\$DEVICE_DIR\/device\.key\.tmp"/);
|
||||
// Both "the key now lives somewhere else" paths record the flag in rpcd:
|
||||
// marketplace.js is a browse-only view with no uci write path of its own.
|
||||
assert.ok(
|
||||
rpcd.split("hub_key_saved").length - 1 >= 2,
|
||||
"both export and import must record hub_key_saved",
|
||||
);
|
||||
});
|
||||
|
||||
test("acl: exporting the key is a write capability, never a read one", () => {
|
||||
const parsed = JSON.parse(acl);
|
||||
const { read, write } = parsed["luci-app-aurora"];
|
||||
const readable = read.ubus["luci.aurora"];
|
||||
const writable = write.ubus["luci.aurora"];
|
||||
// Handing out device.key hands out account control -- publishing as this
|
||||
// creator, and deleting their work. Read access must not reach it.
|
||||
assert.ok(!readable.includes("hub_export_key"), "hub_export_key must not be readable");
|
||||
assert.ok(writable.includes("hub_export_key"), "hub_export_key missing from write");
|
||||
assert.ok(writable.includes("hub_import_key"), "hub_import_key missing from write");
|
||||
});
|
||||
|
||||
test("acl: hub_me is readable and the stale hub_my_shares entry is gone", () => {
|
||||
const parsed = JSON.parse(acl);
|
||||
const read = parsed["luci-app-aurora"].read.ubus["luci.aurora"];
|
||||
assert.ok(read.includes("hub_me"), "hub_me missing from read");
|
||||
assert.ok(!read.includes("hub_my_shares"), "stale hub_my_shares in acl");
|
||||
});
|
||||
|
||||
// --- A share the hub has already dropped is not an unreachable hub ---
|
||||
//
|
||||
// Measured on the device against the live hub: DELETE on an id the hub has
|
||||
// already removed answers 404 -- uclient-fetch exits 8 and prints
|
||||
// "HTTP error 404" on stderr; a host it cannot reach exits 4 and prints
|
||||
// "Failed to send request". `-q` silences both lines, and the exit code alone
|
||||
// lumps every error status together, so the helpers could only ever report
|
||||
// hub_unreachable -- telling a user the store is down when their share is
|
||||
// simply gone.
|
||||
|
||||
test("rpcd script: hub_http_gone reads the 404 off uclient-fetch's stderr", () => {
|
||||
const body = extractFunctionBody(rpcd, "hub_http_gone");
|
||||
assert.match(body, /HTTP error 404/, "the status line is the only 404 signal");
|
||||
});
|
||||
|
||||
for (const fn of ["hub_http_put", "hub_http_delete"]) {
|
||||
test(`rpcd script: ${fn} tells a share the hub dropped apart from an unreachable hub`, () => {
|
||||
const body = extractFunctionBody(rpcd, fn);
|
||||
assert.ok(!/wget -qO/.test(body), "-q swallows the line that names the status");
|
||||
assert.match(body, /2>"\$err"/, "stderr must be kept, not discarded");
|
||||
assert.match(body, /hub_http_gone "\$err"/, "the status line must be consulted");
|
||||
assert.match(body, /rc=2/, "a dropped share needs a return code of its own");
|
||||
});
|
||||
}
|
||||
|
||||
test("rpcd script: hub_delete answers invalid_id, not hub_unreachable, when the share is gone", () => {
|
||||
const branchMatch = rpcd.match(/"hub_delete"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(branchMatch);
|
||||
const body = branchMatch[1];
|
||||
assert.match(
|
||||
body,
|
||||
/\n\t\t\t2\)[\s\S]{0,400}?"error": "invalid_id"/,
|
||||
"return code 2 must map to invalid_id",
|
||||
);
|
||||
assert.match(body, /\*\)[\s\S]{0,200}?"error": "hub_unreachable"/);
|
||||
});
|
||||
|
||||
test("rpcd script: hub_share_commit answers invalid_id, not hub_unreachable, when the draft is gone", () => {
|
||||
// Sliced to the next branch, not by the `\n\t;;` the other tests use: that
|
||||
// one is lazy but the nearest single-tab `;;` is the end of the outer case,
|
||||
// so it swallows the following branch and would go green on its fix alone.
|
||||
const start = rpcd.indexOf('"hub_share_commit")');
|
||||
const end = rpcd.indexOf('"hub_me")', start);
|
||||
assert.ok(start > 0 && end > start, "hub_share_commit branch not found");
|
||||
const body = rpcd.slice(start, end);
|
||||
assert.match(
|
||||
body,
|
||||
/\n\t\t\t2\)[\s\S]{0,400}?"error": "invalid_id"/,
|
||||
"return code 2 must map to invalid_id",
|
||||
);
|
||||
});
|
||||
|
||||
// --- The same 404 blindness on the read path ---
|
||||
//
|
||||
// hub_http_get goes through fetch_url, which is shared with asset downloads
|
||||
// and is -q for their sake. So applying a theme the store had already taken
|
||||
// down reported hub_unreachable too, and the store page told the user to check
|
||||
// their connection over a theme that was simply gone.
|
||||
|
||||
test("rpcd script: fetch_url stays quiet unless the caller asks to keep stderr", () => {
|
||||
const body = extractFunctionBody(rpcd, "fetch_url");
|
||||
assert.match(body, /FETCH_URL_ERR/, "callers need a way to opt into stderr");
|
||||
// The other caller is the asset download loop; it must not start spraying
|
||||
// uclient-fetch progress meters into rpcd's stderr.
|
||||
assert.match(body, /\/dev\/null/, "discarding stderr stays the default");
|
||||
assert.match(body, /-q/, "quiet stays the default");
|
||||
});
|
||||
|
||||
test("rpcd script: hub_http_get tells a theme the store dropped apart from an unreachable hub", () => {
|
||||
const body = extractFunctionBody(rpcd, "hub_http_get");
|
||||
assert.match(body, /FETCH_URL_ERR/, "stderr must be kept for this one call");
|
||||
assert.match(body, /hub_http_gone "\$err"/, "the status line must be consulted");
|
||||
assert.match(body, /rc=2/, "a dropped theme needs a return code of its own");
|
||||
});
|
||||
|
||||
test("rpcd script: the apply worker reports invalid_id when the store no longer has the theme", () => {
|
||||
const body = extractFunctionBody(rpcd, "hub_apply_worker");
|
||||
assert.match(
|
||||
body,
|
||||
/"error" "fetch" "invalid_id"/,
|
||||
"a 404 on fetch must not be reported as hub_unreachable",
|
||||
);
|
||||
assert.match(body, /"error" "fetch" "hub_unreachable"/, "the network case must survive");
|
||||
});
|
||||
|
||||
// §8.2 drifted silently: it still listed hub_list, hub_get and hub_my_shares
|
||||
// long after they were deleted, and carried an `author` param that hub_share
|
||||
// and hub_update never took. Nothing checked it, so nothing stopped it. This
|
||||
// pins the table to the acl -- the one file that decides what is callable.
|
||||
test("docs: the marketplace method table stays in step with the acl", () => {
|
||||
const docs = readFileSync(path.join(repoRoot, "docs/DEVELOPMENT.md"), "utf8");
|
||||
const section = docs.slice(docs.indexOf("### 8.2 "), docs.indexOf("### 8.3 "));
|
||||
const documented = [...section.matchAll(/^\| `([a-z_]+)/gm)]
|
||||
.map((m) => m[1])
|
||||
.sort();
|
||||
const parsed = JSON.parse(acl)["luci-app-aurora"];
|
||||
const exposed = [
|
||||
...parsed.read.ubus["luci.aurora"],
|
||||
...parsed.write.ubus["luci.aurora"],
|
||||
]
|
||||
.filter((m) => /^(hub_|get_hub_)/.test(m))
|
||||
.sort();
|
||||
assert.deepEqual(documented, exposed);
|
||||
});
|
||||
|
||||
// 浏览器直传之后,路由器不再需要把 1.2MB 的图 base64 塞进 JSON —— 实测
|
||||
// uclient-fetch 走 TLS 推不动那么大的 body(512KB 三次挂一次,1MB 必断)。
|
||||
// 它只需要告诉浏览器每个 kind 对应哪个本机文件。
|
||||
//
|
||||
// 第二列是相对 /luci-static/aurora/ 的路径而不是裸文件名:图片在 images/,
|
||||
// 字体在 fonts/custom/,而 hub_share_begin 只会给这一列加前缀。
|
||||
test("build_share_payload: emits a path sidecar, not base64", () => {
|
||||
assert.match(rpcd, /local out_file="\$1" files_file="\$\{1\}\.files"/);
|
||||
assert.match(rpcd, /printf '%s\\timages\/%s\\n' "\$kind" "\$fname" >> "\$files_file"/);
|
||||
assert.match(
|
||||
rpcd,
|
||||
/printf '%s\\tfonts\/custom\/%s\.woff2\\n' "font_\$slot" "\$base" >> "\$files_file"/,
|
||||
);
|
||||
assert.match(rpcd, /json_add_string "src" "\/luci-static\/aurora\/\$a_fname"/);
|
||||
assert.ok(!/base64 -w0/.test(rpcd), "base64 encoding should be gone");
|
||||
assert.ok(!/data_b64/.test(rpcd), "data_b64 should be gone from the rpcd script");
|
||||
});
|
||||
|
||||
// 分享一份用了自定义字体的配置,却只发字体栈不发字节 —— 收到的人那台路由器上
|
||||
// 根本没有那个字体族,浏览器静默回退到栈尾的 Lato:配色布局全对、字体没变,而
|
||||
// 商店详情页还明明白白写着那个字体名。真机上抓到过这个现场(struct_font_sans
|
||||
// 指着 "OPPO Sans Regular",aurora-font.css 里根本没有它的 @font-face)。
|
||||
test("build_share_payload: uploaded custom fonts travel with the config", () => {
|
||||
assert.match(rpcd, /json_add_string "kind" "font_\$slot"/);
|
||||
// 名册预设不上传:接收端 sync_and_cache_fonts_from_uci 自己从固定源下载
|
||||
// 并校验,再传一份既浪费带宽也绕开了那份校验。
|
||||
assert.match(
|
||||
rpcd,
|
||||
/shared_custom_font_base\(\)[\s\S]*?get_font_preset_by_stack "\$slot" "\$stack" >\/dev\/null 2>&1 && return 1/,
|
||||
);
|
||||
});
|
||||
|
||||
// 界面上说要发哪一份字体,和线路上真正打包哪一份,必须是同一个判断 —— 两处
|
||||
// 各写一遍迟早漂移成"清单里写着、实际没发"。
|
||||
test("the share manifest and the packer ask the same function", () => {
|
||||
const callers = rpcd.match(/shared_custom_font_base "\$slot"/g) || [];
|
||||
assert.ok(
|
||||
callers.length >= 2,
|
||||
"both build_share_payload and hub_local_state should call it",
|
||||
);
|
||||
assert.match(rpcd, /json_add_array "shared_fonts"/);
|
||||
});
|
||||
|
||||
// 一律报 hub_unreachable 是"发布失败看起来像商店挂了"的全部原因:-q 把
|
||||
// uclient-fetch 唯一会说出理由的那一行也吞了。hub_http_put/delete 一直会
|
||||
// 区分 404,post 只是没跟上。
|
||||
test("hub_http_post distinguishes a rejection from an outage", () => {
|
||||
assert.match(rpcd, /hub_http_post\(\)\s*\{[\s\S]*?2>"\$err"/);
|
||||
assert.match(rpcd, /HUB_HTTP_STATUS=/);
|
||||
assert.ok(
|
||||
!/wget -qO "\$out" --timeout=12/.test(rpcd),
|
||||
"-q swallowed the reason uclient-fetch prints",
|
||||
);
|
||||
});
|
||||
|
||||
// 三段式发布:路由器建草稿拿票据 -> 浏览器直传字节 -> 路由器提交。
|
||||
// 中间那段不经过路由器,因为 uclient-fetch 走 TLS 推不动大 body。
|
||||
test("rpcd exposes the three-step publish", () => {
|
||||
for (const m of ["hub_share_begin", "hub_share_commit"]) {
|
||||
assert.match(rpcd, new RegExp(`json_add_object "${m}"`), `${m} missing from list`);
|
||||
assert.ok(rpcd.includes(`"${m}")`), `${m} missing from call`);
|
||||
assert.ok(acl.includes(`"${m}"`), `${m} missing from ACL write list`);
|
||||
}
|
||||
});
|
||||
|
||||
test("hub_share_begin hands out local asset URLs, never the device token", () => {
|
||||
assert.match(rpcd, /\/luci-static\/aurora\/images\//);
|
||||
const begin = rpcd.slice(rpcd.indexOf('"hub_share_begin")'));
|
||||
const body = begin.slice(0, begin.indexOf("\n\t\t;;"));
|
||||
assert.match(body, /configs\/draft/);
|
||||
// 拼回复的那一段里不能出现 DEVICE_TOKEN
|
||||
const reply = body.slice(body.indexOf("json_add_array \"assets\""));
|
||||
assert.ok(!/DEVICE_TOKEN/.test(reply), "the device token must never reach the response");
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
|
||||
|
||||
// jshn 只存在于 OpenWrt 上,所以这里不 source 整个脚本,而是把待测函数切出来配桩跑。
|
||||
// 桩把每次调用打成一行,断言比对的是「发出了什么」,不是「源码长什么样」—— 这样才能
|
||||
// 抓住管道子 shell 那类陷阱:源码看着对,jshn 的写入却全丢在退出的子进程里。
|
||||
const STUBS = `
|
||||
json_add_array() { echo "ARRAY $1"; }
|
||||
json_add_string() { echo "NAME $2"; }
|
||||
json_close_array() { echo "/ARRAY"; }
|
||||
json_add_object() { echo "OBJ $1"; }
|
||||
json_add_int() { echo "SIZE $1=$2"; }
|
||||
json_close_object(){ echo "/OBJ"; }
|
||||
`;
|
||||
|
||||
async function runJsonAddIcons(iconPath) {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
const start = src.indexOf("json_add_icons() {");
|
||||
assert.ok(start !== -1, "json_add_icons not found");
|
||||
const end = src.indexOf("\n}\n", start) + 3;
|
||||
const script = `ICON_PATH="${iconPath}"\n${STUBS}\n${src.slice(start, end)}\njson_add_icons\n`;
|
||||
return execFileSync("sh", ["-c", script], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function fixture(files) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-icons-"));
|
||||
for (const [name, bytes] of Object.entries(files))
|
||||
writeFileSync(join(dir, name), "x".repeat(bytes));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("json_add_icons emits every file's name and byte size", async () => {
|
||||
// 文件名里可以有空格:receive_upload 只拒绝空串、含 / 和含 .. 的名字。
|
||||
const dir = fixture({ "favicon.ico": 3, "app icon.png": 7, "empty.svg": 0 });
|
||||
try {
|
||||
const out = await runJsonAddIcons(dir);
|
||||
const names = [...out.matchAll(/^NAME (.+)$/gm)].map((m) => m[1]).sort();
|
||||
assert.deepEqual(names, ["app icon.png", "empty.svg", "favicon.ico"]);
|
||||
const sizes = Object.fromEntries(
|
||||
[...out.matchAll(/^SIZE (.+)=\s*(\d+)$/gm)].map((m) => [m[1], Number(m[2])]),
|
||||
);
|
||||
assert.deepEqual(sizes, { "favicon.ico": 3, "app icon.png": 7, "empty.svg": 0 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("json_add_icons survives an empty directory, a missing one, and a lone file", async () => {
|
||||
const empty = mkdtempSync(join(tmpdir(), "aurora-icons-"));
|
||||
const one = fixture({ "a.png": 2 });
|
||||
const sub = fixture({ "b.png": 4 });
|
||||
mkdirSync(join(sub, "nested"));
|
||||
try {
|
||||
for (const dir of [empty, join(empty, "does-not-exist")]) {
|
||||
const out = await runJsonAddIcons(dir);
|
||||
assert.ok(!out.includes("NAME "), `${dir} should list nothing`);
|
||||
assert.ok(!out.includes("SIZE "), `${dir} should size nothing`);
|
||||
}
|
||||
// 单文件时 wc 不打印 total 行 —— 解析必须两种情况都对。
|
||||
assert.match(await runJsonAddIcons(one), /^SIZE a\.png=\s*2$/m);
|
||||
// 子目录既不进 icons 数组,也不进 icon_sizes。
|
||||
const out = await runJsonAddIcons(sub);
|
||||
assert.ok(!out.includes("nested"), "directories must not be listed as icons");
|
||||
} finally {
|
||||
for (const d of [empty, one, sub]) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("json_add_icons calls wc once for the whole directory, not once per file", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
const start = src.indexOf("json_add_icons() {");
|
||||
const body = src.slice(start, src.indexOf("\n}\n", start));
|
||||
assert.ok(
|
||||
!/wc -c < /.test(body),
|
||||
"per-file `wc -c < file` is one fork per icon on a device where forks are the cost",
|
||||
);
|
||||
assert.ok(
|
||||
!/\|\s*while /.test(body),
|
||||
"piping into while runs the loop in a subshell and every json_add_* write is lost",
|
||||
);
|
||||
});
|
||||
|
||||
test("the package manager is detected once per rpcd invocation", async () => {
|
||||
const src = await readFile(RPCD, "utf8");
|
||||
assert.ok(
|
||||
!/\$\(detect_package_manager\)/.test(src),
|
||||
"command substitution is a fork; callers must read $_pkg_manager instead",
|
||||
);
|
||||
assert.match(src, /_pkg_manager=""/, "the memo variable must exist at script scope");
|
||||
assert.match(
|
||||
src,
|
||||
/detect_package_manager\(\) \{\s*\n\s*\[ -n "\$_pkg_manager" \] && return/,
|
||||
"detect_package_manager must short-circuit on the memo",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
// 主界面背景(main_bg)与 login_bg 共用一条资产管线。可切片的纯函数走行为
|
||||
// 断言(同 rpcd-share-assets 的 slice 手法);与 jshn/网络纠缠的段落退化为
|
||||
// 文本断言——它们守的是"哪一行必须存在",不是排序或数值。
|
||||
|
||||
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
|
||||
|
||||
function slice(name) {
|
||||
const start = rpcd.indexOf(`${name}() {`);
|
||||
assert.ok(start !== -1, `${name} not found in rpcd script`);
|
||||
const end = rpcd.indexOf("\n}\n", start) + 3;
|
||||
return rpcd.slice(start, end);
|
||||
}
|
||||
|
||||
function runSh(script, env = {}) {
|
||||
return execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// is_valid_main_bg_value —— share 与 apply 两端共用的界内校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const boundsCases = [
|
||||
["struct_main_bg_alpha", "67%", true],
|
||||
["struct_main_bg_alpha", "50%", true],
|
||||
["struct_main_bg_alpha", "100%", true],
|
||||
["struct_main_bg_alpha", "49%", false],
|
||||
["struct_main_bg_alpha", "101%", false],
|
||||
["struct_main_bg_alpha", "67", false],
|
||||
["struct_main_bg_blur", "20px", true],
|
||||
["struct_main_bg_blur", "0px", true],
|
||||
["struct_main_bg_blur", "40px", true],
|
||||
["struct_main_bg_blur", "41px", false],
|
||||
["struct_main_bg_blur", "20%", false],
|
||||
["struct_main_bg_scrim", "20%", true],
|
||||
["struct_main_bg_scrim", "0%", true],
|
||||
["struct_main_bg_scrim", "70%", true],
|
||||
["struct_main_bg_scrim", "71%", false],
|
||||
["struct_main_bg_scrim", "-1%", false],
|
||||
["struct_login_bg_alpha", "100%", true],
|
||||
["struct_login_bg_alpha", "49%", false],
|
||||
["struct_login_bg_blur", "0px", true],
|
||||
["struct_login_bg_blur", "41px", false],
|
||||
["struct_login_bg_scrim", "0%", true],
|
||||
["struct_login_bg_scrim", "71%", false],
|
||||
];
|
||||
|
||||
test("is_valid_main_bg_value enforces the hub's bounds on both ends", () => {
|
||||
for (const [key, value, ok] of boundsCases) {
|
||||
const out = runSh(
|
||||
slice("is_valid_main_bg_value") +
|
||||
`\nis_valid_main_bg_value "${key}" "${value}" && echo OK || echo NO\n`,
|
||||
).trim();
|
||||
assert.equal(out, ok ? "OK" : "NO", `${key}=${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// local_image_name —— main_bg 从 struct_main_bg 的 url() 里剥文件名
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("local_image_name resolves main_bg from its url() wrapper", () => {
|
||||
const stub = `
|
||||
is_theme_shipped_image() { return 1; }
|
||||
uci() {
|
||||
[ "$3" = "aurora.theme.struct_main_bg" ] && {
|
||||
printf "url('/luci-static/aurora/images/wall.jpg')\\n"; return 0; }
|
||||
return 1
|
||||
}
|
||||
`;
|
||||
const out = runSh(
|
||||
stub +
|
||||
slice("local_image_name") +
|
||||
`\nICON_PATH="$TMPDIR_ICONS"\nlocal_image_name main_bg || echo NONE\n`,
|
||||
{ TMPDIR_ICONS: process.cwd() },
|
||||
).trim();
|
||||
// 文件不存在于 ICON_PATH → NONE;但走到存在性检查前必须已剥出文件名
|
||||
// (路径逃逸值则在更早一步被拒)。这里断言的是"main_bg 不再落到
|
||||
// 非 bg 的 uci get 分支"——旧代码里它会去读 aurora.theme.main_bg。
|
||||
assert.equal(out, "NONE");
|
||||
});
|
||||
|
||||
test("local_image_name rejects a traversal value in struct_main_bg", () => {
|
||||
const stub = `
|
||||
is_theme_shipped_image() { return 1; }
|
||||
uci() {
|
||||
[ "$3" = "aurora.theme.struct_main_bg" ] && {
|
||||
printf "url('/luci-static/aurora/images/../../etc/aurora/device.key')\\n"; return 0; }
|
||||
return 1
|
||||
}
|
||||
`;
|
||||
const out = runSh(
|
||||
stub + slice("local_image_name") + `\nlocal_image_name main_bg || echo NONE\n`,
|
||||
).trim();
|
||||
assert.equal(out, "NONE");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kind 表成员资格(纯函数,行为断言)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("hub asset tables carry main_bg like login_bg", () => {
|
||||
const script =
|
||||
slice("hub_asset_ext_ok") +
|
||||
slice("hub_asset_target_name") +
|
||||
`
|
||||
hub_asset_ext_ok main_bg jpg && echo EXT_JPG
|
||||
hub_asset_ext_ok main_bg png && echo EXT_PNG
|
||||
hub_asset_ext_ok main_bg svg || echo EXT_NO_SVG
|
||||
hub_asset_target_name main_bg 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef jpg
|
||||
echo
|
||||
`;
|
||||
const out = runSh(script).trim().split("\n");
|
||||
assert.deepEqual(out, [
|
||||
"EXT_JPG",
|
||||
"EXT_PNG",
|
||||
"EXT_NO_SVG",
|
||||
"hub-main-bg-0123456789ab.jpg",
|
||||
]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文本断言:与 jshn/网络纠缠的段落,守"哪一行必须存在"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("build_share_payload ships main_bg and the three tunables", () => {
|
||||
const fn = slice("build_share_payload");
|
||||
assert.match(fn, /for kind in logo_svg .* login_bg main_bg; do/);
|
||||
// 三键循环 + 界内才发(越界静默缺席,同字体栈回退姿势)
|
||||
assert.match(fn, /for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do/);
|
||||
assert.match(fn, /is_valid_main_bg_value "\$key" "\$value"/);
|
||||
});
|
||||
|
||||
test("validate_and_apply validates the tunables and parks them for the asset step", () => {
|
||||
const fn = slice("validate_and_apply_hub_payload");
|
||||
assert.match(fn, /for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do/);
|
||||
assert.match(fn, /is_valid_main_bg_value "\$key" "\$value" \|\| \{ rm -f "\$batch_file"; return 1; \}/);
|
||||
assert.match(fn, /HUB_BG_TUNABLES="\$HUB_BG_TUNABLES\$key=\$value/);
|
||||
});
|
||||
|
||||
test("the tunables land only for a background this apply actually replaced", () => {
|
||||
const fn = slice("apply_hub_bg_tunables");
|
||||
assert.match(fn, /uci -q set "aurora\.theme\.\$key=\$value"/);
|
||||
assert.match(fn, /uci -q delete "aurora\.theme\.\$key"/);
|
||||
assert.match(fn, /struct_main_bg_\*\)\n\s*case "\$written" in \*" main_bg "\*\)/);
|
||||
assert.match(fn, /struct_login_bg_\*\)\n\s*case "\$written" in \*" login_bg "\*\)/);
|
||||
});
|
||||
|
||||
test("apply_hub_asset writes struct_main_bg's url() and drops the stale lqip", () => {
|
||||
const fn = slice("apply_hub_asset");
|
||||
assert.match(fn, /struct_main_bg="url\('\/luci-static\/aurora\/images\/\$name'\)"/);
|
||||
assert.match(fn, /delete aurora\.theme\.struct_main_bg_lqip/);
|
||||
// login_bg 同款清理:换图后旧 LQIP 指着换掉前的图,留着会闪错图
|
||||
assert.match(fn, /delete aurora\.theme\.struct_login_bg_lqip/);
|
||||
});
|
||||
|
||||
test("sweep keeps a live main_bg and collects the hub-main-bg prefix", () => {
|
||||
const fn = slice("sweep_hub_images");
|
||||
assert.match(fn, /struct_main_bg/);
|
||||
assert.match(fn, /hub-main-bg-\*/);
|
||||
});
|
||||
|
||||
test("remove_icon clears a deleted image out of struct_main_bg", () => {
|
||||
assert.match(rpcd, /current_main_bg=\$\(uci -q get aurora\.theme\.struct_main_bg\)/);
|
||||
assert.match(rpcd, /delete aurora\.theme\.struct_main_bg\b/);
|
||||
});
|
||||
|
||||
test("the three tunables enter the theme fingerprint; the filename key stays out", () => {
|
||||
const fn = slice("theme_fingerprint");
|
||||
assert.match(fn, /struct_main_bg_alpha=/);
|
||||
assert.match(fn, /struct_main_bg_blur=/);
|
||||
assert.match(fn, /struct_main_bg_scrim=/);
|
||||
assert.match(fn, /struct_login_bg_alpha=/);
|
||||
assert.match(fn, /struct_login_bg_scrim=/);
|
||||
// struct_main_bg=(文件名)绝不能进指纹——匹配到 alpha/blur/scrim 之外的
|
||||
// 裸 struct_main_bg= 即失败
|
||||
assert.doesNotMatch(fn, /struct_main_bg=(?!alpha|blur|scrim)/);
|
||||
});
|
||||
|
||||
test("hub_local_state's image manifest asks about main_bg too", () => {
|
||||
// 3378 行附近的第二份 kind 列表(发布前 manifest)与打包用同一列表
|
||||
const lists = rpcd.match(/for kind in logo_svg [^\n]*login_bg[^\n]*; do/g) ?? [];
|
||||
assert.ok(lists.length >= 2, "expected both kind lists");
|
||||
for (const line of lists) assert.match(line, /main_bg/);
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
|
||||
const rpcd = readFileSync(RPCD, "utf8");
|
||||
const acl = readFileSync(
|
||||
repo("root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// 把待测函数从脚本里切出来单跑:整份脚本依赖 jshn 和 uci,两者都只存在于
|
||||
// OpenWrt 上。切片 + 打桩测的是「这个函数拿什么、扔掉什么、按什么顺序」,而
|
||||
// 不是「源码长什么样」—— 排序和过滤这类逻辑,文本断言根本抓不住。
|
||||
function slice(name) {
|
||||
const start = rpcd.indexOf(`${name}() {`);
|
||||
assert.ok(start !== -1, `${name} not found in rpcd script`);
|
||||
const end = rpcd.indexOf("\n}\n", start) + 3;
|
||||
return rpcd.slice(start, end);
|
||||
}
|
||||
|
||||
// md5sum 是 busybox 自带的,macOS 上没有;用 cksum 替身。测的是喂进哈希前的那
|
||||
// 串东西是否稳定,哈希算法本身不是被测对象。
|
||||
const HASH_STUB = `md5sum() { cksum; }\n`;
|
||||
|
||||
// uci 的最小替身:show 打印固定夹具,get 从夹具里查,set/commit 记到 $UCI_LOG。
|
||||
const UCI_STUB = `
|
||||
uci() {
|
||||
case "$2" in
|
||||
show) printf '%s' "$UCI_OUT" ;;
|
||||
get) printf '%s' "$UCI_OUT" | sed -n "s/^$3='\\(.*\\)'\\$/\\1/p" ;;
|
||||
set) printf 'SET %s\\n' "$3" >> "$UCI_LOG" ;;
|
||||
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
|
||||
esac
|
||||
}
|
||||
`;
|
||||
|
||||
const BASE =
|
||||
[
|
||||
"aurora.theme.light_bg='#ffffff'",
|
||||
"aurora.theme.dark_bg='#101114'",
|
||||
"aurora.theme.nav_type='sidebar'",
|
||||
"aurora.theme.struct_font_sans='\"Nunito\", sans-serif'",
|
||||
"aurora.theme.struct_font_mono='ui-monospace'",
|
||||
"aurora.theme.struct_spacing='0.3rem'",
|
||||
"aurora.theme.struct_radius_base='0.875rem'",
|
||||
"aurora.theme.struct_content_width_centered='92rem'",
|
||||
"aurora.theme.toolbar_enabled='1'",
|
||||
].join("\n") + "\n";
|
||||
|
||||
function runFingerprint(uciShowOutput) {
|
||||
const script =
|
||||
`uci() { printf '%s' "$UCI_OUT"; }\n` +
|
||||
HASH_STUB +
|
||||
slice("theme_fingerprint") +
|
||||
"\ntheme_fingerprint\n";
|
||||
return execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_OUT: uciShowOutput },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function runShell(body, uciShowOutput) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-one-slot-"));
|
||||
const log = join(dir, "uci.log");
|
||||
const script =
|
||||
`UCI_LOG="${log}"\n: > "$UCI_LOG"\n` +
|
||||
UCI_STUB +
|
||||
HASH_STUB +
|
||||
slice("theme_fingerprint") +
|
||||
slice("normalize_preset_name") +
|
||||
slice("theme_modified") +
|
||||
slice("ensure_active_source") +
|
||||
slice("record_active_source") +
|
||||
body +
|
||||
`\ncat "$UCI_LOG"\n`;
|
||||
try {
|
||||
return execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_OUT: uciShowOutput },
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// theme_fingerprint
|
||||
|
||||
test("theme_fingerprint: uci 输出顺序不影响结果", () => {
|
||||
const shuffled = BASE.trim().split("\n").reverse().join("\n") + "\n";
|
||||
assert.equal(runFingerprint(BASE), runFingerprint(shuffled));
|
||||
});
|
||||
|
||||
test("theme_fingerprint: 簿记项与资产项不进指纹", () => {
|
||||
const noise =
|
||||
BASE +
|
||||
[
|
||||
"aurora.theme.active_source='hub:abc'",
|
||||
"aurora.theme.active_fingerprint='deadbeef'",
|
||||
"aurora.theme.active_preset='sage-green'",
|
||||
"aurora.theme.hub_applied='test'",
|
||||
"aurora.theme.icon_cache_version='1754400000'",
|
||||
"aurora.theme.hub_key_saved='1'",
|
||||
"aurora.theme.logo_svg='logo.svg'",
|
||||
"aurora.theme.favicon_png='favicon.png'",
|
||||
"aurora.theme.struct_login_bg='url(/x.png)'",
|
||||
].join("\n") +
|
||||
"\n";
|
||||
assert.equal(runFingerprint(BASE), runFingerprint(noise));
|
||||
});
|
||||
|
||||
test("theme_fingerprint: 外观键任何一项变化都改变指纹", () => {
|
||||
const base = runFingerprint(BASE);
|
||||
for (const [from, to] of [
|
||||
["light_bg='#ffffff'", "light_bg='#fefefe'"],
|
||||
["dark_bg='#101114'", "dark_bg='#000000'"],
|
||||
["nav_type='sidebar'", "nav_type='mega-menu'"],
|
||||
["struct_spacing='0.3rem'", "struct_spacing='0.5rem'"],
|
||||
["struct_radius_base='0.875rem'", "struct_radius_base='0rem'"],
|
||||
[
|
||||
"struct_content_width_centered='92rem'",
|
||||
"struct_content_width_centered='80rem'",
|
||||
],
|
||||
["toolbar_enabled='1'", "toolbar_enabled='0'"],
|
||||
["struct_font_mono='ui-monospace'", "struct_font_mono='Menlo'"],
|
||||
]) {
|
||||
assert.notEqual(
|
||||
runFingerprint(BASE.replace(from, to)),
|
||||
base,
|
||||
`${from} 未改变指纹`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("theme_fingerprint: toolbar_item 段不进指纹", () => {
|
||||
// 用户加一个快捷方式不该被判成「改过主题」—— 预设根本没碰它。
|
||||
const withItems =
|
||||
BASE +
|
||||
"aurora.@toolbar_item[0].title='Luci'\naurora.@toolbar_item[0].url='/x'\n";
|
||||
assert.equal(runFingerprint(BASE), runFingerprint(withItems));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// theme_modified / ensure_active_source / record_active_source
|
||||
|
||||
test("theme_modified: 指纹相符时返回 1(不是我的)", () => {
|
||||
const uci = BASE + `aurora.theme.active_fingerprint='${runFingerprint(BASE)}'\n`;
|
||||
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, uci), /THEIRS/);
|
||||
});
|
||||
|
||||
test("theme_modified: 指纹不符时返回 0(是我的)", () => {
|
||||
const uci = BASE + "aurora.theme.active_fingerprint='0000000000000000'\n";
|
||||
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, uci), /MINE/);
|
||||
});
|
||||
|
||||
test("theme_modified: 没有记录指纹时判为「我的」", () => {
|
||||
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, BASE), /MINE/);
|
||||
});
|
||||
|
||||
test("ensure_active_source: 已有值时什么都不写", () => {
|
||||
const uci = BASE + "aurora.theme.active_source='hub:abc123'\n";
|
||||
assert.doesNotMatch(runShell(`ensure_active_source`, uci), /SET/);
|
||||
});
|
||||
|
||||
test("ensure_active_source: 老配置带 active_preset 时迁成 builtin: 并补指纹", () => {
|
||||
const uci = BASE + "aurora.theme.active_preset='sage-green'\n";
|
||||
const out = runShell(`ensure_active_source`, uci);
|
||||
assert.match(out, /SET aurora\.theme\.active_source=builtin:sage-green/);
|
||||
assert.match(out, /SET aurora\.theme\.active_fingerprint=\S/);
|
||||
assert.match(out, /COMMIT/);
|
||||
});
|
||||
|
||||
test("ensure_active_source: classic 归一成 default", () => {
|
||||
const uci = BASE + "aurora.theme.active_preset='classic'\n";
|
||||
assert.match(
|
||||
runShell(`ensure_active_source`, uci),
|
||||
/SET aurora\.theme\.active_source=builtin:default/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ensure_active_source: 什么都没有时落到 builtin:default", () => {
|
||||
assert.match(
|
||||
runShell(`ensure_active_source`, BASE),
|
||||
/SET aurora\.theme\.active_source=builtin:default/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ensure_active_source: 老配置带 hub_applied 时降级为 mine 且清空指纹", () => {
|
||||
// hub_applied 只存名字不存 id,反查不到商店条目。降级成 mine + 空指纹是正确
|
||||
// 的:那确实是一份不对应任何卡的配置,它会显示为「我的配置」并持有钩子。
|
||||
const uci = BASE + "aurora.theme.hub_applied='test'\n";
|
||||
const out = runShell(`ensure_active_source`, uci);
|
||||
assert.match(out, /SET aurora\.theme\.active_source=mine/);
|
||||
assert.match(out, /SET aurora\.theme\.active_fingerprint=\s*$/m);
|
||||
});
|
||||
|
||||
test("record_active_source: 写来源与当前指纹,一次 commit", () => {
|
||||
const out = runShell(`record_active_source "hub:abc123"`, BASE);
|
||||
assert.match(out, /SET aurora\.theme\.active_source=hub:abc123/);
|
||||
assert.match(out, /SET aurora\.theme\.active_fingerprint=\S/);
|
||||
assert.match(out, /COMMIT/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// backup_if_mine
|
||||
|
||||
function runBackup(uciShowOutput) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-backup-"));
|
||||
const deviceDir = join(dir, "device");
|
||||
const configFile = join(dir, "aurora.conf");
|
||||
mkdirSync(deviceDir);
|
||||
mkdirSync(join(deviceDir, "pre-hub-images"));
|
||||
writeFileSync(join(deviceDir, "pre-hub-images", "logo.svg"), "old-logo");
|
||||
writeFileSync(configFile, "config theme 'theme'\n");
|
||||
const script =
|
||||
`DEVICE_DIR="${deviceDir}"\nCONFIG_FILE="${configFile}"\n` +
|
||||
`uci() { case "$2" in show) printf '%s' "$UCI_OUT" ;; get) printf '%s' "$UCI_OUT" | sed -n "s/^$3='\\(.*\\)'\\$/\\1/p" ;; esac; }\n` +
|
||||
HASH_STUB +
|
||||
slice("theme_fingerprint") +
|
||||
slice("theme_modified") +
|
||||
slice("backup_if_mine") +
|
||||
`\nbackup_if_mine\necho "TAKEN=$THEME_BACKUP_TAKEN"\n`;
|
||||
const out = execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_OUT: uciShowOutput },
|
||||
});
|
||||
const result = {
|
||||
taken: /TAKEN=1/.test(out),
|
||||
confExists: existsSync(join(deviceDir, "pre-hub-backup.conf")),
|
||||
imagesExist: existsSync(join(deviceDir, "pre-hub-images", "logo.svg")),
|
||||
};
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
test("backup_if_mine: 配置是我的时候留底,并清空上一次的图片备份", () => {
|
||||
const r = runBackup(BASE + "aurora.theme.active_fingerprint='0000'\n");
|
||||
assert.equal(r.taken, true);
|
||||
assert.equal(r.confExists, true);
|
||||
assert.equal(r.imagesExist, false, "旧图片备份必须清掉,否则回滚会拿到上上次的图");
|
||||
});
|
||||
|
||||
test("backup_if_mine: 配置来自商店时不留底,且原封不动保住已有备份", () => {
|
||||
// 这一条是整个「一个槽位」能成立的关键:在商店里连点几个主题试看,你自己那
|
||||
// 份配置必须一直留在备份槽里。
|
||||
const fp = runFingerprint(BASE);
|
||||
const r = runBackup(BASE + `aurora.theme.active_fingerprint='${fp}'\n`);
|
||||
assert.equal(r.taken, false);
|
||||
assert.equal(r.confExists, false);
|
||||
assert.equal(r.imagesExist, true, "别人的主题不该冲掉你自己的图片备份");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply 路径接线
|
||||
|
||||
test("rpcd script: 两条 apply 路径都过 backup_if_mine,且旧的无条件 cp 已经消失", () => {
|
||||
assert.match(rpcd, /hub_apply_worker\(\)[\s\S]*?backup_if_mine/);
|
||||
assert.match(rpcd, /"apply_theme_preset"\)[\s\S]*?backup_if_mine/);
|
||||
// 断言范围是 worker 自己:backup_if_mine 内部当然还有那行 cp,它现在是有
|
||||
// 条件的那一份。
|
||||
assert.doesNotMatch(
|
||||
slice("hub_apply_worker"),
|
||||
/cp "\$CONFIG_FILE" "\$DEVICE_DIR\/pre-hub-backup\.conf"/,
|
||||
"worker 里的无条件备份必须由 backup_if_mine 取代",
|
||||
);
|
||||
});
|
||||
|
||||
test("rpcd script: apply_hub_asset 的图片备份受 THEME_BACKUP_TAKEN 闸门约束", () => {
|
||||
assert.match(slice("apply_hub_asset"), /THEME_BACKUP_TAKEN/);
|
||||
});
|
||||
|
||||
test("rpcd script: 两条 apply 路径都在成功收尾时记录来源", () => {
|
||||
assert.match(rpcd, /record_active_source "hub:\$id"/);
|
||||
assert.match(rpcd, /record_active_source "builtin:\$name"/);
|
||||
});
|
||||
|
||||
test("rpcd script: 指纹在资产落盘之后才记录", () => {
|
||||
// 顺序是正确性的一部分:hub 的资产是在色值那批 uci batch 之后单独 commit 的,
|
||||
// 指纹若抢在前面算,刚应用完的主题立刻就会读回「已修改」。
|
||||
const worker = slice("hub_apply_worker");
|
||||
assert.ok(
|
||||
worker.indexOf("apply_hub_asset") < worker.indexOf("record_active_source"),
|
||||
"record_active_source 必须排在资产应用之后",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// json_add_config_preview / hub_local_state
|
||||
|
||||
const JSHN_STUBS = `
|
||||
json_add_object() { echo "OBJ $1"; }
|
||||
json_close_object(){ echo "/OBJ"; }
|
||||
json_add_string() { echo "STR $1=$2"; }
|
||||
`;
|
||||
|
||||
const PREVIEW_UCI =
|
||||
"aurora.theme.light_bg='#ffffff'\n" +
|
||||
"aurora.theme.dark_bg='#101114'\n" +
|
||||
"aurora.theme.nav_type='sidebar'\n" +
|
||||
"aurora.theme.struct_spacing='0.3rem'\n" +
|
||||
"aurora.theme.struct_radius_base='0.875rem'\n" +
|
||||
"aurora.theme.struct_content_width_centered='92rem'\n" +
|
||||
"aurora.theme.toolbar_enabled='1'\n" +
|
||||
"aurora.theme.struct_font_sans='\"Nunito\", sans-serif'\n" +
|
||||
"aurora.theme.struct_font_mono='ui-monospace'\n";
|
||||
|
||||
function runConfigPreview() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-preview-"));
|
||||
const conf = join(dir, "saved.conf");
|
||||
writeFileSync(conf, "config theme 'theme'\n");
|
||||
// uci -c <dir> show/get 的替身:show 吐夹具,get 从夹具里查。
|
||||
const script =
|
||||
`uci() {
|
||||
case "$4" in
|
||||
show) printf '%s' "$UCI_OUT" ;;
|
||||
get) printf '%s' "$UCI_OUT" | sed -n "s/^$5='\\(.*\\)'\\$/\\1/p" ;;
|
||||
esac
|
||||
}\n` +
|
||||
JSHN_STUBS +
|
||||
slice("json_add_config_preview") +
|
||||
`\njson_add_config_preview "${conf}"\n`;
|
||||
const out = execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_OUT: PREVIEW_UCI },
|
||||
});
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
test("json_add_config_preview: 投影出 colors/layout/typography 三段", () => {
|
||||
const out = runConfigPreview();
|
||||
assert.match(out, /OBJ colors/);
|
||||
assert.match(out, /STR light_bg=#ffffff/);
|
||||
assert.match(out, /STR dark_bg=#101114/);
|
||||
assert.match(out, /OBJ layout/);
|
||||
assert.match(out, /STR nav_type=sidebar/);
|
||||
assert.match(out, /STR struct_radius_base=0\.875rem/);
|
||||
assert.match(out, /OBJ typography/);
|
||||
assert.match(out, /STR struct_font_mono=ui-monospace/);
|
||||
});
|
||||
|
||||
test("json_add_config_preview: 带空格的字体栈完整保留", () => {
|
||||
// 抓的是词分割陷阱:字体栈里有空格,绝不能走 $(...) 展开。
|
||||
assert.match(
|
||||
runConfigPreview(),
|
||||
/STR struct_font_sans="Nunito", sans-serif/,
|
||||
);
|
||||
});
|
||||
|
||||
test("json_add_config_preview: jshn 写入没有掉进管道子 shell", () => {
|
||||
// 若颜色循环写成 `... | while read`,循环体在子 shell 里跑,退出后这些
|
||||
// json_add_string 全部丢失 —— 输出里就只剩空的 OBJ colors / /OBJ。
|
||||
const out = runConfigPreview();
|
||||
const block = out.slice(out.indexOf("OBJ colors"), out.indexOf("/OBJ"));
|
||||
assert.ok(block.includes("STR light_bg"), "颜色写入丢失:循环跑在子 shell 里");
|
||||
});
|
||||
|
||||
test("rpcd script: hub_local_state 已声明且过 ensure_active_source", () => {
|
||||
assert.match(rpcd, /json_add_object "hub_local_state"; json_close_object/);
|
||||
assert.match(rpcd, /"hub_local_state"\)[\s\S]*?ensure_active_source/);
|
||||
assert.match(rpcd, /"hub_local_state"\)[\s\S]*?json_add_boolean "modified"/);
|
||||
});
|
||||
|
||||
test("acl: hub_local_state 在 read 列表里", () => {
|
||||
assert.match(acl, /"hub_local_state"/);
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
|
||||
|
||||
// 同 rpcd-one-slot:把待测函数切出来单跑。整份脚本依赖 jshn 和 uci,两者都只
|
||||
// 存在于 OpenWrt 上;而这一批测的全是排序、过滤和落盘这类文本断言抓不住的
|
||||
// 行为——编号一旦两端算得不一样,接收方的图标就整体错位,而源码看上去毫无问题。
|
||||
function slice(name) {
|
||||
const start = rpcd.indexOf(`${name}() {`);
|
||||
assert.ok(start !== -1, `${name} not found in rpcd script`);
|
||||
const end = rpcd.indexOf("\n}\n", start) + 3;
|
||||
return rpcd.slice(start, end);
|
||||
}
|
||||
|
||||
function constant(name) {
|
||||
const match = rpcd.match(new RegExp(`^readonly ${name}=(.*)$`, "m"));
|
||||
assert.ok(match, `${name} not found`);
|
||||
return `readonly ${name}=${match[1]}\n`;
|
||||
}
|
||||
|
||||
// uci 的替身。刻意不用 rpcd-one-slot 那个 sed 版本:section id 长
|
||||
// `@toolbar_item[0]`,方括号在 sed 的正则里是字符类,那个替身查不到它。
|
||||
const UCI_STUB = `
|
||||
uci() {
|
||||
cmd=""; arg=""
|
||||
for a in "$@"; do
|
||||
case "$a" in -*) continue ;; esac
|
||||
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
|
||||
done
|
||||
case "$cmd" in
|
||||
show) printf '%s\\n' "$UCI_OUT" ;;
|
||||
get)
|
||||
v=$(printf '%s\\n' "$UCI_OUT" | grep -F "$arg=" | head -1 | sed "s/^[^=]*='//;s/'\\$//")
|
||||
[ -n "$v" ] || return 1
|
||||
printf '%s\\n' "$v"
|
||||
;;
|
||||
set) printf 'SET %s\\n' "$arg" >> "$UCI_LOG" ;;
|
||||
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
|
||||
esac
|
||||
}
|
||||
`;
|
||||
|
||||
function runSh(script, env = {}) {
|
||||
return execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sniff_image_ext —— 字节来自网络,扩展名来自字节
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
|
||||
const ICO = Buffer.from([0x00, 0x00, 0x01, 0x00, 1, 0, 0, 0]);
|
||||
// WebP 的格式名在偏移 8,前面隔着 RIFF 自己的长度字段 —— 只读 8 字节是看不
|
||||
// 见它的。
|
||||
const WEBP = Buffer.concat([
|
||||
Buffer.from("RIFF"), Buffer.from([0x1a, 0, 0, 0]),
|
||||
Buffer.from("WEBP"), Buffer.from("VP8L"),
|
||||
]);
|
||||
|
||||
function sniff(bytes) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-sniff-"));
|
||||
const file = join(dir, "asset");
|
||||
writeFileSync(file, bytes);
|
||||
const out = runSh(
|
||||
slice("sniff_image_ext") + `\nsniff_image_ext "${file}" || echo NONE\n`,
|
||||
).trim();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
test("sniff_image_ext: 按魔数认出五种格式,认不出就失败", () => {
|
||||
assert.equal(sniff(PNG), "png");
|
||||
assert.equal(sniff(JPEG), "jpg");
|
||||
assert.equal(sniff(ICO), "ico");
|
||||
assert.equal(sniff(WEBP), "webp");
|
||||
assert.equal(sniff('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), "svg");
|
||||
assert.equal(sniff('<?xml version="1.0"?><svg></svg>'), "svg");
|
||||
// BOM + 前导空白都不该挡住 SVG —— 和 hub 的 isSvg 一样的姿态。
|
||||
assert.equal(sniff("\uFEFF\n <svg></svg>"), "svg");
|
||||
assert.equal(sniff("not an image at all"), "NONE");
|
||||
// RIFF 但不是 WEBP —— 一个 wav 不该被当成背景图。
|
||||
assert.equal(
|
||||
sniff(Buffer.concat([Buffer.from("RIFF"), Buffer.from([0x1a, 0, 0, 0]), Buffer.from("WAVE")])),
|
||||
"NONE",
|
||||
);
|
||||
});
|
||||
|
||||
test("hub_asset_ext_ok: 每种 kind 只收它该收的格式", () => {
|
||||
const script =
|
||||
slice("hub_asset_ext_ok") +
|
||||
`\nfor pair in "$@"; do
|
||||
k=\${pair%%:*}; e=\${pair##*:}
|
||||
if hub_asset_ext_ok "$k" "$e"; then echo "$pair OK"; else echo "$pair NO"; fi
|
||||
done\n`;
|
||||
const cases = [
|
||||
"logo_svg:svg", "logo_svg:png",
|
||||
"favicon_png:png", "favicon_png:svg",
|
||||
"favicon_ico:ico", "favicon_ico:png",
|
||||
"pwa_icon_192:png", "pwa_icon_192:jpg",
|
||||
"login_bg:png", "login_bg:jpg", "login_bg:webp", "login_bg:svg",
|
||||
"main_bg:webp",
|
||||
"toolbar_icon_0:png", "toolbar_icon_11:svg", "toolbar_icon_0:ico",
|
||||
"font_sans:png",
|
||||
];
|
||||
const out = runSh(`set -- ${cases.join(" ")}\n${script}`);
|
||||
const verdict = Object.fromEntries(
|
||||
out.trim().split("\n").map((line) => line.split(" ")),
|
||||
);
|
||||
assert.deepEqual(verdict, {
|
||||
"logo_svg:svg": "OK", "logo_svg:png": "NO",
|
||||
"favicon_png:png": "OK", "favicon_png:svg": "NO",
|
||||
"favicon_ico:ico": "OK", "favicon_ico:png": "NO",
|
||||
"pwa_icon_192:png": "OK", "pwa_icon_192:jpg": "NO",
|
||||
// 背景原样存,传什么格式就是什么格式,三种都收。
|
||||
"login_bg:png": "OK", "login_bg:jpg": "OK", "login_bg:webp": "OK",
|
||||
"login_bg:svg": "NO", "main_bg:webp": "OK",
|
||||
"toolbar_icon_0:png": "OK", "toolbar_icon_11:svg": "OK", "toolbar_icon_0:ico": "NO",
|
||||
// 字体不走这条路,问到它就是调用方分流错了。
|
||||
"font_sans:png": "NO",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hub_asset_target_name —— 落地名不能再是出厂名
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("hub_asset_target_name: 全部 sha 派生且带 hub- 前缀,绝不落成出厂名", () => {
|
||||
const sha = "0123456789abcdef".repeat(4);
|
||||
const kinds = [
|
||||
"logo_svg", "favicon_png", "favicon_ico",
|
||||
"pwa_icon_192", "pwa_icon_512", "login_bg", "toolbar_icon_3",
|
||||
];
|
||||
const script =
|
||||
slice("hub_asset_target_name") +
|
||||
`\nfor k in ${kinds.join(" ")}; do
|
||||
case "$k" in logo_svg) e=svg ;; favicon_ico) e=ico ;; *) e=png ;; esac
|
||||
hub_asset_target_name "$k" "${sha}" "$e"; echo
|
||||
done
|
||||
hub_asset_target_name "nonsense" "${sha}" png || echo UNKNOWN_REJECTED\n`;
|
||||
const names = runSh(script).trim().split("\n");
|
||||
|
||||
assert.deepEqual(names, [
|
||||
"hub-logo-0123456789ab.svg",
|
||||
"hub-favicon-0123456789ab.png",
|
||||
"hub-favicon-0123456789ab.ico",
|
||||
"hub-app192-0123456789ab.png",
|
||||
"hub-app512-0123456789ab.png",
|
||||
"hub-login-bg-0123456789ab.png",
|
||||
"hub-tb-0123456789ab.png",
|
||||
"UNKNOWN_REJECTED",
|
||||
]);
|
||||
|
||||
// 这才是这次改动的要害:落地名若还是出厂名,一份从商店应用来的配置就再也
|
||||
// 分享不出它的 logo/favicon/PWA 图标 —— build_share_payload 会把它们当成
|
||||
// 「用户从没改过这个槽位」而跳过。
|
||||
const shipped = slice("is_theme_shipped_image");
|
||||
const check = runSh(
|
||||
shipped +
|
||||
`\nfor n in ${names.slice(0, -1).join(" ")}; do
|
||||
is_theme_shipped_image "$n" && echo "LEAK $n"
|
||||
done; echo DONE\n`,
|
||||
).trim();
|
||||
assert.equal(check, "DONE", "落地名一旦撞上出厂名,二次分享就会静默丢图");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nth_custom_toolbar_icon —— 两端必须算出同一个编号
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toolbarFixture(items) {
|
||||
const lines = [];
|
||||
items.forEach((item, i) => {
|
||||
lines.push(`aurora.@toolbar_item[${i}]=toolbar_item`);
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
lines.push(`aurora.@toolbar_item[${i}].${key}='${value}'`);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function iconOrder(uciOut) {
|
||||
const script =
|
||||
constant("MAX_SHARED_TOOLBAR_ICONS") +
|
||||
UCI_STUB +
|
||||
slice("has_control_char") +
|
||||
slice("strip_control_chars") +
|
||||
slice("is_theme_shipped_image") +
|
||||
slice("toolbar_icon_custom") +
|
||||
slice("toolbar_item_shareable") +
|
||||
slice("nth_custom_toolbar_icon") +
|
||||
`\ni=0
|
||||
while [ "$i" -lt 20 ]; do
|
||||
nth_custom_toolbar_icon "$i" || break
|
||||
echo
|
||||
i=$((i + 1))
|
||||
done\n`;
|
||||
return runSh(script, { UCI_OUT: uciOut, UCI_LOG: "/dev/null" })
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const ok = (extra) => ({ title: "T", url: "/x", enabled: "1", ...extra });
|
||||
|
||||
test("nth_custom_toolbar_icon: 按首次出现顺序去重编号", () => {
|
||||
const order = iconOrder(
|
||||
toolbarFixture([
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "beta.svg" }),
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "gamma.png" }),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(order, ["alpha.png", "beta.svg", "gamma.png"]);
|
||||
});
|
||||
|
||||
test("nth_custom_toolbar_icon: 主题自带的图标不占编号", () => {
|
||||
// 这四个是 header.ut 默认工具栏用的,每台装了主题的设备上都有 —— 发过去
|
||||
// 纯属浪费,而更要命的是让它们占掉编号会让接收端的映射整体错位。
|
||||
const order = iconOrder(
|
||||
toolbarFixture([
|
||||
ok({ icon: "overview.svg" }),
|
||||
ok({ icon: "mine.png" }),
|
||||
ok({ icon: "system.svg" }),
|
||||
ok({ icon: "logo.svg" }),
|
||||
ok({ icon: "other.png" }),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(order, ["mine.png", "other.png"]);
|
||||
});
|
||||
|
||||
// 这条是整个方案的支点。分享端遍历的是**被接受的**快捷方式,接收端遍历的是
|
||||
// payload.toolbar(也就是那些被接受的)。一条因为标题超长而被跳过的快捷方式,
|
||||
// 它的图标若在分享端占掉一格,接收端就从那里往后全部错位——每个人的工具栏都
|
||||
// 会挂上别人的图。
|
||||
test("nth_custom_toolbar_icon: 被跳过的快捷方式不占编号", () => {
|
||||
const order = iconOrder(
|
||||
toolbarFixture([
|
||||
ok({ icon: "first.png" }),
|
||||
// 标题 31 字符:超出 hub 的 1-30,整条不发
|
||||
{ title: "x".repeat(31), url: "/y", enabled: "1", icon: "skipped-a.png" },
|
||||
// url 没有 scheme 也不以 / 开头
|
||||
ok({ url: "nowhere", icon: "skipped-b.png" }),
|
||||
// 协议相对 url
|
||||
ok({ url: "//evil.example", icon: "skipped-c.png" }),
|
||||
// enabled 不是 0/1
|
||||
ok({ enabled: "yes", icon: "skipped-d.png" }),
|
||||
// 图标名超出字符集
|
||||
ok({ icon: "bad name.png" }),
|
||||
// 标题带单引号
|
||||
ok({ title: "it's", icon: "skipped-e.png" }),
|
||||
ok({ icon: "second.png" }),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(order, ["first.png", "second.png"]);
|
||||
});
|
||||
|
||||
test("nth_custom_toolbar_icon: 编号上限与分享端一致", () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 15; i++) many.push(ok({ icon: `icon${i}.png` }));
|
||||
const order = iconOrder(toolbarFixture(many));
|
||||
// 12 条快捷方式的上限先咬住,图标自然也只剩 12 个。
|
||||
assert.equal(order.length, 12);
|
||||
assert.equal(order[0], "icon0.png");
|
||||
assert.equal(order[11], "icon11.png");
|
||||
});
|
||||
|
||||
test("nth_custom_toolbar_icon: 编号只看名字,不看本机有没有那个文件", () => {
|
||||
// 接收端算同一个编号时,那些文件在它机器上根本还不存在。这个判断一旦掺进
|
||||
// 「文件在不在、够不够小、格式对不对」,两端就再也算不出同一个数。
|
||||
const body = slice("nth_custom_toolbar_icon");
|
||||
assert.ok(
|
||||
!body.includes("toolbar_icon_uploadable"),
|
||||
"编号不能依赖只有分享端才答得出的问题",
|
||||
);
|
||||
assert.ok(!body.includes("ICON_PATH"), "编号不能碰本机文件");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// image_fits_share
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("image_fits_share: 边界正好是 hub 的 2 MiB", () => {
|
||||
const script =
|
||||
constant("MAX_SHARED_IMAGE") +
|
||||
slice("image_fits_share") +
|
||||
`\nfor s in "$@"; do
|
||||
if image_fits_share "$s"; then echo "$s YES"; else echo "$s NO"; fi
|
||||
done\n`;
|
||||
const out = runSh(`set -- 0 1 2097151 2097152 2097153 8388608\n${script}`);
|
||||
assert.deepEqual(
|
||||
out.trim().split("\n"),
|
||||
["0 NO", "1 YES", "2097151 YES", "2097152 YES", "2097153 NO", "8388608 NO"],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restore_pre_hub_images / sweep_hub_images —— sha 名的代价必须被收干净
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sandbox() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-images-"));
|
||||
const iconPath = join(dir, "images");
|
||||
const deviceDir = join(dir, "device");
|
||||
mkdirSync(iconPath);
|
||||
mkdirSync(join(deviceDir, "pre-hub-images"), { recursive: true });
|
||||
return { dir, iconPath, deviceDir };
|
||||
}
|
||||
|
||||
test("restore_pre_hub_images: 先删本次新引入的,再拷回被覆盖的", () => {
|
||||
const { dir, iconPath, deviceDir } = sandbox();
|
||||
const backup = join(deviceDir, "pre-hub-images");
|
||||
|
||||
// 这次 apply 新引入的一张
|
||||
writeFileSync(join(iconPath, "hub-logo-aaaaaaaaaaaa.svg"), "theirs");
|
||||
writeFileSync(join(backup, "hub-logo-aaaaaaaaaaaa.svg.new"), "");
|
||||
// 这次 apply 覆盖掉的一张
|
||||
writeFileSync(join(iconPath, "hub-tb-bbbbbbbbbbbb.png"), "theirs");
|
||||
writeFileSync(join(backup, "hub-tb-bbbbbbbbbbbb.png"), "mine");
|
||||
// 与 hub 无关的一张,一个字节都不该动
|
||||
writeFileSync(join(iconPath, "my-own.png"), "untouched");
|
||||
|
||||
runSh(
|
||||
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
|
||||
slice("restore_pre_hub_images") +
|
||||
"\nrestore_pre_hub_images\n",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
existsSync(join(iconPath, "hub-logo-aaaaaaaaaaaa.svg")),
|
||||
false,
|
||||
"新引入的必须删掉,否则回滚会把别人的图永远留在 overlay 上",
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(join(iconPath, "hub-tb-bbbbbbbbbbbb.png"), "utf8"),
|
||||
"mine",
|
||||
);
|
||||
assert.equal(readFileSync(join(iconPath, "my-own.png"), "utf8"), "untouched");
|
||||
assert.equal(existsSync(backup), false, "备份目录必须清掉");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runSweep(files, uciOut, backups = []) {
|
||||
const { dir, iconPath, deviceDir } = sandbox();
|
||||
for (const name of files) writeFileSync(join(iconPath, name), "x");
|
||||
for (const name of backups) writeFileSync(join(deviceDir, "pre-hub-images", name), "x");
|
||||
|
||||
runSh(
|
||||
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
|
||||
UCI_STUB +
|
||||
slice("is_hub_asset_name") +
|
||||
slice("sweep_hub_images") +
|
||||
"\nsweep_hub_images\n",
|
||||
{ UCI_OUT: uciOut, UCI_LOG: "/dev/null" },
|
||||
);
|
||||
|
||||
const left = readdirSync(iconPath).sort();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return left;
|
||||
}
|
||||
|
||||
test("sweep_hub_images: 留下还被指着的,收走没人要的", () => {
|
||||
const left = runSweep(
|
||||
[
|
||||
"hub-logo-aaaaaaaaaaaa.svg", // 被 logo_svg 指着
|
||||
"hub-login-bg-bbbbbbbbbbbb.png", // 被 struct_login_bg 指着
|
||||
"hub-tb-cccccccccccc.png", // 被某条快捷方式指着
|
||||
"hub-logo-dddddddddddd.svg", // 上一次 apply 的遗留
|
||||
"hub-app192-eeeeeeeeeeee.png", // 同上
|
||||
],
|
||||
[
|
||||
"aurora.theme.logo_svg='hub-logo-aaaaaaaaaaaa.svg'",
|
||||
"aurora.theme.struct_login_bg='url('/luci-static/aurora/images/hub-login-bg-bbbbbbbbbbbb.png')'",
|
||||
"aurora.@toolbar_item[0]=toolbar_item",
|
||||
"aurora.@toolbar_item[0].icon='hub-tb-cccccccccccc.png'",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.deepEqual(left, [
|
||||
"hub-login-bg-bbbbbbbbbbbb.png",
|
||||
"hub-logo-aaaaaaaaaaaa.svg",
|
||||
"hub-tb-cccccccccccc.png",
|
||||
]);
|
||||
});
|
||||
|
||||
test("sweep_hub_images: 回滚还要用的不动", () => {
|
||||
const left = runSweep(
|
||||
["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png"],
|
||||
"aurora.theme.nav_type='sidebar'",
|
||||
["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png.new"],
|
||||
);
|
||||
// 一张是备份下来的原图,一张记着"本次新引入"——两张都是 restore 的输入。
|
||||
assert.deepEqual(left, ["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png"]);
|
||||
});
|
||||
|
||||
test("sweep_hub_images: 用户自己上传的 hub-* 文件不算垃圾", () => {
|
||||
// upload_icon 不限制文件名,一个叫 hub-vacation.png 的自有图标完全合法。
|
||||
// 只按前缀清理,等于在用户没把它设成任何槽位时把它删掉——纯粹的数据丢失。
|
||||
const left = runSweep(
|
||||
[
|
||||
"hub-vacation.png",
|
||||
"hub-logo-notahexstring.svg",
|
||||
"hub-tb-aaaaaaaaaaaa.png", // 这个才是本函数产的形状
|
||||
],
|
||||
"aurora.theme.nav_type='sidebar'",
|
||||
);
|
||||
assert.deepEqual(left, ["hub-logo-notahexstring.svg", "hub-vacation.png"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 时序
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("hub_apply_worker: 清理排在所有资产落地之后", () => {
|
||||
const body = slice("hub_apply_worker");
|
||||
assert.ok(
|
||||
body.indexOf("apply_hub_asset") < body.indexOf("sweep_hub_images"),
|
||||
"抢在资产之前跑,就会把刚下载的图当成垃圾删掉",
|
||||
);
|
||||
assert.ok(
|
||||
body.indexOf("sweep_hub_images") < body.indexOf("record_active_source"),
|
||||
);
|
||||
});
|
||||
|
||||
test("hub_restore_backup: 回滚之后也要清一次", () => {
|
||||
const branch = rpcd.match(/"hub_restore_backup"\)([\s\S]*?)\n\t;;/);
|
||||
assert.ok(branch);
|
||||
// restore_pre_hub_images 只认得**这一次**备份记下的东西;中间那些没拍快照的
|
||||
// apply(拿商店主题盖商店主题)留下的图,只有 sweep 按"还有没有槽位指着"
|
||||
// 才收得干净。
|
||||
assert.match(branch[1], /sweep_hub_images/);
|
||||
assert.ok(
|
||||
branch[1].indexOf("restore_pre_hub_images") < branch[1].indexOf("sweep_hub_images"),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_hub_toolbar_icon —— 落地之后要把用它的快捷方式全部指过来
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyIcon(kind, uciOut) {
|
||||
const script =
|
||||
constant("MAX_SHARED_TOOLBAR_ICONS") +
|
||||
`ICON_PATH="/nonexistent"\nDEVICE_DIR="/nonexistent"\n` +
|
||||
UCI_STUB +
|
||||
// apply_hub_asset 自己有单独的测试;这里替身掉,好让这一条只测"指过来"。
|
||||
`apply_hub_asset() { APPLIED_ASSET_NAME="hub-tb-aaaaaaaaaaaa.png"; return 0; }\n` +
|
||||
slice("has_control_char") +
|
||||
slice("strip_control_chars") +
|
||||
slice("is_theme_shipped_image") +
|
||||
slice("toolbar_icon_custom") +
|
||||
slice("toolbar_item_shareable") +
|
||||
slice("nth_custom_toolbar_icon") +
|
||||
slice("apply_hub_toolbar_icon") +
|
||||
// 真文件,不是 /dev/stdout。uci 替身用 `>> "$UCI_LOG"` 追加,而在 Linux 上
|
||||
// /dev/stdout 是 /proc/self/fd/1 的软链 —— execFileSync 给的 stdout 是一根
|
||||
// 管道,打开 pipe:[N] 这个路径会 ENXIO("cannot create /dev/stdout: No such
|
||||
// device or address"),于是每一条 SET/COMMIT 都写失败。函数照样成功返回,
|
||||
// 所以断言看到的只剩 RC_OK。macOS 的 /dev/stdout 是真设备、直接 dup fd 1,
|
||||
// 本地因此全绿而 CI 全红。rpcd-one-slot.test.mjs 一直用的就是临时文件。
|
||||
`\n: > "$UCI_LOG"\n` +
|
||||
`if apply_hub_toolbar_icon "${kind}" deadbeef http://x/; then rc=RC_OK; else rc=RC_FAIL; fi\n` +
|
||||
// 日志先出、返回码后出,顺序和断言里的一致。
|
||||
`cat "$UCI_LOG"\necho "$rc"\n`;
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-icon-"));
|
||||
try {
|
||||
const out = execFileSync("sh", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_OUT: uciOut, UCI_LOG: join(dir, "uci.log") },
|
||||
});
|
||||
return out.trim().split("\n").filter(Boolean);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const twoUsers = toolbarFixture([
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "beta.png" }),
|
||||
ok({ icon: "alpha.png" }),
|
||||
]);
|
||||
|
||||
test("apply_hub_toolbar_icon: 每一条用了这个图标的快捷方式都被指过来", () => {
|
||||
// toolbar_icon_0 = 第一个不同的图标 = alpha.png,被第 0 和第 2 条用着。
|
||||
const log = applyIcon("toolbar_icon_0", twoUsers);
|
||||
// 断言里带上 =值:指过来还不够,得指到**刚落地的那个名字**上。
|
||||
assert.deepEqual(log, [
|
||||
"SET aurora.@toolbar_item[0].icon=hub-tb-aaaaaaaaaaaa.png",
|
||||
"SET aurora.@toolbar_item[2].icon=hub-tb-aaaaaaaaaaaa.png",
|
||||
"COMMIT",
|
||||
"RC_OK",
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply_hub_toolbar_icon: 编号对应的是第 k 个不同的图标,不是第 k 条快捷方式", () => {
|
||||
// 第 1 条快捷方式用 beta.png,而 beta.png 是第 1 个(0-based)不同的图标 ——
|
||||
// 这两个数字在这份夹具里恰好都是 1,所以再换一份让它们分开:
|
||||
const log = applyIcon(
|
||||
"toolbar_icon_1",
|
||||
toolbarFixture([
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "alpha.png" }),
|
||||
ok({ icon: "beta.png" }),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(log, [
|
||||
"SET aurora.@toolbar_item[3].icon=hub-tb-aaaaaaaaaaaa.png",
|
||||
"COMMIT",
|
||||
"RC_OK",
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply_hub_toolbar_icon: 没有对应槽位的资产不落地", () => {
|
||||
// hub 送来一个 toolbar_icon_5,而这份配置只有两个不同的图标。多出来的那份
|
||||
// 字节没有归属,写下去只会在 ICON_PATH 里躺着。
|
||||
const log = applyIcon("toolbar_icon_5", twoUsers);
|
||||
assert.deepEqual(log, ["RC_FAIL"]);
|
||||
});
|
||||
|
||||
test("apply_hub_toolbar_icon: kind 里的编号不是数字就直接拒", () => {
|
||||
assert.deepEqual(applyIcon("toolbar_icon_", twoUsers), ["RC_FAIL"]);
|
||||
assert.deepEqual(applyIcon("toolbar_icon_x", twoUsers), ["RC_FAIL"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strip_control_chars —— busybox 的 tr 不认字符类
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 真机(JDCloud RE-CS-02, busybox)上 `tr -d '[:cntrl:]'` 把参数当成字面集合
|
||||
// { [ : c n t r l },于是每个分享出去的快捷方式标题都悄悄少掉它的 c/n/t/r/l:
|
||||
// "Overview" → "Oveview"、"Again" → "Agai",而接收端把这个残缺版写进 uci。
|
||||
// GNU tr 和 BSD tr 都实现了字符类,所以开发机上永远看不到。
|
||||
test("strip_control_chars: 只删控制字符,字面的 c/n/t/r/l 一个都不能少", () => {
|
||||
const script =
|
||||
slice("strip_control_chars") +
|
||||
`\nfor s in "$@"; do printf '[%s]\\n' "$(strip_control_chars "$s")"; done\n`;
|
||||
const out = runSh(
|
||||
`set -- "Overview" "Again" "Control Center" "[:cntrl:]" ""\n${script}`,
|
||||
);
|
||||
assert.deepEqual(out.trim().split("\n"), [
|
||||
"[Overview]",
|
||||
"[Again]",
|
||||
"[Control Center]",
|
||||
"[[:cntrl:]]",
|
||||
"[]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("strip_control_chars: 控制字符确实被删掉", () => {
|
||||
const script =
|
||||
slice("strip_control_chars") +
|
||||
`\nv=$(printf 'a\\tb\\001c\\rd')
|
||||
r=$(strip_control_chars "$v"); printf '%s/%s\\n' "$r" "\${#r}"
|
||||
r=$(strip_control_chars "$(printf '\\001\\002')"); printf '%s/%s\\n' "$r" "\${#r}"
|
||||
r=$(strip_control_chars "$(printf '\\001lead')"); printf '%s/%s\\n' "$r" "\${#r}"\n`;
|
||||
assert.deepEqual(runSh(script).trim().split("\n"), ["abcd/4", "/0", "lead/4"]);
|
||||
});
|
||||
|
||||
test("rpcd script: 三处标题剥离都走这个函数,没有 tr 的残留", () => {
|
||||
// 注释里点到 `tr -d '[:cntrl:]'` 是在解释为什么不用它;代码里一处都不能剩。
|
||||
const code = rpcd
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*#/.test(line))
|
||||
.join("\n");
|
||||
assert.ok(
|
||||
!code.includes("tr -d '[:cntrl:]'"),
|
||||
"busybox 的 tr 会把标题里的 c/n/t/r/l 删掉",
|
||||
);
|
||||
assert.equal(
|
||||
(code.match(/strip_control_chars /g) || []).length,
|
||||
// 分享端逐项校验、分享端取标题、应用端 —— 三处
|
||||
3,
|
||||
"三处标题剥离都必须走同一个函数",
|
||||
);
|
||||
});
|
||||
|
||||
// 剥离是按控制字符个数循环的,而应用端的 title 直接来自 hub 响应。
|
||||
test("rpcd script: 应用端在剥离之前先粗筛长度", () => {
|
||||
const body = slice("validate_and_apply_hub_payload");
|
||||
const capIdx = body.indexOf('[ "${#title}" -le 200 ]');
|
||||
const stripIdx = body.indexOf('strip_control_chars "$title"');
|
||||
assert.ok(capIdx >= 0, "缺少长度粗筛");
|
||||
assert.ok(capIdx < stripIdx, "粗筛必须排在剥离之前,否则是一条拒绝服务路径");
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
// 应用一个主题 = 穿上它那一套,而不是它那一套外加上一个主题的壁纸和 logo。
|
||||
// 这一组守的是「交接」那一步:上个主题带进来的资产被清掉,用户自己上传的
|
||||
// 原封不动留下。归属只看文件名形状,所以判定本身也在这里钉死。
|
||||
|
||||
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
|
||||
|
||||
function slice(name) {
|
||||
const start = rpcd.indexOf(`${name}() {`);
|
||||
assert.ok(start !== -1, `${name} not found in rpcd script`);
|
||||
const end = rpcd.indexOf("\n}\n", start) + 3;
|
||||
return rpcd.slice(start, end);
|
||||
}
|
||||
|
||||
// uci 的最小替身,带状态:get 查库,set/delete 改库,commit 记一笔。被测的正是
|
||||
// 「哪些键留下、哪些键消失」,只记调用日志的无状态桩答不了这个问题。
|
||||
// 旗标位置不固定:清理走 \`uci -q get\`,而 sweep 走 \`uci show aurora\`。按位置取
|
||||
// 参数的桩会在后者上静默失灵,给出一份空的 live 名单 —— 那会让 sweep 的断言
|
||||
// 变成永远成立。所以跳过所有 -* 再取动词,同 rpcd-share-assets 的桩。
|
||||
const UCI_STUB = `
|
||||
uci() {
|
||||
cmd=""; arg=""
|
||||
for a in "$@"; do
|
||||
case "$a" in -*) continue ;; esac
|
||||
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
|
||||
done
|
||||
case "$cmd" in
|
||||
get)
|
||||
v=$(grep "^\${arg}=" "$UCI_DB" | head -1 | cut -d= -f2-)
|
||||
[ -n "$v" ] || return 1
|
||||
printf '%s\\n' "$v"
|
||||
;;
|
||||
delete)
|
||||
grep -v "^\${arg}=" "$UCI_DB" > "$UCI_DB.t"; mv "$UCI_DB.t" "$UCI_DB" ;;
|
||||
set)
|
||||
k="\${arg%%=*}"; v="\${arg#*=}"
|
||||
grep -v "^\${k}=" "$UCI_DB" > "$UCI_DB.t"; mv "$UCI_DB.t" "$UCI_DB"
|
||||
printf '%s=%s\\n' "$k" "$v" >> "$UCI_DB" ;;
|
||||
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
`;
|
||||
|
||||
// 一次 apply 之后 uci 里剩下什么。fixture 是 "key=value" 行,返回同样的形状。
|
||||
function runWithUci(fnSlices, call, fixture, env = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-handoff-"));
|
||||
const db = join(dir, "uci.db");
|
||||
const log = join(dir, "uci.log");
|
||||
writeFileSync(db, fixture.length ? fixture.join("\n") + "\n" : "");
|
||||
writeFileSync(log, "");
|
||||
try {
|
||||
execFileSync("sh", ["-c", UCI_STUB + fnSlices.join("\n") + "\n" + call], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_DB: db, UCI_LOG: log, ...env },
|
||||
});
|
||||
return {
|
||||
uci: readFileSync(db, "utf8").split("\n").filter(Boolean).sort(),
|
||||
commits: readFileSync(log, "utf8").split("\n").filter(Boolean).length,
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const CLEAR = [slice("is_hub_asset_name"), slice("clear_inherited_assets")];
|
||||
const bgUrl = (name) => `url('/luci-static/aurora/images/${name}')`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// is_hub_asset_name —— 归属判定,清理与回收共用的那一条
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const shapeCases = [
|
||||
// apply_hub_asset 真会产出的七种前缀
|
||||
["hub-logo-0123456789ab.svg", true],
|
||||
["hub-favicon-0123456789ab.png", true],
|
||||
["hub-app192-0123456789ab.png", true],
|
||||
["hub-app512-0123456789ab.png", true],
|
||||
["hub-login-bg-abcdef012345.jpg", true],
|
||||
["hub-main-bg-abcdef012345.png", true],
|
||||
["hub-tb-abcdef012345.png", true],
|
||||
// 用户自己上传的图,哪怕名字以 hub- 开头也不算 —— 误清就是数据丢失
|
||||
["hub-wallpaper.png", false],
|
||||
["hub-main-bg.png", false],
|
||||
["my-photo.jpg", false],
|
||||
["logo.svg", false],
|
||||
// 形状必须严丝合缝:12 位、全小写十六进制、三字符扩展名
|
||||
["hub-main-bg-abcdef01234.png", false],
|
||||
["hub-main-bg-abcdef0123456.png", false],
|
||||
["hub-main-bg-ABCDEF012345.png", false],
|
||||
["hub-main-bg-abcdefg12345.png", false],
|
||||
["hub-main-bg-abcdef012345.jpeg", false],
|
||||
["", false],
|
||||
];
|
||||
|
||||
test("is_hub_asset_name only accepts the shape apply_hub_asset produces", () => {
|
||||
for (const [name, expected] of shapeCases) {
|
||||
const out = execFileSync(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
slice("is_hub_asset_name") +
|
||||
`\nis_hub_asset_name "${name}" && echo YES || echo NO\n`,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
assert.equal(out, expected ? "YES" : "NO", `${name || "(empty)"}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("sweep_hub_images asks is_hub_asset_name rather than re-deriving the shape", () => {
|
||||
const sweep = slice("sweep_hub_images");
|
||||
assert.match(sweep, /is_hub_asset_name "\$name" \|\| continue/);
|
||||
assert.equal(
|
||||
/\[0-9a-f\]\[0-9a-f\]/.test(sweep),
|
||||
false,
|
||||
"the hex shape must live in exactly one place",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clear_inherited_assets —— 上个主题的资产被交还,用户自己的留下
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("a preset apply drops every image the previous theme brought", () => {
|
||||
const { uci } = runWithUci(CLEAR, 'clear_inherited_assets ""', [
|
||||
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
|
||||
"aurora.theme.favicon_png=hub-favicon-0123456789ab.png",
|
||||
"aurora.theme.favicon_ico=hub-favicon-0123456789ab.ico",
|
||||
"aurora.theme.pwa_icon_192=hub-app192-0123456789ab.png",
|
||||
"aurora.theme.pwa_icon_512=hub-app512-0123456789ab.png",
|
||||
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
|
||||
"aurora.theme.struct_main_bg_lqip=data:image/png;base64,AA",
|
||||
"aurora.theme.struct_main_bg_alpha=90%",
|
||||
"aurora.theme.struct_main_bg_blur=8px",
|
||||
"aurora.theme.struct_main_bg_scrim=40%",
|
||||
`aurora.theme.struct_login_bg=${bgUrl("hub-login-bg-abcdef012345.jpg")}`,
|
||||
"aurora.theme.struct_login_bg_alpha=60%",
|
||||
"aurora.theme.nav_type=sidebar",
|
||||
]);
|
||||
|
||||
// 外观本身(nav_type)不归它管,一个字都不能动
|
||||
assert.deepEqual(uci, ["aurora.theme.nav_type=sidebar"]);
|
||||
});
|
||||
|
||||
test("images the user uploaded survive a theme switch, tunables included", () => {
|
||||
const fixture = [
|
||||
"aurora.theme.logo_svg=my-logo.svg",
|
||||
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
|
||||
"aurora.theme.struct_main_bg_lqip=data:image/png;base64,AA",
|
||||
// 用户自己拉过的滑杆:图是他的,亮度也是他的
|
||||
"aurora.theme.struct_main_bg_alpha=95%",
|
||||
"aurora.theme.struct_main_bg_blur=4px",
|
||||
];
|
||||
const { uci, commits } = runWithUci(CLEAR, 'clear_inherited_assets ""', fixture);
|
||||
|
||||
assert.deepEqual(uci, [...fixture].sort());
|
||||
assert.equal(commits, 0, "nothing changed, so nothing may be committed");
|
||||
});
|
||||
|
||||
test("a slot this apply just wrote is never cleared", () => {
|
||||
const { uci } = runWithUci(CLEAR, 'clear_inherited_assets "logo_svg main_bg"', [
|
||||
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
|
||||
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
|
||||
"aurora.theme.struct_main_bg_alpha=90%",
|
||||
// 这一份 payload 没带登录背景 —— 它还是上一个主题的
|
||||
`aurora.theme.struct_login_bg=${bgUrl("hub-login-bg-abcdef012345.jpg")}`,
|
||||
"aurora.theme.struct_login_bg_blur=12px",
|
||||
]);
|
||||
|
||||
assert.deepEqual(uci, [
|
||||
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
|
||||
"aurora.theme.struct_main_bg_alpha=90%",
|
||||
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
|
||||
].sort());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clear + sweep 串起来:报上来的那个现象,以及它旁边那条不能误伤的线
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 预设那条路径的真实两步:先交还槽位,再回收没人指着的文件。分开测各自成立,
|
||||
// 串起来才回答「屏幕上还有没有那张背景、overlay 上还剩不剩那个文件」。
|
||||
function runPresetHandoff(files, fixture) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-handoff-e2e-"));
|
||||
const iconPath = join(dir, "images");
|
||||
const deviceDir = join(dir, "device");
|
||||
mkdirSync(iconPath, { recursive: true });
|
||||
mkdirSync(deviceDir, { recursive: true });
|
||||
const db = join(dir, "uci.db");
|
||||
writeFileSync(db, fixture.join("\n") + "\n");
|
||||
writeFileSync(join(dir, "uci.log"), "");
|
||||
for (const name of files) writeFileSync(join(iconPath, name), "x");
|
||||
|
||||
// sweep 读的是 `uci -q show aurora`(为了拿快捷方式的图标),而清理走的是
|
||||
// get/delete —— 同一个库两种读法。show 由库现场拼出来,否则测的就是两份彼此
|
||||
// 不同步的状态,sweep 会拿到一份空的 live 名单然后把什么都删掉。
|
||||
// uci show 的行是 key='value';sweep 的 sed 认的正是那对引号。%c/39 拼引号,
|
||||
// 免得在 sh -c 里套三层引号。
|
||||
const stub = UCI_STUB.replace(
|
||||
' case "$cmd" in',
|
||||
` case "$cmd" in
|
||||
show) awk '{ i=index($0,"="); printf "%s=%c%s%c\\n", substr($0,1,i-1), 39, substr($0,i+1), 39 }' "$UCI_DB" ;;`,
|
||||
);
|
||||
|
||||
execFileSync(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
|
||||
stub +
|
||||
slice("is_hub_asset_name") +
|
||||
slice("clear_inherited_assets") +
|
||||
slice("sweep_hub_images") +
|
||||
'\nclear_inherited_assets ""\nsweep_hub_images\n',
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UCI_DB: db, UCI_LOG: join(dir, "uci.log") },
|
||||
},
|
||||
);
|
||||
|
||||
const result = {
|
||||
uci: readFileSync(db, "utf8").split("\n").filter(Boolean).sort(),
|
||||
files: readdirSync(iconPath).sort(),
|
||||
};
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
test("the reported bug: a store background does not survive into a preset", () => {
|
||||
const { uci, files } = runPresetHandoff(
|
||||
["hub-main-bg-abcdef012345.png"],
|
||||
[
|
||||
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
|
||||
"aurora.theme.struct_main_bg_alpha=90%",
|
||||
"aurora.theme.struct_main_bg_blur=8px",
|
||||
"aurora.theme.nav_type=sidebar",
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(uci, ["aurora.theme.nav_type=sidebar"]);
|
||||
assert.deepEqual(files, [], "槽位空了,那张图就是垃圾,不能留在 overlay 上");
|
||||
});
|
||||
|
||||
test("a hub image a shortcut still points at is not swept", () => {
|
||||
// 快捷方式不归 clear 管(预设对它们没有主张),所以它的图标必须活下来 ——
|
||||
// 同时这条也证明 sweep 真的读到了 live 名单,而不是拿着一份空名单乱删。
|
||||
const { files } = runPresetHandoff(
|
||||
["hub-tb-abcdef012345.png", "hub-logo-0123456789ab.svg"],
|
||||
[
|
||||
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
|
||||
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(files, ["hub-tb-abcdef012345.png"]);
|
||||
});
|
||||
|
||||
test("the wallpaper the user uploaded is not collateral damage", () => {
|
||||
const { uci, files } = runPresetHandoff(
|
||||
["my-wallpaper.jpg", "hub-logo-0123456789ab.svg"],
|
||||
[
|
||||
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
|
||||
"aurora.theme.struct_main_bg_alpha=95%",
|
||||
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(uci, [
|
||||
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
|
||||
"aurora.theme.struct_main_bg_alpha=95%",
|
||||
].sort());
|
||||
// 用户自己的图留着;上一个主题的 logo 连槽位带文件一起走
|
||||
assert.deepEqual(files, ["my-wallpaper.jpg"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_hub_bg_tunables —— 调参跟着图走
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TUNABLES = [slice("apply_hub_bg_tunables")];
|
||||
|
||||
const payloadTunables = [
|
||||
"struct_main_bg_alpha=80%",
|
||||
"struct_main_bg_blur=",
|
||||
"struct_main_bg_scrim=30%",
|
||||
"struct_login_bg_alpha=",
|
||||
"struct_login_bg_blur=",
|
||||
"struct_login_bg_scrim=",
|
||||
].join("\n");
|
||||
|
||||
test("the landed background takes the author's tunables, missing ones reset", () => {
|
||||
const { uci } = runWithUci(
|
||||
TUNABLES,
|
||||
'HUB_BG_TUNABLES="$FIXTURE_TUNABLES"\napply_hub_bg_tunables "main_bg"',
|
||||
[
|
||||
"aurora.theme.struct_main_bg_alpha=95%",
|
||||
// 作者没调过 blur:该回到 CSS 默认,而不是继承上一个主题的值
|
||||
"aurora.theme.struct_main_bg_blur=40px",
|
||||
],
|
||||
{ FIXTURE_TUNABLES: payloadTunables },
|
||||
);
|
||||
|
||||
assert.deepEqual(uci, [
|
||||
"aurora.theme.struct_main_bg_alpha=80%",
|
||||
"aurora.theme.struct_main_bg_scrim=30%",
|
||||
].sort());
|
||||
});
|
||||
|
||||
test("a theme with no background of its own leaves the user's tunables alone", () => {
|
||||
const fixture = [
|
||||
"aurora.theme.struct_main_bg_alpha=95%",
|
||||
"aurora.theme.struct_login_bg_blur=12px",
|
||||
];
|
||||
const { uci, commits } = runWithUci(
|
||||
TUNABLES,
|
||||
'HUB_BG_TUNABLES="$FIXTURE_TUNABLES"\napply_hub_bg_tunables ""',
|
||||
fixture,
|
||||
{ FIXTURE_TUNABLES: payloadTunables },
|
||||
);
|
||||
|
||||
assert.deepEqual(uci, [...fixture].sort());
|
||||
assert.equal(commits, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 两条 apply 路径的接线与顺序
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 注释里提到某个函数名不等于调用了它 —— 顺序断言只看代码行。
|
||||
const codeOnly = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*#/.test(line))
|
||||
.join("\n");
|
||||
|
||||
test("validate_and_apply_hub_payload parks the tunables instead of batching them", () => {
|
||||
const fn = slice("validate_and_apply_hub_payload");
|
||||
assert.match(fn, /HUB_BG_TUNABLES="\$HUB_BG_TUNABLES\$key=\$value/);
|
||||
assert.equal(
|
||||
/delete aurora\.theme\.%s\\n" "\$key" >> "\$batch_file"/.test(fn),
|
||||
false,
|
||||
"tunables must not be reset before the asset step knows what landed",
|
||||
);
|
||||
});
|
||||
|
||||
test("hub apply settles the assets before it fingerprints the result", () => {
|
||||
const worker = codeOnly(slice("hub_apply_worker"));
|
||||
const tunables = worker.indexOf("apply_hub_bg_tunables");
|
||||
const clear = worker.indexOf("clear_inherited_assets");
|
||||
const sweep = worker.indexOf("sweep_hub_images");
|
||||
const record = worker.indexOf("record_active_source");
|
||||
|
||||
assert.ok(tunables !== -1 && clear !== -1);
|
||||
assert.ok(tunables < clear, "tunables are written before the slots are cleared");
|
||||
assert.ok(clear < sweep, "clearing frees the slots that sweeping then collects");
|
||||
assert.ok(sweep < record, "the fingerprint must be taken last");
|
||||
assert.match(worker, /landed="\$landed\$a_kind "/);
|
||||
});
|
||||
|
||||
test("apply_theme_preset hands back every asset the previous theme brought", () => {
|
||||
const start = rpcd.indexOf('"apply_theme_preset")');
|
||||
assert.ok(start !== -1);
|
||||
const branch = codeOnly(
|
||||
rpcd.slice(start, rpcd.indexOf('"export_config")', start)),
|
||||
);
|
||||
|
||||
const clear = branch.indexOf('clear_inherited_assets ""');
|
||||
const sweep = branch.indexOf("sweep_hub_images");
|
||||
const record = branch.indexOf("record_active_source");
|
||||
|
||||
assert.ok(clear !== -1, "a preset carries no assets, so it clears them all");
|
||||
assert.ok(clear < sweep);
|
||||
assert.ok(
|
||||
sweep < record,
|
||||
"the tunables are in theme_fingerprint -- clearing must precede recording",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { repo } from "./paths.mjs";
|
||||
|
||||
// 快捷方式和资产走同一条规矩:应用一个主题就是穿上它那一套,内置和社区没有
|
||||
// 分别 —— 主题带了就应用,没带就是没有。这一组守的是「内置预设也带快捷方式」
|
||||
// 和「指不到文件的图标不留在配置里」。
|
||||
|
||||
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
|
||||
const PRESETS = ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"];
|
||||
|
||||
function slice(name) {
|
||||
const start = rpcd.indexOf(`${name}() {`);
|
||||
assert.ok(start !== -1, `${name} not found in rpcd script`);
|
||||
const end = rpcd.indexOf("\n}\n", start) + 3;
|
||||
return rpcd.slice(start, end);
|
||||
}
|
||||
|
||||
const DEPS =
|
||||
slice("has_control_char") + slice("has_batch_unsafe_char") + slice("load_preset_toolbar");
|
||||
|
||||
function loadToolbar(templateText) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-tb-"));
|
||||
const path = join(dir, "x.template");
|
||||
writeFileSync(path, templateText);
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
DEPS +
|
||||
`\nif load_preset_toolbar "${path}"; then\n` +
|
||||
` printf 'OK\\n'; printf '%s\\n' "$PRESET_TOOLBAR"\n` +
|
||||
`else printf 'FAIL\\n'; fi\n`,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).split("\n");
|
||||
const ok = out[0] === "OK";
|
||||
return {
|
||||
ok,
|
||||
rows: ok ? out.slice(1).filter(Boolean).map((l) => l.split("\t")) : [],
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const block = (title, url, icon, enabled) =>
|
||||
[
|
||||
"config toolbar_item",
|
||||
`\toption title '${title}'`,
|
||||
`\toption url '${url}'`,
|
||||
...(icon === null ? [] : [`\toption icon '${icon}'`]),
|
||||
`\toption enabled '${enabled}'`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const wrap = (...blocks) =>
|
||||
["config aurora 'theme'", "\toption nav_type 'sidebar'", "", ...blocks].join("\n");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 每个内置预设都带着自己的快捷方式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("every built-in template carries the same four shortcuts", () => {
|
||||
let reference = null;
|
||||
for (const preset of PRESETS) {
|
||||
const file = repo(`root/usr/share/aurora/${preset}.template`);
|
||||
const { ok, rows } = loadToolbar(readFileSync(file, "utf8"));
|
||||
assert.ok(ok, `${preset}: template rejected by load_preset_toolbar`);
|
||||
assert.equal(rows.length, 4, `${preset}: expected 4 shortcuts`);
|
||||
// 图标必须是主题自带的那几张,否则接收端 drop_missing_toolbar_icons 会把
|
||||
// 它清掉,预设就成了一排没有图标的按钮。
|
||||
for (const [, , , icon] of rows) {
|
||||
assert.match(icon, /^(overview|system|software|network)\.svg$/, `${preset}: ${icon}`);
|
||||
}
|
||||
if (reference === null) reference = JSON.stringify(rows);
|
||||
else assert.equal(JSON.stringify(rows), reference, `${preset} drifted from default`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a template with no toolbar_item parses as 'no shortcuts', not as an error", () => {
|
||||
const { ok, rows } = loadToolbar(wrap());
|
||||
assert.equal(ok, true, "没有快捷方式是一个合法的主题,不是一份坏模板");
|
||||
assert.deepEqual(rows, []);
|
||||
});
|
||||
|
||||
test("shortcut fields survive parsing intact", () => {
|
||||
const { ok, rows } = loadToolbar(
|
||||
wrap(
|
||||
block("My Site", "https://example.com/a b", "my-icon.png", "1"),
|
||||
block("No Icon", "/cgi-bin/luci/admin", null, "0"),
|
||||
),
|
||||
);
|
||||
assert.ok(ok);
|
||||
// icon 排在最后且唯一可空:TAB 是 IFS 空白类,空的中间字段会让 read 把后面
|
||||
// 的字段整体左移(enabled 被读进 icon)。这条断言就是钉住那个顺序的。
|
||||
assert.deepEqual(rows, [
|
||||
["My Site", "https://example.com/a b", "1", "my-icon.png"],
|
||||
["No Icon", "/cgi-bin/luci/admin", "0", ""],
|
||||
]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 校验:与 hub 那一份同一套界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rejected = [
|
||||
["title 带单引号(会撑破 uci batch 的引号)", block("It's", "/x", "a.png", "1")],
|
||||
["title 超过 30 字", block("x".repeat(31), "/x", "a.png", "1")],
|
||||
["title 为空", block("", "/x", "a.png", "1")],
|
||||
["url 没有 scheme 也不以 / 开头", block("T", "example.com", "a.png", "1")],
|
||||
["url 是协议相对地址", block("T", "//example.com", "a.png", "1")],
|
||||
["url 超过 200 字", block("T", "/" + "x".repeat(200), "a.png", "1")],
|
||||
["enabled 不是 0/1", block("T", "/x", "a.png", "2")],
|
||||
["enabled 缺席", "config toolbar_item\n\toption title 'T'\n\toption url '/x'\n"],
|
||||
["icon 含非法字符", block("T", "/x", "a b.png", "1")],
|
||||
["icon 带路径穿越", block("T", "/x", "../../etc/passwd", "1")],
|
||||
];
|
||||
|
||||
test("load_preset_toolbar rejects what the hub payload would reject", () => {
|
||||
for (const [why, bad] of rejected) {
|
||||
const { ok } = loadToolbar(wrap(bad));
|
||||
assert.equal(ok, false, `应当拒绝:${why}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("more than 12 shortcuts is rejected, same cap as the hub", () => {
|
||||
const many = Array.from({ length: 13 }, (_, i) => block(`T${i}`, `/x${i}`, null, "1"));
|
||||
assert.equal(loadToolbar(wrap(...many)).ok, false);
|
||||
const twelve = Array.from({ length: 12 }, (_, i) => block(`T${i}`, `/x${i}`, null, "1"));
|
||||
assert.equal(loadToolbar(wrap(...twelve)).ok, true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// drop_missing_toolbar_icons:指不到文件的图标不许留在配置里
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UCI_STUB = `
|
||||
uci() {
|
||||
cmd=""; arg=""
|
||||
for a in "$@"; do
|
||||
case "$a" in -*) continue ;; esac
|
||||
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
|
||||
done
|
||||
case "$cmd" in
|
||||
# 真 uci show 的两种行:段头 aurora.<sid>=<type> 不带引号,选项
|
||||
# aurora.<sid>.<opt>='<value>' 带引号。段头按点数区分(1 个点 = 段头)。
|
||||
# 一律加引号的桩会让 drop_missing 的 sed 一个 toolbar_item 都找不到,
|
||||
# 断言于是永远成立。
|
||||
show) awk '{ i=index($0,"="); k=substr($0,1,i-1); v=substr($0,i+1);
|
||||
n=gsub(/\\./,".",k)
|
||||
if (n <= 1) printf "%s=%s\\n", k, v
|
||||
else printf "%s=%c%s%c\\n", k, 39, v, 39 }' "$UCI_DB" ;;
|
||||
# 按字面前缀比,不能用 grep:uci 的匿名段 id 长成 @toolbar_item[0],里面的
|
||||
# [0] 会被 grep 当成字符类,于是任何带匿名段的键都查不到、也删不掉。
|
||||
get)
|
||||
awk -v k="$arg" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); f = 1; exit }
|
||||
END { if (!f) exit 1 }' "$UCI_DB" || return 1
|
||||
;;
|
||||
delete)
|
||||
awk -v k="$arg" 'index($0, k "=") != 1' "$UCI_DB" > "$UCI_DB.t"
|
||||
mv "$UCI_DB.t" "$UCI_DB" ;;
|
||||
commit) : ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
`;
|
||||
|
||||
function runDrop(files, fixture, { iconDir = true } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "aurora-drop-"));
|
||||
const iconPath = join(dir, "images");
|
||||
if (iconDir) {
|
||||
execFileSync("mkdir", ["-p", iconPath]);
|
||||
for (const name of files) writeFileSync(join(iconPath, name), "x");
|
||||
}
|
||||
const db = join(dir, "uci.db");
|
||||
writeFileSync(db, fixture.join("\n") + "\n");
|
||||
try {
|
||||
execFileSync(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
`ICON_PATH="${iconPath}"\n` +
|
||||
UCI_STUB +
|
||||
slice("drop_missing_toolbar_icons") +
|
||||
"\ndrop_missing_toolbar_icons\n",
|
||||
],
|
||||
{ encoding: "utf8", env: { ...process.env, UCI_DB: db } },
|
||||
);
|
||||
return readFileSync(db, "utf8").split("\n").filter(Boolean).sort();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("an icon whose bytes never landed is cleared, not left as a dead name", () => {
|
||||
const uci = runDrop(
|
||||
["hub-tb-abcdef012345.png", "overview.svg"],
|
||||
[
|
||||
"aurora.@toolbar_item[0]=toolbar_item",
|
||||
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
|
||||
"aurora.@toolbar_item[1]=toolbar_item",
|
||||
// 作者机器上的文件名,本机没有这张图
|
||||
"aurora.@toolbar_item[1].icon=authors-icon.png",
|
||||
"aurora.@toolbar_item[2]=toolbar_item",
|
||||
"aurora.@toolbar_item[2].icon=overview.svg",
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(uci, [
|
||||
"aurora.@toolbar_item[0]=toolbar_item",
|
||||
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
|
||||
"aurora.@toolbar_item[1]=toolbar_item",
|
||||
"aurora.@toolbar_item[2]=toolbar_item",
|
||||
"aurora.@toolbar_item[2].icon=overview.svg",
|
||||
].sort());
|
||||
});
|
||||
|
||||
test("a path-traversal icon name is cleared without being stat'ed", () => {
|
||||
const uci = runDrop(
|
||||
[],
|
||||
[
|
||||
"aurora.@toolbar_item[0]=toolbar_item",
|
||||
"aurora.@toolbar_item[0].icon=../../etc/passwd",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(uci, ["aurora.@toolbar_item[0]=toolbar_item"]);
|
||||
});
|
||||
|
||||
test("no image directory at all means the theme package is missing, not that every icon is dead", () => {
|
||||
const fixture = [
|
||||
"aurora.@toolbar_item[0]=toolbar_item",
|
||||
"aurora.@toolbar_item[0].icon=overview.svg",
|
||||
];
|
||||
assert.deepEqual(runDrop([], fixture, { iconDir: false }), [...fixture].sort());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 接线
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const codeOnly = (text) =>
|
||||
text.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n");
|
||||
|
||||
test("apply_theme_preset replaces the shortcut sections the way hub apply does", () => {
|
||||
const start = rpcd.indexOf('"apply_theme_preset")');
|
||||
const branch = codeOnly(rpcd.slice(start, rpcd.indexOf('"export_config")', start)));
|
||||
|
||||
assert.match(branch, /load_preset_toolbar "\$template"/);
|
||||
assert.match(branch, /delete aurora\.@toolbar_item\[0\]/);
|
||||
assert.match(branch, /add aurora toolbar_item/);
|
||||
assert.ok(
|
||||
branch.indexOf("delete aurora.@toolbar_item[0]") <
|
||||
branch.indexOf("add aurora toolbar_item"),
|
||||
"先删光再重建,否则预设的快捷方式会叠在上一个主题的后面",
|
||||
);
|
||||
});
|
||||
|
||||
// `uci batch` 会把 `add` 新建的匿名 section 名(cfg0686e6…)打到 stdout 上。
|
||||
// apply_theme_preset 是同步 handler,它的 stdout 就是 ubus 的应答体 —— 那几行
|
||||
// 名字排在 `{ "result": 0 }` 前面,rpcd 解析 JSON 失败,回 INVALID_ARGUMENT,
|
||||
// 前端于是报「应用失败:未知」,而配置其实已经 commit 了。
|
||||
test("every uci batch swallows its stdout, or the section names land in the JSON reply", () => {
|
||||
const lines = rpcd.split("\n");
|
||||
const offenders = lines
|
||||
.map((line, i) => ({ line, no: i + 1 }))
|
||||
.filter(({ line }) => /\|\s*uci batch\b/.test(line) && !/^\s*#/.test(line))
|
||||
.filter(({ line }) => !/\|\s*uci batch\s+[^|]*>\s*\/dev\/null/.test(line));
|
||||
|
||||
assert.deepEqual(
|
||||
offenders.map(({ no }) => no),
|
||||
[],
|
||||
`uci batch 未吞掉 stdout(行 ${offenders.map((o) => o.no).join(", ")})`,
|
||||
);
|
||||
});
|
||||
|
||||
test("hub apply drops dead icons after the icon assets have landed", () => {
|
||||
const worker = codeOnly(slice("hub_apply_worker"));
|
||||
assert.ok(
|
||||
worker.indexOf("apply_hub_toolbar_icon") < worker.indexOf("drop_missing_toolbar_icons"),
|
||||
"抢在资产步前面跑,会把还没落地的图标全清掉",
|
||||
);
|
||||
assert.ok(
|
||||
worker.indexOf("drop_missing_toolbar_icons") < worker.indexOf("record_active_source"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the sharing side still ships every icon name, so both ends number alike", () => {
|
||||
// 分享端一旦因为「传不了」省掉某个名字,接收端 nth_custom_toolbar_icon 数出
|
||||
// 来的序号就整体左移,后面的图标全部张冠李戴。兜底只能在接收端。
|
||||
const fn = slice("build_share_payload");
|
||||
assert.match(fn, /\[ -n "\$icon" \] && json_add_string "icon" "\$icon"/);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = repo("root/usr/libexec/rpcd/luci.aurora");
|
||||
|
||||
test("rpcd defines shared MAX_UPLOAD and receive_upload()", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /readonly MAX_UPLOAD=8388608/);
|
||||
assert.ok(!src.includes("FONT_MAX_UPLOAD"), "FONT_MAX_UPLOAD must be renamed");
|
||||
assert.match(src, /^receive_upload\(\)/m);
|
||||
});
|
||||
|
||||
test("upload_font and upload_icon route through receive_upload", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /receive_upload "\$FONT_TMP_UPLOAD" font \|\| exit 0/);
|
||||
assert.match(
|
||||
src,
|
||||
/receive_upload "\$TMP_UPLOAD_PATH" image "\$filename" \|\| exit 0/,
|
||||
);
|
||||
});
|
||||
|
||||
test("image allowlist covers favicon .ico and every advertised format", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
// The gate and the picker offer the same set, and it is the set the hub
|
||||
// will take: no format you can put on the router is one you cannot share.
|
||||
assert.match(src, /jpg\|jpeg\|png\|webp\|svg\|ico/);
|
||||
assert.ok(!/\bavif\b/.test(src), "avif is not a format any asset slot can use");
|
||||
});
|
||||
|
||||
test("list endpoints expose byte sizes", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /json_add_int "size" "\$csize"/);
|
||||
assert.match(src, /json_add_object "icon_sizes"/);
|
||||
});
|
||||
|
||||
test("both upload mv sites are guarded and clean tmp on failure", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /if ! mv "\$FONT_TMP_UPLOAD"/);
|
||||
assert.match(src, /if mv "\$TMP_UPLOAD_PATH"/);
|
||||
const storeErrors = src.match(/Failed to store file/g) || [];
|
||||
assert.equal(storeErrors.length, 2, "font and icon mv failure paths");
|
||||
});
|
||||
|
||||
test("remove_icon clears every uci slot that named the deleted file", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const start = src.indexOf('"remove_icon")');
|
||||
const end = src.indexOf('"write_pwa_manifest")');
|
||||
assert.ok(start !== -1 && end > start, "remove_icon handler missing");
|
||||
const body = src.slice(start, end);
|
||||
|
||||
// 删掉的图片可能同时是 logo/favicon/PWA 图标/iOS 主屏图标/工具栏快捷方式的
|
||||
// 图标 —— 只清一部分就会让 uci 指向一个已经不存在的文件:主题 404,分享
|
||||
// 清单还在承诺它。
|
||||
assert.match(
|
||||
body,
|
||||
/for icon_slot in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 pwa_apple_touch; do/,
|
||||
"every theme-level image slot must be cleared, not just the login background",
|
||||
);
|
||||
assert.match(body, /uci -q delete "aurora\.theme\.\$icon_slot"/);
|
||||
assert.match(body, /uci -q delete aurora\.theme\.struct_login_bg$/m);
|
||||
|
||||
// 工具栏项的 icon 也指向同一批上传图标,而且 build_share_payload 会把它
|
||||
// 当普通字符串发到 hub —— 悬空的名字会被每个应用者继承。
|
||||
assert.match(
|
||||
body,
|
||||
/sed -n 's\/\^aurora\\\.\\\(\[\^\.=\]\*\\\)=toolbar_item\$\/\\1\/p'/,
|
||||
"anonymous toolbar_item sections must be enumerated the way build_share_payload does",
|
||||
);
|
||||
assert.match(
|
||||
body,
|
||||
/uci -q get "aurora\.\$toolbar_sid\.icon"/,
|
||||
"each toolbar shortcut's icon must be checked against the deleted file",
|
||||
);
|
||||
assert.match(body, /uci -q delete "aurora\.\$toolbar_sid\.icon"/);
|
||||
assert.ok(
|
||||
!/\[[0-9]+\]\.icon/.test(body),
|
||||
"toolbar sections are anonymous -- never address them by index",
|
||||
);
|
||||
|
||||
// 既有的 .. 防护必须留着
|
||||
assert.ok(
|
||||
body.includes('dirname "$filename" | grep -q "\\.\\."'),
|
||||
"the .. traversal guard must survive",
|
||||
);
|
||||
|
||||
// 仍然只提交一次,且只在真的改了东西时提交
|
||||
const commits = body.match(/uci -q commit aurora/g) || [];
|
||||
assert.equal(commits.length, 1, "exactly one commit for the whole removal");
|
||||
assert.match(
|
||||
body,
|
||||
/\[ "\$icon_slot_cleared" = 1 \] && uci -q commit aurora/,
|
||||
"the commit must be conditional on something having changed",
|
||||
);
|
||||
|
||||
// BusyBox ash:没有数组、没有 [[、函数外没有 local
|
||||
assert.ok(!body.includes("[["), "no bash [[ in remove_icon");
|
||||
assert.ok(!/^\s*local /m.test(body), "no local outside a function");
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("view/aurora/studio.js");
|
||||
|
||||
// 背景组件的版式 CSS 曾寄养在 luci-theme-aurora 的按需补丁
|
||||
// patches/admin-system-aurora.css 里。装在别的主题下(shadcn 有同样的补丁
|
||||
// 机制、没有这张表)补丁根本不加载,.bg-preview 丢掉 position/height/overflow,
|
||||
// 内部八层 absolute 逃到初始包含块糊满全页。app 自己的组件版式归 app,
|
||||
// 这几条断言就是不让它再漂回主题。
|
||||
|
||||
test("the background component ships its own layout, not a theme patch", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /const ensureBgCardStyles = /);
|
||||
assert.match(src, /id: "aurora-bg-card-styles"/);
|
||||
// 渲染路径上必须真的调用,否则注入函数只是死代码
|
||||
assert.match(src, /ensureBgCardStyles\(\);/);
|
||||
for (const cls of [
|
||||
".bg-duo",
|
||||
".bg-preview",
|
||||
".bg-srow",
|
||||
".bg-pane",
|
||||
".bg-card-head",
|
||||
"[data-bg-target]",
|
||||
]) {
|
||||
assert.ok(src.includes(cls + " {") || src.includes(cls + ","), `${cls} 缺版式规则`);
|
||||
}
|
||||
});
|
||||
|
||||
// 组件立在这一条上:预览的图层全是 absolute,它必须是那个定位、限高、裁剪的祖先。
|
||||
test("the preview stays the positioned, sized, clipping ancestor", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const rule = src.match(/\.bg-preview \{[^}]*\}/);
|
||||
assert.ok(rule, ".bg-preview rule missing");
|
||||
for (const decl of ["position: relative", "overflow: hidden", "height: 130px"]) {
|
||||
assert.ok(rule[0].includes(decl), `.bg-preview must declare ${decl}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 主题词汇表不通用:shadcn 定义了 --surface/--bg/--brand,却没有
|
||||
// --hairline/--text-subtle/--text-muted/--control-bg。裸 var() 会整条声明作废,
|
||||
// 边框和次要文字就这么消失的。别名一律走 fallback 链,组件内联样式只认别名。
|
||||
test("every theme value the component reads has a fallback chain", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const aliases = [
|
||||
"--bgp-line",
|
||||
"--bgp-subtle",
|
||||
"--bgp-muted",
|
||||
"--bgp-control",
|
||||
"--bgp-surface",
|
||||
"--bgp-bg",
|
||||
"--bgp-text",
|
||||
"--bgp-brand",
|
||||
"--bgp-radius",
|
||||
];
|
||||
for (const alias of aliases) {
|
||||
const decl = src.match(
|
||||
new RegExp(`${alias}: var\\(--[a-z-]+, var\\(--[a-z-]+, [^;]+\\)\\);`),
|
||||
);
|
||||
assert.ok(decl, `${alias} must resolve through a two-step fallback chain`);
|
||||
}
|
||||
// 组件自己的样式只准引用别名 —— 注入表和 buildBgPreview 的内联样式两处。
|
||||
// 切片刻意只圈这两段:页面别处(字体管理器、资产库)仍直接用 Aurora token,
|
||||
// 那是另一码事,不该被这条断言连坐。
|
||||
const region = (from, to) =>
|
||||
src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from)));
|
||||
const component = [
|
||||
region("const ensureBgCardStyles", "const enhanceColorTokenGroups"),
|
||||
region("const buildBgPreview", "const addBackgroundOption"),
|
||||
].join("\n");
|
||||
const bare = component.match(
|
||||
/var\(--(?:hairline|text-subtle|text-muted|control-bg|surface|bg|text|brand)\)/g,
|
||||
);
|
||||
assert.equal(
|
||||
bare,
|
||||
null,
|
||||
`bare theme tokens left in the component: ${bare?.join(", ")}`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { repo, srcPath } from "./paths.mjs";
|
||||
|
||||
const SRC = srcPath("view/aurora/studio.js");
|
||||
|
||||
test("layout: the workbench shell is gone", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(!src.includes("aurora-wb-"), "no aurora-wb-* class may remain");
|
||||
assert.ok(!src.includes("buildWorkbench"), "buildWorkbench must be deleted");
|
||||
assert.ok(!src.includes("WB_CSS"), "WB_CSS must be deleted");
|
||||
assert.ok(
|
||||
!src.includes("refreshPreview"),
|
||||
"the preview canvas hook must be deleted",
|
||||
);
|
||||
assert.ok(
|
||||
!/require utils\.theme-preview/.test(src),
|
||||
"studio.js must stop requiring theme-preview (marketplace.js still uses the file)",
|
||||
);
|
||||
});
|
||||
|
||||
// LuCI already names this page in the tab strip directly above the content
|
||||
// (menu.d: "Design Studio"). form.Map renders its own title as an <h2> right
|
||||
// under that strip, so handing it one printed the same two words twice, a
|
||||
// hand's width apart. The tab is the one that has to stay: it is how you get
|
||||
// to the other page.
|
||||
test("layout: the tab strip names the page, so the map carries no title", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /new form\.Map\("aurora"\)/, "form.Map must take no title");
|
||||
assert.ok(
|
||||
!/new form\.Map\("aurora",\s*_\(/.test(src),
|
||||
"no title string may be handed to form.Map",
|
||||
);
|
||||
// description is a separate slot in form.js and still renders on its own,
|
||||
// which is what keeps the version chips and the export/import/reset row.
|
||||
assert.match(src, /m\.description = headerBar/);
|
||||
});
|
||||
|
||||
test("layout: handleReset restages colors instead of reloading the page", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(
|
||||
!/handleReset[\s\S]{0,400}window\.location\.reload/.test(src),
|
||||
"handleReset must not reload the page",
|
||||
);
|
||||
assert.match(src, /this\.super\("handleReset", \[ev\]\)/);
|
||||
assert.match(src, /this\.colorEditor\?\.schedule\("light"\)/);
|
||||
assert.match(src, /this\.colorEditor\?\.schedule\("dark"\)/);
|
||||
});
|
||||
|
||||
test("layout: the header has no second Marketplace entry and no preset dropdown", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(
|
||||
!src.includes("Browse more themes in the Marketplace"),
|
||||
"tabmenu already links the store; a header button is a duplicate entry",
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes("callApplyThemePreset"),
|
||||
"presets are the store's job now",
|
||||
);
|
||||
assert.match(src, /buildConfigToolbarNode/);
|
||||
assert.match(src, /id: "theme-version"/);
|
||||
assert.match(src, /id: "config-version"/);
|
||||
assert.match(
|
||||
src,
|
||||
/const versionArea = E\(/,
|
||||
"the version area must be a named node — the update capsule appends to it",
|
||||
);
|
||||
});
|
||||
|
||||
test("colors: source tokens hang straight off the mode sub-tab", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.ok(
|
||||
!src.includes("_base_colors"),
|
||||
"the Source Color Tokens SectionValue wrapper must be gone",
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes("Source Color Tokens"),
|
||||
"its title string goes with it",
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/option: \(\.\.\.args\) => section\.taboption\(mode, \.\.\.args\)/,
|
||||
"source colors are added through a taboption adapter",
|
||||
);
|
||||
});
|
||||
|
||||
test("colors: derived tokens keep exactly one wrapper, without a title", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const block = src.slice(
|
||||
src.indexOf("const createColorSections ="),
|
||||
src.indexOf("const colorGroupFor ="),
|
||||
);
|
||||
const wrappers = (block.match(/form\.SectionValue/g) || []).length;
|
||||
assert.equal(wrappers, 1, "only the derived group may keep a wrapper");
|
||||
assert.ok(
|
||||
!block.includes("Derived Color Tokens"),
|
||||
"the wrapper renders no title -- the fold's summary carries the label",
|
||||
);
|
||||
});
|
||||
|
||||
test("colors: the format help moved to the sub-tab description", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(
|
||||
src,
|
||||
/colorSubsection\.tab\("light", _\("Light Mode"\), COLOR_TAB_HINT\)/,
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/colorSubsection\.tab\("dark", _\("Dark Mode"\), COLOR_TAB_HINT\)/,
|
||||
);
|
||||
assert.match(src, /const COLOR_TAB_HINT =/);
|
||||
});
|
||||
|
||||
test("colors: token groups are borderless heading + rule, not boxes", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
// The group itself declares only a margin -- no box around it.
|
||||
const box = /\.aurora-token-group,\n\.aurora-derived-fold \{([^}]*)\}/.exec(src);
|
||||
assert.ok(box, "the two folds should share one box rule");
|
||||
assert.ok(!/border/.test(box[1]), "the group box border is gone");
|
||||
assert.ok(!/border-radius/.test(box[1]), "the group box radius is gone");
|
||||
// The rule lives on the summary instead, shared by both folds.
|
||||
const summary = /\.aurora-token-group > summary,\n\.aurora-derived-fold > summary \{([^}]*)\}/.exec(src);
|
||||
assert.ok(summary, "both summaries should share one rule");
|
||||
assert.match(summary[1], /border-bottom:/, "the summary carries the dividing rule");
|
||||
});
|
||||
|
||||
test("colors: derived tokens collapse into a single fold", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /const enhanceDerivedFold = \(root\) => \{/);
|
||||
assert.match(src, /class: "aurora-derived-fold"/);
|
||||
assert.ok(
|
||||
!/aurora-derived-fold"[\s\S]{0,120}open: ""/.test(src),
|
||||
"the derived fold must start collapsed",
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/enhanceColorTokenGroups\(mapNode\);\s*\n\s*enhanceDerivedFold\(mapNode\);/,
|
||||
"the fold runs after the groups are built",
|
||||
);
|
||||
});
|
||||
|
||||
test("nav: the style picker stays a ListValue so depends() keeps working", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const option = src.slice(
|
||||
src.indexOf('"nav_type",\n _("Navigation Style")'),
|
||||
src.indexOf('"struct_spacing"'),
|
||||
);
|
||||
assert.match(option, /so\.widget = "radio"/, "radio widget, not a bare DOM control");
|
||||
assert.match(
|
||||
option,
|
||||
/so\.renderWidget = renderNavChoiceWidget/,
|
||||
"the option only points at the decorator",
|
||||
);
|
||||
|
||||
const widget = src.slice(
|
||||
src.indexOf("const renderNavChoiceWidget ="),
|
||||
src.indexOf("const renderColorField ="),
|
||||
);
|
||||
assert.match(
|
||||
widget,
|
||||
/form\.ListValue\.prototype\.renderWidget\.apply\(this, arguments\)/,
|
||||
"the parent widget must still produce the inputs -- change/depends ride on it",
|
||||
);
|
||||
});
|
||||
|
||||
test("nav: the wireframe goes before the input, never between input/label/text", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
// ui.Select's radio markup is span.cbi-radio > [input, label, span(text)],
|
||||
// and the caption span's click handler walks previousElementSibling twice to
|
||||
// reach the input. Splitting that chain silently kills click-to-select.
|
||||
assert.match(src, /insertBefore\(drawing, input\)/);
|
||||
assert.ok(
|
||||
!/closest\("label"\)/.test(src),
|
||||
"there is no wrapping label in LuCI's radio markup",
|
||||
);
|
||||
});
|
||||
|
||||
test("nav: three wireframes, no images, no network", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /const NAV_CHOICE_WIREFRAMES = \{/);
|
||||
const block = src.slice(
|
||||
src.indexOf("const NAV_CHOICE_WIREFRAMES = {"),
|
||||
src.indexOf("const ensureNavChoiceStyles"),
|
||||
);
|
||||
["mega-menu", "dropdown", "sidebar"].forEach((key) => {
|
||||
assert.ok(
|
||||
new RegExp(`"${key}":|${key}:`).test(block),
|
||||
`missing wireframe for ${key}`,
|
||||
);
|
||||
});
|
||||
assert.ok(!/<img|url\(|fetch\(/.test(block), "wireframes must be pure CSS");
|
||||
});
|
||||
|
||||
test("nav: content width still depends on nav_type and is retained", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /so\.retain = true;/);
|
||||
assert.match(src, /so\.depends\("nav_type", "mega-menu"\);/);
|
||||
assert.match(src, /so\.depends\("nav_type", "dropdown"\);/);
|
||||
});
|
||||
|
||||
test("layout: the three outer tabs are back", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
// The workbench replaced these with an accordion. Restoring the map without
|
||||
// restoring them stacks every section into one endless page -- which no
|
||||
// source assertion caught, only a screenshot did.
|
||||
assert.match(src, /s\.tab\("colors", _\("Colors"\)\)/);
|
||||
assert.match(src, /s\.tab\("layout_typography", _\("Layout & Typography"\)\)/);
|
||||
assert.match(src, /s\.tab\("icons_branding", _\("Branding & Shortcuts"\)\)/);
|
||||
["colorSection", "structureSection", "fontSection", "assetSection",
|
||||
"logoSection", "toolbarSection"].forEach((name) => {
|
||||
assert.match(
|
||||
src,
|
||||
new RegExp(`const ${name} = s\\.taboption\\(`),
|
||||
`${name} must hang off a tab, not the bare section`,
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
!/= s\.option\(\s*\n\s*form\.SectionValue/.test(src),
|
||||
"no top-level SectionValue may bypass the tabs",
|
||||
);
|
||||
});
|
||||
|
||||
test("the two engine scripts download in parallel and still execute in order", async () => {
|
||||
const source = await readFile(srcPath("view/aurora/studio.js"), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/script\.async = false/,
|
||||
"a dynamically inserted script defaults to async; without this the two " +
|
||||
"engine files race and tokens.global.js can evaluate before Color exists",
|
||||
);
|
||||
assert.ok(
|
||||
!/await loadGlobalScript\("utils\/color\.global\.js"\);\s*\n\s*if \(typeof AuroraTokens/.test(
|
||||
source,
|
||||
),
|
||||
"awaiting the first script before requesting the second costs a second round trip",
|
||||
);
|
||||
});
|
||||
|
||||
// 发布的起点在工作台,不在商店。导出/导入/重置都是对"整套配置"动手,分享是
|
||||
// 同一族的事 —— 导出是存给自己,分享是发给别人。商店那边三处发布入口因此
|
||||
// 全部删掉(见 marketplace-view.test.mjs)。
|
||||
test("studio: publishing starts here, next to export/import", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /_\("Share to the store"\)/);
|
||||
// 意图用 URL 参数传,不用 sessionStorage:刷新、回退、收藏行为都可预测。
|
||||
assert.match(src, /L\.url\("admin\/system\/aurora\/marketplace"\)/);
|
||||
assert.match(src, /"\?share=1"/);
|
||||
// One primary action; export, import and reset sit behind "More".
|
||||
assert.match(
|
||||
src,
|
||||
/shareButton,\s*buildMoreMenu\(\[exportItem, importItem, E\("li", \{ role: "separator", class: "sep" \}\), resetItem\]\),/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header: a quiet version line, one primary action, the rest behind "More"
|
||||
|
||||
const headerBlock = (src) =>
|
||||
src.slice(src.indexOf(" const versionEntry ="), src.indexOf(" m.description = headerBar;"));
|
||||
|
||||
test("header: each package is its muted name, then its full installed version in the theme's green .label", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const head = headerBlock(src);
|
||||
assert.match(head, /E\("span", attrs, \[\s*E\("b", \{\}, label\),/);
|
||||
// Current is the theme's own success colour, and the version is shown whole.
|
||||
assert.match(head, /\{ class: installed \? "label success" : "label" \},\s*installed \|\| _\("Unknown"\),/);
|
||||
assert.ok(!src.includes("shortVersion"));
|
||||
assert.match(head, /versionEntry\(\{ id: "theme-version" \}, _\("Theme"\), installedVersions\?\.theme\?\.installed_version\)/);
|
||||
assert.match(head, /versionEntry\(\{ id: "config-version" \}, _\("Config"\), installedVersions\?\.config\?\.installed_version\)/);
|
||||
assert.match(head, /const versionArea = E\("div", \{ class: "aurora-studio-versions" \}/);
|
||||
// Nothing about an update exists until the manifest says so.
|
||||
for (const later of ['"arrow"', '"up"', 'E("a"', '"label warning"']) assert.ok(!head.includes(later), later);
|
||||
// The old separate pill and the "Theme: " prefixes are gone for good.
|
||||
for (const gone of ['_("Update available %s")', "`v${", '_("Theme: ")', '_("Config: ")'])
|
||||
assert.ok(!src.includes(gone), gone);
|
||||
});
|
||||
|
||||
test("header: a newer build turns that package's label to warning and hangs → link after it; the other stays green", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const block = src.slice(src.indexOf("const showUpdateCapsule ="), src.indexOf('const s = m.section(form.NamedSection'));
|
||||
assert.match(block, /\["luci-theme-aurora", "theme-version", installedVersions\?\.theme\?\.installed_version\],/);
|
||||
assert.match(block, /\["luci-app-aurora-config", "config-version", installedVersions\?\.config\?\.installed_version\],/);
|
||||
assert.match(block, /packages\.forEach\(\(\[pkg, id, installed\]\) => \{/);
|
||||
assert.match(block, /feedCheck\.findManifestVersion\(\s*manifest,\s*channel,\s*format,\s*pkg,\s*\)/);
|
||||
// Versions that cannot be ordered say nothing at all: the label stays green, no arrow, no link.
|
||||
assert.match(block, /if \(!feedCheck\.isNewer\(installed, available\)\) return;/);
|
||||
assert.match(block, /const entry = versionArea\.querySelector\("#" \+ id\);/);
|
||||
assert.match(
|
||||
block,
|
||||
/entry\.classList\.add\("up"\);\s*entry\.querySelector\("\.label"\)\.className = "label warning";\s*entry\.appendChild\(E\("span", \{ class: "arrow", "aria-hidden": "true" \}, "→"\)\);/,
|
||||
);
|
||||
assert.match(block, /E\("a", \{ href: L\.url\(packagePagePath\) \}, \[label\]\)/);
|
||||
assert.match(block, /const label = _\("%s available"\)\.format\(available\);/);
|
||||
assert.match(src, /const packagePagePath = feedCheck\.pickPackageManagerPath\(menuTree\);/);
|
||||
assert.ok(!block.includes("innerHTML"));
|
||||
// It stays on this line: nothing here feeds the inbox or the header count.
|
||||
for (const inboxHook of ["inboxIndicator", "hubApi", "noticesCache", "setNoticesLocal"])
|
||||
assert.ok(!src.includes(inboxHook), inboxHook);
|
||||
});
|
||||
|
||||
test("header: the row's colours are the theme's .label classes; the new rules only space and align", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const css = src.slice(src.indexOf("const ensureToolbarStyles"), src.indexOf("const ensureBgCardStyles"));
|
||||
const rowRules = [...css.matchAll(/^(\.aurora-studio-versions[^{]*) \{([^}]*)\}/gm)];
|
||||
const label = rowRules.find((m) => m[1] === ".aurora-studio-versions .label");
|
||||
assert.ok(label, "the label only gets spacing and numerals");
|
||||
assert.deepEqual(
|
||||
label[2].trim().split(/;\s*/).filter(Boolean).map((d) => d.split(":")[0].trim()).sort(),
|
||||
["font-variant-numeric", "margin-left"],
|
||||
);
|
||||
const arrow = rowRules.find((m) => m[1] === ".aurora-studio-versions .arrow");
|
||||
assert.ok(arrow && !/color|background/.test(arrow[2]), "the arrow inherits the muted row colour");
|
||||
// No colour of our own on any label: success and warning are the theme's.
|
||||
assert.ok(!/\.label\.(success|warning)|\.label[^{]*\{[^}]*(color|background)/.test(css));
|
||||
assert.match(css, /\.aurora-studio-versions \{\s*color: var\(--text-subtle,/);
|
||||
assert.match(css, /\.aurora-studio-versions b \{\s*color: var\(--text-muted,/);
|
||||
});
|
||||
|
||||
test("header: Share is the one button; Export, Import and Reset are menu items with their handlers intact", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const bar = src.slice(src.indexOf("const buildConfigToolbarNode ="), src.indexOf("const buildMoreMenu ="));
|
||||
assert.equal((bar.match(/E\(\s*"button",\s*\{\s*class: "cbi-button/g) || []).length, 1);
|
||||
assert.match(bar, /class: "cbi-button cbi-button-action",\s*click: \(\) => \{\s*window\.location\.href =/);
|
||||
for (const [name, label] of [
|
||||
["exportItem", "Export configuration"],
|
||||
["importItem", "Import configuration"],
|
||||
["resetItem", "Reset everything to defaults"],
|
||||
]) {
|
||||
const item = bar.slice(bar.indexOf(`const ${name} = E(`));
|
||||
assert.match(item, /^const \w+ = E\(\s*"li",\s*\{\s*role: "menuitem",\s*tabindex: "-1",/);
|
||||
assert.ok(bar.includes(`_("${label}")`), label);
|
||||
}
|
||||
assert.match(bar, /const resetItem = E\(\s*"li",\s*\{\s*role: "menuitem",\s*tabindex: "-1",\s*class: "danger",/);
|
||||
// Same flows as before: only the entry points moved.
|
||||
assert.match(bar, /L\.resolveDefault\(callExportConfig\(\), null\)/);
|
||||
assert.match(bar, /\.uploadFile\(CONFIG_IMPORT_PATH, btn\)/);
|
||||
assert.match(bar, /ui\.showModal\(_\("Import Aurora Configuration\?"\)/);
|
||||
assert.match(bar, /L\.resolveDefault\(\s*callImportConfig\(\),/);
|
||||
assert.match(bar, /ui\.showModal\(_\("Reset All Aurora Settings"\)/);
|
||||
assert.match(bar, /L\.resolveDefault\(callResetDefaults\(\), \{\}\)/);
|
||||
// No version text in the menu.
|
||||
assert.ok(!/installed_version|shortVersion/.test(bar));
|
||||
});
|
||||
|
||||
test("header: the More menu is LuCI's dropdown shell, made operable as a menu", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const menu = src.slice(src.indexOf("const buildMoreMenu ="), src.indexOf("const FONT_DEFAULT_STACKS") > src.indexOf("const buildMoreMenu =") ? src.indexOf("const FONT_DEFAULT_STACKS") : src.indexOf("const versionEntry ="));
|
||||
assert.match(menu, /class: "dropdown", role: "menu", id: "aurora-studio-more-menu", tabindex: "-1"/);
|
||||
assert.match(menu, /class: "cbi-dropdown cbi-button aurora-studio-more",/);
|
||||
for (const attr of ['role: "button"', 'tabindex: "0"', '"aria-label": _("More")', '"aria-haspopup": "menu"', '"aria-expanded": "false"', '"aria-controls": menu.id'])
|
||||
assert.ok(menu.includes(attr), attr);
|
||||
// [open] is what every theme's .cbi-dropdown > ul.dropdown keys its popover on.
|
||||
assert.match(menu, /toggle\.setAttribute\("open", ""\);\s*toggle\.setAttribute\("aria-expanded", "true"\);/);
|
||||
assert.match(menu, /toggle\.removeAttribute\("open"\);\s*toggle\.setAttribute\("aria-expanded", "false"\);/);
|
||||
assert.match(menu, /entries\(\)\[0\]\.focus\(\);/);
|
||||
assert.match(menu, /if \(ev\.key === "Escape" && isOpen\) close\(true\);/);
|
||||
assert.match(menu, /if \(refocus\) toggle\.focus\(\);/);
|
||||
assert.match(menu, /\["ArrowDown", "ArrowUp", "Home", "End"\]\.includes\(ev\.key\)/);
|
||||
assert.match(menu, /else if \(ev\.key === "Tab"\) close\(false\);/);
|
||||
assert.match(menu, /if \(!toggle\.contains\(ev\.target\)\) close\(false\);/);
|
||||
assert.match(menu, /document\.addEventListener\("click", onOutside\);/);
|
||||
assert.match(menu, /document\.removeEventListener\("click", onOutside\);/);
|
||||
assert.match(menu, /if \(menu\.contains\(ev\.target\)\) close\(false\);/);
|
||||
});
|
||||
|
||||
test("header: the new rules cover only what the theme's dropdown and buttons cannot say", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
const css = src.slice(src.indexOf("const ensureToolbarStyles"), src.indexOf("const ensureBgCardStyles"));
|
||||
// The popover surface, its items and their hover are the theme's.
|
||||
for (const themeOwned of ["surface-overlay", "box-shadow", "backdrop", "border-radius: 1", ":hover {\n background"])
|
||||
assert.ok(!css.includes(themeOwned), themeOwned);
|
||||
assert.match(css, /\.cbi-dropdown\.aurora-studio-more > ul\.dropdown \{\s*left: auto;\s*min-width: 0;\s*right: 0;\s*top: calc\(100% \+ 6px\);\s*width: min\(280px, calc\(100vw - 32px\)\);/);
|
||||
assert.match(css, /li\.danger \{\s*color: var\(--danger,/);
|
||||
const phone = css.slice(css.indexOf("@media (max-width: 600px) {"));
|
||||
assert.match(phone, /\.aurora-studio-acts \{\s*flex: 1 1 100%;/);
|
||||
assert.match(phone, /\.aurora-studio-acts > \.cbi-button:not\(\.aurora-studio-more\) \{\s*flex: 1;\s*min-height: 42px;/);
|
||||
assert.match(phone, /\.cbi-dropdown\.aurora-studio-more \{\s*height: 42px;\s*width: 42px;/);
|
||||
assert.match(phone, /@media \(max-width: 600px\), \(hover: none\) \{[\s\S]*li\[role="menuitem"\] \{\s*min-height: 42px;/);
|
||||
});
|
||||
|
||||
test("the closed more-menu list takes no space", async () => {
|
||||
const src = await readFile(SRC, "utf8");
|
||||
assert.match(src, /\.cbi-dropdown\.aurora-studio-more:not\(\[open\]\) > ul\.dropdown \{\s*display: none;\s*\}/);
|
||||
});
|
||||
Loaded 100 of 766 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user