update 2026-10-14 04:56:49

This commit is contained in:
action committed 2026-10-14 04:56:49 +08:00
1 parent 1e69cad46b
commit c90a1f4fc1
766 files changed
+181363 -31

No files matched your search

+111
View File
@@ -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))
+156
View File
@@ -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 ''
+2507
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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);
}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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'
+898
View File
@@ -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.
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+49
View File
@@ -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
View File
@@ -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
)
)
)
+63
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large. Load diff
+267
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+}
+3
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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, "![alt](https://example.com/x.png)"), '<p>!<a href="https://example.com/x.png" target="_blank" rel="noreferrer">alt</a></p>');
assert.deepEqual(tags(m.render("![alt](https://example.com/x.png)", 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> &amp; <!-- c -->", makeDoc(), allow);
assert.deepEqual(tags(tree), ["p", "p"]);
assert.equal(show(tree), `<p>${hostile}</p><p><b>bold?</b> &amp; <!-- 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