Compare commits

...
4 Commits
Author SHA1 Message Date
github-actions[bot] 1820377713 🛸 Sync 2026-08-28 05:40:13
Merge-upstream / merge (push) Canceled after 0s
2026-08-28 05:40:13 +08:00
github-actions[bot] 92e53e5990 🎄 Sync 2026-08-28 01:32:27 2026-08-28 01:32:27 +08:00
kiddin9 2398a25322 Update luci-theme-footstrap.patch 2026-08-28 01:28:41 +08:00
github-actions[bot] 7491dec606 🎉 Sync 2026-08-28 01:19:01 2026-08-28 01:19:01 +08:00
47 changed files with 1828 additions and 1378 deletions
@@ -1,12 +1,13 @@
--- a/luci-theme-footstrap/root/etc/config/footstrap --- a/luci-theme-footstrap/root/etc/config/footstrap
+++ b/luci-theme-footstrap/root/etc/config/footstrap +++ b/luci-theme-footstrap/root/etc/config/footstrap
@@ -1,2 +1,6 @@ @@ -1,2 +1,7 @@
config footstrap 'settings' config footstrap 'settings'
+ option pattern 'a583df0ec986a6b8454f7be514e8cb32' + option pattern 'a583df0ec986a6b8454f7be514e8cb32'
+ option darkmode 'dark' + option darkmode 'dark'
+ option wallpaper 'pattern' + option wallpaper 'pattern'
+ option layout 'sidebar' + option layout 'sidebar'
+ option autocollapse 'on'
--- a/luci-theme-footstrap/styles/02-tokens.css --- a/luci-theme-footstrap/styles/02-tokens.css
+++ b/luci-theme-footstrap/styles/02-tokens.css +++ b/luci-theme-footstrap/styles/02-tokens.css
+4 -4
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=dae PKG_NAME:=dae
PKG_VERSION:=2026.08.21 PKG_VERSION:=2026.08.26
PKG_RELEASE:=37 PKG_RELEASE:=38
PKG_SOURCE:=dae-src-2026.08.21-8138f66f2c6d.tar.gz PKG_SOURCE:=dae-src-2026.08.26-936105a87a65.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
@@ -39,7 +39,7 @@ GO_PKG_LDFLAGS:= \
GO_PKG_LDFLAGS_X:= \ GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/cmd.Version=$(PKG_VERSION) \ $(GO_PKG)/cmd.Version=$(PKG_VERSION) \
$(GO_PKG)/common/consts.MaxMatchSetLen_=1024 $(GO_PKG)/common/consts.MaxMatchSetLen_=1024
GO_PKG_TAGS:=trace GO_PKG_TAGS:=trace,timetzdata
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/bpf.mk include $(INCLUDE_DIR)/bpf.mk
+1 -1
View File
@@ -27,7 +27,7 @@ start_service() {
config_get log_maxsize "config" "log_maxsize" "1" config_get log_maxsize "config" "log_maxsize" "1"
procd_open_instance "$CONF" procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param command "$PROG" run procd_set_param command "$PROG" run
procd_append_param command --config "$config_file" procd_append_param command --config "$config_file"
procd_append_param command --disable-timestamp procd_append_param command --disable-timestamp
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=daed PKG_NAME:=daed
PKG_VERSION:=2026.08.21 PKG_VERSION:=2026.08.26
PKG_RELEASE:=48 PKG_RELEASE:=49
PKG_SOURCE:=daed-src-2026.08.21-aeef51da973f.tar.gz PKG_SOURCE:=daed-src-2026.08.26-003da3d0a669.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede PKG_NAME:=luci-app-daede
PKG_VERSION:=1.14.7 PKG_VERSION:=1.14.7
PKG_RELEASE:=40 PKG_RELEASE:=41
PKG_MAINTAINER:=kenzok8 PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -39,7 +39,7 @@ const CSS = [
'.dd-log-pane .dd-line.dd-hidden{display:none}', '.dd-log-pane .dd-line.dd-hidden{display:none}',
'.dd-log-pane .dd-empty{opacity:.5;font-style:italic}', '.dd-log-pane .dd-empty{opacity:.5;font-style:italic}',
/* 简化后的字段视觉 */ /* 简化后的字段视觉 */
'.dd-log-pane .dd-ts{color:#6b7480;margin-right:8px}', '.dd-log-pane .dd-ts{color:#6b7480;margin-right:8px;white-space:nowrap}',
'.dd-log-pane .dd-lvl{display:inline-block;min-width:38px;padding:0 5px;margin-right:8px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:.4px;text-align:center;vertical-align:1px}', '.dd-log-pane .dd-lvl{display:inline-block;min-width:38px;padding:0 5px;margin-right:8px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:.4px;text-align:center;vertical-align:1px}',
'.dd-log-pane .dd-lvl-info{color:#7fc7a8;background:rgba(127,199,168,.08)}', '.dd-log-pane .dd-lvl-info{color:#7fc7a8;background:rgba(127,199,168,.08)}',
'.dd-log-pane .dd-lvl-warn{color:#e8b95a;background:rgba(232,185,90,.10)}', '.dd-log-pane .dd-lvl-warn{color:#e8b95a;background:rgba(232,185,90,.10)}',
@@ -53,6 +53,7 @@ const CSS = [
/* 拆字段:time="May 25 07:04:59" level=info msg="..." key=val key="val with space" ... */ /* 拆字段:time="May 25 07:04:59" level=info msg="..." key=val key="val with space" ... */
const RE_LINE = /^time="([^"]*)"\s+level=(\w+)\s+msg=(?:"((?:[^"\\]|\\.)*)"|(\S+))\s*(.*)$/; const RE_LINE = /^time="([^"]*)"\s+level=(\w+)\s+msg=(?:"((?:[^"\\]|\\.)*)"|(\S+))\s*(.*)$/;
const RE_PREFIXED_LINE = /^\[([^\]]+)\]\s+(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\s*(.*)$/i;
function detectLevel(line) { function detectLevel(line) {
// daed/dae logs use lvl=info / [INFO] / level=warning style // daed/dae logs use lvl=info / [INFO] / level=warning style
@@ -65,22 +66,29 @@ function detectLevel(line) {
return 'dd-error'; return 'dd-error';
} }
/* 化时间戳并按北京时间显示: /* 格式化时间戳并按北京时间显示:
* - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成北京时间 * - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成北京时间
* - 旧 logrus 短格式 (e.g. "May 25 07:04:59") 无时区信息,原样提取 * - 新格式和旧 logrus 短格式无时区信息,保留原始完整日期时间
*/ */
function shortTs(raw) { function formatTs(raw) {
if (!raw) return raw; if (!raw) return raw;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) { if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
const d = new Date(raw); const d = new Date(raw);
if (!isNaN(d.getTime())) { if (!isNaN(d.getTime())) {
const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000); const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const pad = n => String(n).padStart(2, '0'); const pad = n => String(n).padStart(2, '0');
return pad(beijing.getUTCHours()) + ':' + pad(beijing.getUTCMinutes()) + ':' + pad(beijing.getUTCSeconds()); return [
beijing.getUTCFullYear(),
pad(beijing.getUTCMonth() + 1),
pad(beijing.getUTCDate())
].join('-') + ' ' + [
pad(beijing.getUTCHours()),
pad(beijing.getUTCMinutes()),
pad(beijing.getUTCSeconds())
].join(':');
} }
} }
const m = raw.match(/(\d{2}:\d{2}:\d{2})/); return raw;
return m ? m[1] : raw;
} }
function lvlShort(level) { function lvlShort(level) {
@@ -97,22 +105,42 @@ function lvlClass(level) {
return 'dd-lvl-error'; return 'dd-lvl-error';
} }
function parseLine(line) {
let m = line.match(RE_LINE);
if (m) {
return {
ts: formatTs(m[1]),
lvl: m[2],
msg: m[3] !== undefined ? m[3] : (m[4] || ''),
kv: (m[5] || '').trim()
};
}
m = line.match(RE_PREFIXED_LINE);
if (!m) return null;
const body = m[3] || '';
const kvStart = body.search(/(?:^|\s)(?=[A-Za-z_][\w.-]*=)/);
return {
ts: formatTs(m[1]),
lvl: m[2],
msg: kvStart === -1 ? body : body.slice(0, kvStart).trimEnd(),
kv: kvStart === -1 ? '' : body.slice(kvStart).trim()
};
}
function buildLine(ln) { function buildLine(ln) {
const cls = detectLevel(ln); const cls = detectLevel(ln);
const m = ln.match(RE_LINE); const parsed = parseLine(ln);
if (!m) { if (!parsed) {
return E('div', { 'class': 'dd-line ' + cls }, ln); return E('div', { 'class': 'dd-line ' + cls }, ln);
} }
const ts = shortTs(m[1]);
const lvl = m[2];
const msg = m[3] !== undefined ? m[3] : (m[4] || '');
const kv = (m[5] || '').trim();
const parts = [ const parts = [
E('span', { 'class': 'dd-ts' }, ts), E('span', { 'class': 'dd-ts' }, parsed.ts),
E('span', { 'class': 'dd-lvl ' + lvlClass(lvl) }, lvlShort(lvl)), E('span', { 'class': 'dd-lvl ' + lvlClass(parsed.lvl) }, lvlShort(parsed.lvl)),
E('span', { 'class': 'dd-msg' }, msg) E('span', { 'class': 'dd-msg' }, parsed.msg)
]; ];
if (kv) parts.push(E('span', { 'class': 'dd-kv' }, kv)); if (parsed.kv) parts.push(E('span', { 'class': 'dd-kv' }, parsed.kv));
return E('div', { 'class': 'dd-line ' + cls }, parts); return E('div', { 'class': 'dd-line ' + cls }, parts);
} }
@@ -53,6 +53,7 @@ DAE_INIT="/etc/init.d/dae"
if [ -f "$DAE_INIT" ]; then if [ -f "$DAE_INIT" ]; then
sed -i 's/--disable-timestamp//' "$DAE_INIT" sed -i 's/--disable-timestamp//' "$DAE_INIT"
sed -i '/procd_append_param command *$/d' "$DAE_INIT" sed -i '/procd_append_param command *$/d' "$DAE_INIT"
sed -i 's|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray".*|procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"|' "$DAE_INIT"
fi fi
# dae expects geoip.dat / geosite.dat under /usr/share/dae/; # dae expects geoip.dat / geosite.dat under /usr/share/dae/;
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.8.26 PKG_VERSION:=26.8.26
PKG_RELEASE:=247 PKG_RELEASE:=248
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -829,6 +829,8 @@ function rollback_rules()
if geo2rule == "1" and rules ~= "" then if geo2rule == "1" and rules ~= "" then
luci.sys.call("lua /usr/share/passwall/rule_update.lua log '" .. rules .. "' rollback > /dev/null") luci.sys.call("lua /usr/share/passwall/rule_update.lua log '" .. rules .. "' rollback > /dev/null")
end end
uci_set("@global[0]", "flush_set", "1")
uci_save(true)
http_write_json_ok() http_write_json_ok()
end end
@@ -402,6 +402,7 @@ end
---- DNS Forward ---- DNS Forward
o = s:option(Value, "remote_dns", translate("Remote DNS")) o = s:option(Value, "remote_dns", translate("Remote DNS"))
o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "1.1.1.1" o.default = "1.1.1.1"
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)") o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)") o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
@@ -213,7 +213,7 @@ o:depends({dns_shunt = "dnsmasq"})
o:depends({dns_shunt = "chinadns-ng"}) o:depends({dns_shunt = "chinadns-ng"})
o = s:taboption("DNS", Value, "direct_dns", translate("Direct DNS")) o = s:taboption("DNS", Value, "direct_dns", translate("Direct DNS"))
o.datatype = "or(ipaddr,ipaddrport)" o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "223.5.5.5" o.default = "223.5.5.5"
o:value("223.5.5.5") o:value("223.5.5.5")
o:value("223.6.6.6") o:value("223.6.6.6")
@@ -389,7 +389,7 @@ o:depends({dns_mode = "dns2socks"})
---- DNS Forward ---- DNS Forward
o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS")) o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS"))
o.datatype = "or(ipaddr,ipaddrport)" o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "1.1.1.1" o.default = "1.1.1.1"
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)") o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)") o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
@@ -69,13 +69,6 @@ local function convert_geofile()
end end
local function convert(file_path, prefix, tags) local function convert(file_path, prefix, tags)
if next(tags) and fs.access(file_path) then if next(tags) and fs.access(file_path) then
local md5_file = GEO_VAR.TO_SRS_PATH .. prefix .. ".dat.md5"
local new_md5 = sys.exec("md5sum " .. file_path .. " 2>/dev/null | awk '{print $1}'"):gsub("\n", "")
local old_md5 = sys.exec("[ -f " .. md5_file .. " ] && head -n 1 " .. md5_file .. " | tr -d ' \t\n' || echo ''")
if new_md5 ~= "" and new_md5 ~= old_md5 then
sys.call("printf '%s' " .. new_md5 .. " > " .. md5_file)
sys.call("rm -rf " .. GEO_VAR.TO_SRS_PATH .. prefix .. "-*.srs" )
end
for k in pairs(tags) do for k in pairs(tags) do
geo_convert_srs({["geo_path"] = file_path, ["prefix"] = prefix, ["rule_name"] = k}) geo_convert_srs({["geo_path"] = file_path, ["prefix"] = prefix, ["rule_name"] = k})
end end
@@ -1664,8 +1664,12 @@ function gen_config(var)
_direct_dns.port = port _direct_dns.port = port
_direct_dns.address = direct_dns_udp_server _direct_dns.address = direct_dns_udp_server
elseif direct_dns_tcp_server then elseif direct_dns_tcp_server then
if api.is_ipv6(direct_dns_tcp_server) then
direct_dns_tcp_server = api.get_ipv6_full(direct_dns_tcp_server)
end
local port = tonumber(direct_dns_port) or 53 local port = tonumber(direct_dns_port) or 53
_direct_dns.address = "tcp://" .. direct_dns_tcp_server .. ":" .. port _direct_dns.address = "tcp://" .. direct_dns_tcp_server .. ":" .. port
_direct_dns.port = port
end end
if COMMON.default_outbound_tag == "direct" then if COMMON.default_outbound_tag == "direct" then
@@ -1697,7 +1701,11 @@ function gen_config(var)
_remote_dns.port = tonumber(remote_dns_udp_port) or 53 _remote_dns.port = tonumber(remote_dns_udp_port) or 53
elseif remote_dns_tcp_server then elseif remote_dns_tcp_server then
if api.is_ipv6(remote_dns_tcp_server) then
remote_dns_tcp_server = api.get_ipv6_full(remote_dns_tcp_server)
end
_remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53 _remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53
_remote_dns.port = tonumber(remote_dns_tcp_port) or 53
elseif remote_dns_doh then elseif remote_dns_doh then
local _a = api.parseDoH(remote_dns_doh) local _a = api.parseDoH(remote_dns_doh)
@@ -44,6 +44,7 @@ FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18" FAKE_IP_6="fc00::/18"
USE_GEOVIEW=0 USE_GEOVIEW=0
EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$"
factor() { factor() {
if [ -z "$1" ] || [ -z "$2" ]; then if [ -z "$1" ] || [ -z "$2" ]; then
@@ -746,15 +747,14 @@ load_acl() {
} }
filter_haproxy() { filter_haproxy() {
for item in ${haproxy_items}; do for item in $(uci show $CONFIG | grep ".lbss=" | cut -d "'" -f 2); do
local ip=$(get_host_ip ipv4 $(echo $item | awk -F ":" '{print $1}') 1) local ip=$(get_host_ip "ipv4" "$(echo $item | awk -F ":" '{print $1}')" 1)
ipset -q add $IPSET_VPS $ip [ -n "$ip" ] && ! echo "$ip" | grep -Eq "$EXCLUDE_VPSIP" && ipset -q add $IPSET_VPS $ip
done done
echolog " - [$?]加入负载均衡的节点到ipset[$IPSET_VPS]直连完成" echolog " - [$?]加入负载均衡的节点到ipset[$IPSET_VPS]直连完成"
} }
filter_vpsip() { filter_vpsip() {
local EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$"
uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R
echolog " - [$?]加入所有IPv4节点到ipset[$IPSET_VPS]直连完成" echolog " - [$?]加入所有IPv4节点到ipset[$IPSET_VPS]直连完成"
uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | sed "s/^/add $IPSET_VPS6 /" | awk '1; END{print "COMMIT"}' | ipset -! -R uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | sed "s/^/add $IPSET_VPS6 /" | awk '1; END{print "COMMIT"}' | ipset -! -R
@@ -802,7 +802,9 @@ filter_node() {
local port=$(config_n_get "$node" port) local port=$(config_n_get "$node" port)
local hop=$(config_n_get "$node" hysteria2_hop) local hop=$(config_n_get "$node" hysteria2_hop)
[ -n "$hop" ] && port="${port:+$port,}$hop" [ -n "$hop" ] && port="${port:+$port,}$hop"
[ -z "$address" ] || [ -z "$port" ] && return 1 [ -z "$address" ] && return 1
echo "$address" | grep -Eq "$EXCLUDE_VPSIP" && return 1
[ -z "$port" ] && return 1
filter_server_port "$address" "$port" "$stream" filter_server_port "$address" "$port" "$stream"
} }
@@ -50,6 +50,7 @@ FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18" FAKE_IP_6="fc00::/18"
USE_GEOVIEW=0 USE_GEOVIEW=0
EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$"
factor() { factor() {
local ports="$1" local ports="$1"
@@ -809,24 +810,23 @@ load_acl() {
} }
filter_haproxy() { filter_haproxy() {
for item in ${haproxy_items}; do for item in $(uci show $CONFIG | grep ".lbss=" | cut -d "'" -f 2); do
get_host_ip ipv4 $(echo $item | awk -F ":" '{print $1}') 1 get_host_ip "ipv4" "$(echo $item | awk -F ":" '{print $1}')" 1
done | insert_nftset $NFTSET_VPS done | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS
echolog " - [$?]加入负载均衡的节点到nftset[$NFTSET_VPS]直连完成" echolog " - [$?]加入负载均衡的节点到nftset[$NFTSET_VPS]直连完成"
} }
filter_vps_addr() { filter_vps_addr() {
for server_host in "$@"; do for server_host in "$@"; do
get_host_ip "ipv4" ${server_host} get_host_ip "ipv4" "${server_host}"
done | insert_nftset $NFTSET_VPS done | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS
for server_host in "$@"; do for server_host in "$@"; do
get_host_ip "ipv6" ${server_host} get_host_ip "ipv6" "${server_host}"
done | insert_nftset $NFTSET_VPS6 done | insert_nftset $NFTSET_VPS6
} }
filter_vpsip() { filter_vpsip() {
local EXCLUDE_VPSIP="^(0\.0\.0\.0|127\.0\.0\.1|1\.1\.1\.1|1\.1\.1\.2|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9)$"
uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS
echolog " - [$?]加入所有IPv4节点到nftset[$NFTSET_VPS]直连完成" echolog " - [$?]加入所有IPv4节点到nftset[$NFTSET_VPS]直连完成"
uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | insert_nftset $NFTSET_VPS6 uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Eo "\[?[A-Fa-f0-9:]*:[A-Fa-f0-9:]+\]?" | insert_nftset $NFTSET_VPS6
@@ -864,7 +864,9 @@ filter_node() {
local port=$(config_n_get "$node" port) local port=$(config_n_get "$node" port)
local hop=$(config_n_get "$node" hysteria2_hop) local hop=$(config_n_get "$node" hysteria2_hop)
[ -n "$hop" ] && port="${port:+$port,}$hop" [ -n "$hop" ] && port="${port:+$port,}$hop"
[ -z "$address" ] || [ -z "$port" ] && return 1 [ -z "$address" ] && return 1
echo "$address" | grep -Eq "$EXCLUDE_VPSIP" && return 1
[ -z "$port" ] && return 1
filter_server_port "$address" "$port" "$stream" filter_server_port "$address" "$port" "$stream"
} }
@@ -877,7 +879,6 @@ filter_direct_node_list() {
done done
} }
del_script_mwan3() { del_script_mwan3() {
[ -s "/etc/init.d/mwan3" ] && sed -i "/${CONFIG}/d" /etc/init.d/mwan3 >/dev/null 2>&1 [ -s "/etc/init.d/mwan3" ] && sed -i "/${CONFIG}/d" /etc/init.d/mwan3 >/dev/null 2>&1
} }
@@ -726,13 +726,21 @@ if geo2rule == "1" then
end end
-- 如果是手动更新(arg2存在)始终生成规则 -- 如果是手动更新(arg2存在)始终生成规则
if arg2 then geoip_update_ok, geosite_update_ok = true, true end if arg2 then
chnroute_update, chnroute6_update, gfwlist_update, chnlist_update = "1", "1", "1", "1" geoip_update_ok, geosite_update_ok = true, true
end
if not rollback then
chnroute_update, chnroute6_update, gfwlist_update, chnlist_update = "1", "1", "1", "1"
end
if geoip_update_ok then if geoip_update_ok then
if fs.access(asset_location .. "geoip.dat") then if fs.access(asset_location .. "geoip.dat") then
safe_call(fetch_chnroute, "生成chnroute发生错误...") if chnroute_update == "1" then
safe_call(fetch_chnroute6, "生成chnroute6发生错误...") safe_call(fetch_chnroute, "生成chnroute发生错误...")
end
if chnroute6_update == "1" then
safe_call(fetch_chnroute6, "生成chnroute6发生错误...")
end
else else
log("geoip.dat 文件不存在,跳过规则生成。") log("geoip.dat 文件不存在,跳过规则生成。")
end end
@@ -740,8 +748,12 @@ if geo2rule == "1" then
if geosite_update_ok then if geosite_update_ok then
if fs.access(asset_location .. "geosite.dat") then if fs.access(asset_location .. "geosite.dat") then
safe_call(fetch_gfwlist, "生成gfwlist发生错误...") if gfwlist_update == "1" then
safe_call(fetch_chnlist, "生成chnlist发生错误...") safe_call(fetch_gfwlist, "生成gfwlist发生错误...")
end
if chnlist_update == "1" then
safe_call(fetch_chnlist, "生成chnlist发生错误...")
end
else else
log("geosite.dat 文件不存在,跳过规则生成。") log("geosite.dat 文件不存在,跳过规则生成。")
end end
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2 PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.8.27 PKG_VERSION:=26.8.27
PKG_RELEASE:=102 PKG_RELEASE:=103
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -271,7 +271,7 @@ o:depends("_show_dns_option", "1")
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ) ---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
o = s:option(Value, "remote_dns", translate("Remote DNS")) o = s:option(Value, "remote_dns", translate("Remote DNS"))
o.datatype = "or(ipaddr,ipaddrport)" o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "1.1.1.1" o.default = "1.1.1.1"
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)") o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)") o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
@@ -182,7 +182,7 @@ end
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ) ---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS")) o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS"))
o.datatype = "or(ipaddr,ipaddrport)" o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "1.1.1.1" o.default = "1.1.1.1"
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)") o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)") o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
+25 -1
View File
@@ -2048,4 +2048,28 @@ function gen_wireguard_key()
public_key = public_key public_key = public_key
} }
end end
end end
function parseDNS(dns)
if not dns then return nil end
if true then
-- IPv6
-- [::1]:5053
local address, port = dns:match("%[(.-)%]:([0-9]+)$")
if address and datatypes.ip6addr(address) and datatypes.port(port) then
return address, port
end
-- [::1]
if is_ipv6(dns) then
return get_ipv6_only(dns), 53
end
end
if true then
-- 1.1.1.1:5053
local h, p = dns:match("^([^:]+):([^:]+)$")
if (h and p and datatypes.ip4addr(h) and datatypes.port(p)) then
return h, p
end
end
return dns, 53
end
@@ -1623,8 +1623,6 @@ function gen_config(var)
end end
end) end)
end end
local _remote_dns_ip = nil
local _remote_dns = { local _remote_dns = {
tag = remote_dns_tag, tag = remote_dns_tag,
@@ -1635,14 +1633,15 @@ function gen_config(var)
_remote_dns.address = remote_dns_udp_server _remote_dns.address = remote_dns_udp_server
_remote_dns.port = tonumber(remote_dns_udp_port) or 53 _remote_dns.port = tonumber(remote_dns_udp_port) or 53
_remote_dns_proto = "udp" _remote_dns_proto = "udp"
_remote_dns_ip = remote_dns_udp_server
end end
if remote_dns_tcp_server then if remote_dns_tcp_server then
if api.is_ipv6(remote_dns_tcp_server) then
remote_dns_tcp_server = api.get_ipv6_full(remote_dns_tcp_server)
end
_remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53 _remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53
_remote_dns.port = tonumber(remote_dns_tcp_port) or 53 _remote_dns.port = tonumber(remote_dns_tcp_port) or 53
_remote_dns_proto = "tcp" _remote_dns_proto = "tcp"
_remote_dns_ip = remote_dns_tcp_server
end end
if remote_dns_doh_url and remote_dns_doh_host then if remote_dns_doh_url and remote_dns_doh_host then
@@ -1651,7 +1650,6 @@ function gen_config(var)
end end
_remote_dns.address = remote_dns_doh_url _remote_dns.address = remote_dns_doh_url
_remote_dns.port = tonumber(remote_dns_doh_port) or 443 _remote_dns.port = tonumber(remote_dns_doh_port) or 443
_remote_dns_ip = remote_dns_doh_ip
end end
if _remote_dns.address then if _remote_dns.address then
@@ -69,7 +69,7 @@ check_run_environment() {
run_xray() { run_xray() {
local flag node redir_port socks_address socks_port socks_username socks_password http_address http_port http_username http_password local flag node redir_port socks_address socks_port socks_username socks_password http_address http_port http_username http_password
local dns_listen_port direct_dns_query_strategy remote_dns_protocol remote_dns_udp_server remote_dns_tcp_server remote_dns_doh remote_dns_client_ip remote_dns_detour remote_fakedns remote_dns_query_strategy dns_cache local dns_listen_port direct_dns_query_strategy remote_dns_protocol remote_dns_udp_server remote_dns_udp_port remote_dns_tcp_server remote_dns_tcp_port remote_dns_doh remote_dns_client_ip remote_dns_detour remote_fakedns remote_dns_query_strategy dns_cache
local loglevel log_file config_file local loglevel log_file config_file
eval_set_val $@ eval_set_val $@
node_protocol=$(config_n_get $node protocol) node_protocol=$(config_n_get $node protocol)
@@ -100,6 +100,7 @@ run_xray() {
} }
} }
[ -n "$dns_listen_port" ] && { [ -n "$dns_listen_port" ] && {
local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${AUTO_DNS}")"
json_add_string "dns_listen_port" "${dns_listen_port}" json_add_string "dns_listen_port" "${dns_listen_port}"
[ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}" [ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}"
[ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0)
@@ -129,24 +130,16 @@ run_xray() {
set_cache_var "node_${node}_direct_nftset6" "${direct_nftset6}" set_cache_var "node_${node}_direct_nftset6" "${direct_nftset6}"
} }
} }
[ "$remote_fakedns" = "1" ] && {
json_add_string "remote_dns_fake" "1"
json_add_string "remote_dns_fake_strategy" "${remote_dns_query_strategy}"
}
case "$remote_dns_protocol" in case "$remote_dns_protocol" in
udp) udp)
local _dns=$(get_first_dns remote_dns_udp_server 53 | sed 's/#/:/g') json_add_string "remote_dns_udp_server" "${remote_dns_udp_server}"
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') json_add_string "remote_dns_udp_port" "${remote_dns_udp_port}"
local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}') dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "udp://${remote_dns_udp_server}:${remote_dns_udp_port}")"
json_add_string "remote_dns_udp_port" "${_dns_port}"
json_add_string "remote_dns_udp_server" "${_dns_address}"
;; ;;
tcp) tcp)
local _dns=$(get_first_dns remote_dns_tcp_server 53 | sed 's/#/:/g') json_add_string "remote_dns_tcp_server" "${remote_dns_tcp_server}"
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') json_add_string "remote_dns_tcp_port" "${remote_dns_tcp_port}"
local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}') dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "tcp://${remote_dns_tcp_server}:${remote_dns_tcp_port}")"
json_add_string "remote_dns_tcp_port" "${_dns_port}"
json_add_string "remote_dns_tcp_server" "${_dns_address}"
;; ;;
doh) doh)
local _doh_url=$(echo $remote_dns_doh | awk -F ',' '{print $1}') local _doh_url=$(echo $remote_dns_doh | awk -F ',' '{print $1}')
@@ -162,11 +155,18 @@ run_xray() {
json_add_string "remote_dns_doh_url" "${_doh_url}" json_add_string "remote_dns_doh_url" "${_doh_url}"
json_add_string "remote_dns_doh_host" "${_doh_host}" json_add_string "remote_dns_doh_host" "${_doh_host}"
[ -n "$_doh_bootstrap" ] && json_add_string "remote_dns_doh_ip" "${_doh_bootstrap}" [ -n "$_doh_bootstrap" ] && json_add_string "remote_dns_doh_ip" "${_doh_bootstrap}"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${_doh_url}")"
;; ;;
esac esac
[ "$remote_fakedns" = "1" ] && {
json_add_string "remote_dns_fake" "1"
json_add_string "remote_dns_fake_strategy" "${remote_dns_query_strategy}"
dns_msg="${dns_msg} + FakeDNS "
}
[ -n "$remote_dns_detour" ] && json_add_string "remote_dns_detour" "${remote_dns_detour}" [ -n "$remote_dns_detour" ] && json_add_string "remote_dns_detour" "${remote_dns_detour}"
[ -n "$remote_dns_query_strategy" ] && json_add_string "remote_dns_query_strategy" "${remote_dns_query_strategy}" [ -n "$remote_dns_query_strategy" ] && json_add_string "remote_dns_query_strategy" "${remote_dns_query_strategy}"
[ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}" [ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}"
log_out="${dns_msg})"
} }
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}"
@@ -176,6 +176,7 @@ run_xray() {
json_add_string "redir_port" "${redir_port}" json_add_string "redir_port" "${redir_port}"
set_cache_var "node_${node}_redir_port" "${redir_port}" set_cache_var "node_${node}_redir_port" "${redir_port}"
json_add_string "tcp_proxy_way" "${TCP_PROXY_WAY}" json_add_string "tcp_proxy_way" "${TCP_PROXY_WAY}"
[ -n "${log_out}" ] && log_out="Xray[${redir_port}] ${log_out}"
} }
json_add_string "node" "${node}" json_add_string "node" "${node}"
@@ -189,6 +190,8 @@ run_xray() {
$XRAY_BIN run -test -c "$config_file" > $test_log_file; local status=$? $XRAY_BIN run -test -c "$config_file" > $test_log_file; local status=$?
if [ "${status}" == 0 ]; then if [ "${status}" == 0 ]; then
ln_run ${QUEUE_RUN} "$XRAY_BIN" xray $log_file run -c "$config_file" ln_run ${QUEUE_RUN} "$XRAY_BIN" xray $log_file run -c "$config_file"
[ -n "${log_out}" ] && log 2 ${log_out}
unset log_out
else else
_error_log_file=$test_log_file _error_log_file=$test_log_file
return ${status} return ${status}
@@ -197,7 +200,7 @@ run_xray() {
run_singbox() { run_singbox() {
local flag node redir_port socks_address socks_port socks_username socks_password http_address http_port http_username http_password local flag node redir_port socks_address socks_port socks_username socks_password http_address http_port http_username http_password
local dns_listen_port direct_dns_query_strategy remote_dns_protocol remote_dns_udp_server remote_dns_tcp_server remote_dns_doh remote_dns_client_ip remote_dns_detour remote_fakedns remote_dns_query_strategy remote_rewrite_ttl dns_cache local dns_listen_port direct_dns_query_strategy remote_dns_protocol remote_dns_udp_server remote_dns_udp_port remote_dns_tcp_server remote_dns_tcp_port remote_dns_doh remote_dns_client_ip remote_dns_detour remote_fakedns remote_dns_query_strategy remote_rewrite_ttl dns_cache
local loglevel log_file config_file local loglevel log_file config_file
eval_set_val $@ eval_set_val $@
local type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z') local type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z')
@@ -239,6 +242,9 @@ run_singbox() {
} }
} }
[ -n "$dns_listen_port" ] && { [ -n "$dns_listen_port" ] && {
local dns_msg="DNS[${dns_listen_port}]:($(i18n "Direct DNS: %s" "${AUTO_DNS}")"
json_add_string "dns_listen_port" "${dns_listen_port}"
[ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}"
[ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0)
[ "${write_ipset_direct}" = "1" ] && { [ "${write_ipset_direct}" = "1" ] && {
direct_dnsmasq_listen_port=$(get_new_port auto) direct_dnsmasq_listen_port=$(get_new_port auto)
@@ -270,21 +276,17 @@ run_singbox() {
case "$remote_dns_protocol" in case "$remote_dns_protocol" in
udp|\ udp|\
quic) quic)
local _dns=$(get_first_dns remote_dns_udp_server 53 | sed 's/#/:/g') json_add_string "remote_dns_udp_server" "${remote_dns_udp_server}"
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') json_add_string "remote_dns_udp_port" "${remote_dns_udp_port}"
local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}')
json_add_string "remote_dns_udp_port" "${_dns_port}"
json_add_string "remote_dns_udp_server" "${_dns_address}"
[ "$remote_dns_protocol" == "quic" ] && json_add_string "remote_dns_quic" "1" [ "$remote_dns_protocol" == "quic" ] && json_add_string "remote_dns_quic" "1"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${remote_dns_protocol}://${remote_dns_udp_server}:${remote_dns_udp_port}")"
;; ;;
tcp|\ tcp|\
tls) tls)
local _dns=$(get_first_dns remote_dns_tcp_server 53 | sed 's/#/:/g') json_add_string "remote_dns_tcp_server" "${remote_dns_tcp_server}"
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') json_add_string "remote_dns_tcp_port" "${remote_dns_tcp_port}"
local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}')
json_add_string "remote_dns_tcp_port" "${_dns_port}"
json_add_string "remote_dns_tcp_server" "${_dns_address}"
[ "$remote_dns_protocol" == "tls" ] && json_add_string "remote_dns_tls" "1" [ "$remote_dns_protocol" == "tls" ] && json_add_string "remote_dns_tls" "1"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${remote_dns_protocol}://${remote_dns_tcp_server}:${remote_dns_tcp_port}")"
;; ;;
doh|\ doh|\
http3) http3)
@@ -302,17 +304,19 @@ run_singbox() {
json_add_string "remote_dns_doh_url" "${_doh_url}" json_add_string "remote_dns_doh_url" "${_doh_url}"
json_add_string "remote_dns_doh_host" "${_doh_host}" json_add_string "remote_dns_doh_host" "${_doh_host}"
[ "$remote_dns_protocol" == "http3" ] && json_add_string "remote_dns_http3" "1" [ "$remote_dns_protocol" == "http3" ] && json_add_string "remote_dns_http3" "1"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${_doh_url}")"
;; ;;
esac esac
[ "$remote_fakedns" = "1" ] && {
json_add_string "remote_dns_fake" "1"
dns_msg="${dns_msg} + FakeDNS "
}
[ -n "$remote_dns_detour" ] && json_add_string "remote_dns_detour" "${remote_dns_detour}" [ -n "$remote_dns_detour" ] && json_add_string "remote_dns_detour" "${remote_dns_detour}"
[ -n "$remote_dns_query_strategy" ] && json_add_string "remote_dns_query_strategy" "${remote_dns_query_strategy}" [ -n "$remote_dns_query_strategy" ] && json_add_string "remote_dns_query_strategy" "${remote_dns_query_strategy}"
[ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}" [ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}"
[ -n "$dns_listen_port" ] && json_add_string "dns_listen_port" "${dns_listen_port}"
[ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}"
[ "$remote_fakedns" = "1" ] && json_add_string "remote_dns_fake" "1"
[ -n "$remote_rewrite_ttl" ] && json_add_string "remote_rewrite_ttl" "${remote_rewrite_ttl}" [ -n "$remote_rewrite_ttl" ] && json_add_string "remote_rewrite_ttl" "${remote_rewrite_ttl}"
log_out="${dns_msg})"
} }
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}"
@@ -322,6 +326,7 @@ run_singbox() {
json_add_string "redir_port" "${redir_port}" json_add_string "redir_port" "${redir_port}"
set_cache_var "node_${node}_redir_port" "${redir_port}" set_cache_var "node_${node}_redir_port" "${redir_port}"
json_add_string "tcp_proxy_way" "${TCP_PROXY_WAY}" json_add_string "tcp_proxy_way" "${TCP_PROXY_WAY}"
[ -n "${log_out}" ] && log_out="Sing-Box[${redir_port}] ${log_out}"
} }
json_add_string "node" "${node}" json_add_string "node" "${node}"
@@ -335,6 +340,8 @@ run_singbox() {
$SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; local status=$? $SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; local status=$?
if [ "${status}" == 0 ]; then if [ "${status}" == 0 ]; then
ln_run ${QUEUE_RUN} "$SINGBOX_BIN" "sing-box" "${log_file}" run -c "$config_file" ln_run ${QUEUE_RUN} "$SINGBOX_BIN" "sing-box" "${log_file}" run -c "$config_file"
[ -n "${log_out}" ] && log 2 ${log_out}
unset log_out
else else
_error_log_file=$test_log_file _error_log_file=$test_log_file
return ${status} return ${status}
@@ -803,13 +810,28 @@ run_ipset_dnsmasq() {
} }
acl_node() { acl_node() {
[ ! -f ${TMP_ACL_PATH}/acl_node_default ] && ENABLED_DEFAULT_ACL=0
local acl_node_num=$(jsonfilter -s "${acl_json}" -e '$.node_order[*]' | wc -l)
[ "${acl_node_num}" == 0 ] && {
ENABLED_DEFAULT_ACL=0
ENABLED_ACLS=0
return
}
[ "$(uci -q get dhcp.@dnsmasq[0].dns_redirect)" == "1" ] && {
uci -q set ${CONFIG}.@global[0].dnsmasq_dns_redirect='1'
uci -q commit ${CONFIG}
uci -q set dhcp.@dnsmasq[0].dns_redirect='0'
uci -q commit dhcp
json_init
json_add_string "LOG" "0"
lua $APP_PATH/helper_dnsmasq.lua restart "$(json_dump)"
}
local run_func local run_func
[ -n "${XRAY_BIN}" ] && run_func="run_xray" [ -n "${XRAY_BIN}" ] && run_func="run_xray"
[ -n "${SINGBOX_BIN}" ] && run_func="run_singbox" [ -n "${SINGBOX_BIN}" ] && run_func="run_singbox"
local acl_node_num=0
for nid in $(jsonfilter -s "${acl_json}" -e '$.node_order[*]'); do for nid in $(jsonfilter -s "${acl_json}" -e '$.node_order[*]'); do
[ ! -f ${TMP_ACL_PATH}/acl_node_${nid} ] && continue [ ! -f ${TMP_ACL_PATH}/acl_node_${nid} ] && continue
acl_node_num=$(expr $acl_node_num + 1)
local _var=$(cat ${TMP_ACL_PATH}/acl_node_${nid} 2>/dev/null) local _var=$(cat ${TMP_ACL_PATH}/acl_node_${nid} 2>/dev/null)
eval local ${_var} eval local ${_var}
local type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z') local type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z')
@@ -851,26 +873,24 @@ acl_node() {
uci -q add_list dhcp.@dnsmasq[0].addnmount=${GLOBAL_DNSMASQ_CONF_PATH} uci -q add_list dhcp.@dnsmasq[0].addnmount=${GLOBAL_DNSMASQ_CONF_PATH}
uci -q commit dhcp uci -q commit dhcp
json_init lua $APP_PATH/helper_dnsmasq.lua logic_restart
json_add_string "LOG" "1"
lua $APP_PATH/helper_dnsmasq.lua logic_restart "$(json_dump)"
else else
#Run a copy dnsmasq instance, DNS hijack for that need proxy devices. #Run a copy dnsmasq instance, DNS hijack for that need proxy devices.
dnsmasq_port=$(get_new_port auto) dnsmasq_port=$(get_new_port auto)
run_copy_dnsmasq flag="default" listen_port=${dnsmasq_port} local_dns="${DNSMASQ_LOCAL_DNS}" tun_dns="${DNSMASQ_TUN_DNS}" default_dns="${DNSMASQ_DEFAULT_DNS}" run_copy_dnsmasq flag="default" listen_port=${dnsmasq_port} local_dns="${DNSMASQ_LOCAL_DNS}" tun_dns="${DNSMASQ_TUN_DNS}" default_dns="${DNSMASQ_DEFAULT_DNS}"
#dhcp.leases to hosts #dhcp.leases to hosts
$APP_PATH/lease2hosts.sh > /dev/null 2>&1 & $APP_PATH/lease2hosts.sh > /dev/null 2>&1 &
log 2 "Dnsmasq[${dnsmasq_port}]:(127.0.0.1:${dns_listen_port})"
fi fi
else else
dnsmasq_port=$(get_new_port auto) dnsmasq_port=$(get_new_port auto)
run_copy_dnsmasq flag="${flag}" listen_port=${dnsmasq_port} local_dns="${LOCAL_DNS:-${AUTO_DNS}}" tun_dns="127.0.0.1#${dns_listen_port}" default_dns="${AUTO_DNS}" run_copy_dnsmasq flag="${flag}" listen_port=${dnsmasq_port} local_dns="${LOCAL_DNS:-${AUTO_DNS}}" tun_dns="127.0.0.1#${dns_listen_port}" default_dns="${AUTO_DNS}"
#dhcp.leases to hostsMore actions #dhcp.leases to hostsMore actions
$APP_PATH/lease2hosts.sh > /dev/null 2>&1 & $APP_PATH/lease2hosts.sh > /dev/null 2>&1 &
log 2 "Dnsmasq[${dnsmasq_port}]:(127.0.0.1:${dns_listen_port})"
fi fi
rm -f ${TMP_ACL_PATH}/acl_node_${nid} rm -f ${TMP_ACL_PATH}/acl_node_${nid}
done done
[ ! -f ${TMP_ACL_PATH}/acl_node_default ] && ENABLED_DEFAULT_ACL=0
[ "${acl_node_num}" == 0 ] && ENABLED_ACLS=0 && ENABLED_DEFAULT_ACL=0
} }
start() { start() {
@@ -892,16 +912,6 @@ start() {
nftflag=0 nftflag=0
USE_TABLES="" USE_TABLES=""
check_run_environment check_run_environment
[ "$(uci -q get dhcp.@dnsmasq[0].dns_redirect)" == "1" ] && {
uci -q set ${CONFIG}.@global[0].dnsmasq_dns_redirect='1'
uci -q commit ${CONFIG}
uci -q set dhcp.@dnsmasq[0].dns_redirect='0'
uci -q commit dhcp
json_init
json_add_string "LOG" "0"
lua $APP_PATH/helper_dnsmasq.lua restart "$(json_dump)"
}
[ -n "$USE_TABLES" ] && source $APP_PATH/${USE_TABLES}.sh start [ -n "$USE_TABLES" ] && source $APP_PATH/${USE_TABLES}.sh start
set_cache_var "USE_TABLES" "$USE_TABLES" set_cache_var "USE_TABLES" "$USE_TABLES"
if [ "$ENABLED_DEFAULT_ACL" == 1 ] || [ "$ENABLED_ACLS" == 1 ]; then if [ "$ENABLED_DEFAULT_ACL" == 1 ] || [ "$ENABLED_ACLS" == 1 ]; then
@@ -191,6 +191,7 @@ function acl_app(l)
local config_path = api.TMP_ACL_PATH .. "/" .. flag local config_path = api.TMP_ACL_PATH .. "/" .. flag
local config_file = config_path .. ".json" local config_file = config_path .. ".json"
local log_file = v.log == "0" and "/dev/null" or config_path .. ".log" local log_file = v.log == "0" and "/dev/null" or config_path .. ".log"
local dns_server, dns_port = api.parseDNS(v.remote_dns)
add_args(run_args, "flag", flag) add_args(run_args, "flag", flag)
add_args(run_args, "node", node[".name"]) add_args(run_args, "node", node[".name"])
add_args(run_args, "redir_port", v.redir_port) add_args(run_args, "redir_port", v.redir_port)
@@ -199,8 +200,10 @@ function acl_app(l)
add_args(run_args, "dns_listen_port", v.dns_port) add_args(run_args, "dns_listen_port", v.dns_port)
add_args(run_args, "direct_dns_query_strategy", v.direct_dns_query_strategy) add_args(run_args, "direct_dns_query_strategy", v.direct_dns_query_strategy)
add_args(run_args, "remote_dns_protocol", v.remote_dns_protocol) add_args(run_args, "remote_dns_protocol", v.remote_dns_protocol)
add_args(run_args, "remote_dns_tcp_server", v.remote_dns) add_args(run_args, "remote_dns_tcp_server", dns_server)
add_args(run_args, "remote_dns_udp_server", v.remote_dns) add_args(run_args, "remote_dns_tcp_port", dns_port)
add_args(run_args, "remote_dns_udp_server", dns_server)
add_args(run_args, "remote_dns_udp_port", dns_port)
add_args(run_args, "remote_dns_doh", v.remote_dns_doh) add_args(run_args, "remote_dns_doh", v.remote_dns_doh)
add_args(run_args, "remote_dns_client_ip", v.remote_dns_client_ip) add_args(run_args, "remote_dns_client_ip", v.remote_dns_client_ip)
add_args(run_args, "remote_dns_detour", v.remote_dns_detour) add_args(run_args, "remote_dns_detour", v.remote_dns_detour)
@@ -366,6 +366,6 @@ if arg[1] then
if arg[2] then if arg[2] then
var = jsonc.parse(arg[2]) var = jsonc.parse(arg[2])
end end
func(var) func(var or {})
end end
end end
@@ -256,9 +256,9 @@ add_shunt_t_rule() {
} }
load_acl() { load_acl() {
log_i18n 1 "Access Control:"
acl_json=$(lua $APP_PATH/app_acl.lua) acl_json=$(lua $APP_PATH/app_acl.lua)
acl_node acl_node
log_i18n 1 "Access Control:"
for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do
eval local $(cat "${TMP_ACL_PATH}/${sid}/var") eval local $(cat "${TMP_ACL_PATH}/${sid}/var")
@@ -294,9 +294,9 @@ add_shunt_t_rule() {
} }
load_acl() { load_acl() {
log_i18n 1 "Access Control:"
acl_json=$(lua $APP_PATH/app_acl.lua) acl_json=$(lua $APP_PATH/app_acl.lua)
acl_node acl_node
log_i18n 1 "Access Control:"
for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do
eval $(cat "${TMP_ACL_PATH}/${sid}/var") eval $(cat "${TMP_ACL_PATH}/${sid}/var")
@@ -253,28 +253,6 @@ hosts_foreach() {
done done
} }
get_first_dns() {
local __hosts_val=${1}; shift 1
__first() {
[ -z "${2}" ] && return 0
echo "${2}#${3}"
return 1
}
eval "hosts_foreach \"${__hosts_val}\" __first \"$@\""
}
get_last_dns() {
local __hosts_val=${1}; shift 1
local __first __last
__every() {
[ -z "${2}" ] && return 0
__last="${2}#${3}"
__first=${__first:-${__last}}
}
eval "hosts_foreach \"${__hosts_val}\" __every \"$@\""
[ "${__first}" == "${__last}" ] || echo "${__last}"
}
check_port_exists() { check_port_exists() {
local port=$1 local port=$1
local protocol=$2 local protocol=$2
+3 -3
View File
@@ -15,12 +15,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand PKG_NAME:=luci-app-wwand
PKG_RELEASE:=6 PKG_RELEASE:=7
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=a716b595603f2b1bc01d71be199c63bacc1ba9fc PKG_SOURCE_VERSION:=8dab3e346939ddc633f86e36b4b30365f62d74e0
PKG_SOURCE_DATE:=2026-08-25 PKG_SOURCE_DATE:=2026-08-27
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
+5 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?= FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),) ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION) PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=38 PKG_RELEASE:=39
endif endif
LUCI_TITLE:=Footstrap Theme LUCI_TITLE:=Footstrap Theme
@@ -170,6 +170,10 @@ define Build/Prepare/luci-theme-footstrap
$(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources $(SHELL) $(CURDIR)/strip-probes.sh $(PKG_BUILD_DIR)/htdocs/luci-static/resources
# …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only. # …and the same for the shell under root/ (71% and 95% comment lines). Whole-line `#` only.
$(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root $(SHELL) $(CURDIR)/strip-shell.sh $(PKG_BUILD_DIR)/root
# The two static assets luci.mk has no step for: the SVG favicon carries a 753-byte comment and
# the manifest its indentation, and both are fetched by every browser over a link uhttpd does
# not compress. awk only, so this runs on a buildbot with no node.
$(SHELL) $(CURDIR)/strip-assets.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap
$(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \ $(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js $(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js
endef endef
+55 -7
View File
@@ -33,14 +33,52 @@ done
TMP="$OUT.tmp.$$" TMP="$OUT.tmp.$$"
# $TMP.min too: an awk failure used to leave it behind next to the real output. # $TMP.min too: an awk failure used to leave it behind next to the real output.
trap 'rm -f "$TMP" "$TMP.min"' EXIT trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT
mkdir -p "$(dirname "$OUT")" mkdir -p "$(dirname "$OUT")"
# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can
# be read and edited alone; concatenated, that is 38 copies of the same six-to-eleven bytes, 554 of
# them, plus two files that are all comment and emit nothing but `@layer page{}`.
#
# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently
# re-layered instead of just being wrong — hence the check that each file opens with the layer its
# directory means. A file with no wrapper at all (00-header.css: the banner and the layer-order
# statement) is copied through, and must come before the wrapped ones or it would land inside the
# block.
emit_layer() {
layer="$1"; shift
body="$TMP.layer"
: > "$body"
for f in "$@"; do
if head -1 "$f" | grep -q '^@layer '; then
head -1 "$f" | grep -q "^@layer $layer {\$" || {
echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
rm -f "$body"; exit 1; }
# the file's own wrapper: its first line, and its last line, which is that wrapper's `}`
sed '1d;$d' "$f" >> "$body"
elif [ -s "$body" ]; then
echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
echo "build-css: swallowed into the $layer block instead of staying above it." >&2
rm -f "$body"; exit 1
else
cat "$f"
fi
done
if [ -s "$body" ]; then
printf '@layer %s {\n' "$layer"
cat "$body"
printf '}\n'
fi
rm -f "$body"
}
# glob expands in filename order # glob expands in filename order
cat "$D"/styles/*.css \ {
"$D"/styles/base/*.css \ emit_layer tokens "$D"/styles/*.css
"$D"/styles/theme/*.css \ emit_layer base "$D"/styles/base/*.css
"$D"/styles/pages/*.css > "$TMP" emit_layer theme "$D"/styles/theme/*.css
emit_layer page "$D"/styles/pages/*.css
} > "$TMP"
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines. # Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
# #
@@ -135,9 +173,19 @@ squeeze() {
prev = (length(out) ? substr(out, length(out), 1) : lastc) prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "") nxt = (i <= n ? substr(line, i, 1) : "")
# drop it entirely next to a delimiter; otherwise it may be a combinator # drop it entirely next to a delimiter; otherwise it may be a combinator
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":") #
# `>` is a delimiter too, and the only one that is itself a combinator: a
# space either side of it is decoration. 516 of them in the sheet, so it is
# 1,032 B — the file header used to guess "~200 bytes" for this whole pass.
# Safe because a `>` outside a string can only be the child combinator: the
# sheet has no media range syntax (`@media (width > 600px)`), and the 107
# `>` inside string literals never reach here, the scanner having copied them
# verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and
# `calc(100% - 10px)` make them ambiguous without tracking bracket depth,
# and they are worth 14 B and 34 B.
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
continue continue
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "") if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue continue
out = out " "; lastreal = " " out = out " "; lastreal = " "
continue continue
@@ -3,7 +3,8 @@
'require ui'; 'require ui';
'require dom'; 'require dom';
'require fs-prefs as prefs'; 'require fs-prefs as prefs';
'require fs-widgets as widgets'; 'require fs-axes as axes';
'require fs-assets as assets';
'require fs-version as ver'; 'require fs-version as ver';
/* The Appearance controls: the DOM that presents the axes. It owns no preference fs-prefs.js /* The Appearance controls: the DOM that presents the axes. It owns no preference fs-prefs.js
@@ -22,6 +23,201 @@
* The version line makes no request and must not grow one: which version is INSTALLED is what this * The version line makes no request and must not grow one: which version is INSTALLED is what this
* page answers, and which is available is the package manager's question. */ * page answers, and which is available is the package manager's question. */
/* ---- colour: reading what the page is actually painted ----
*
* This lived in fs-widgets.js, which the menu and the search palette also require so the whole
* colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe,
* canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was
* fs-widgets' only colour export and this file its only consumer. */
/* ---- colour: reading what the page is actually painted ----
*
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
/* Build the whole form. Returns a promise for one element wire() appends to the stock page. /* Build the whole form. Returns a promise for one element wire() appends to the stock page.
* *
* Everything applies immediately and there is nothing to save: every axis is this browser's, in * Everything applies immediately and there is nothing to save: every axis is this browser's, in
@@ -69,6 +265,9 @@ function build() {
]); ]);
}; };
/* the three literals every colour row repeats; a string literal survives minification intact */
const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap');
/* ---- the controls are LuCI's own ---- /* ---- the controls are LuCI's own ----
* *
* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other * Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other
@@ -105,7 +304,7 @@ function build() {
/* one colour axis: `probe` is the live token the control reads the effective colour back from, /* one colour axis: `probe` is the live token the control reads the effective colour back from,
* `contrast` the pair it reports */ * `contrast` the pair it reports */
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => { const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
const ctl = widgets.colorControl(axis.current(), bump(axis.apply), lbl, { const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
probe: probe, probe: probe,
read: axis.current, read: axis.current,
contrast: contrast, contrast: contrast,
@@ -137,7 +336,7 @@ function build() {
dark: _('Dark', 'footstrap') dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)), }, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), { group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
footstrap: 'Footstrap', footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast', hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is
@@ -145,7 +344,7 @@ function build() {
bootstrap: 'Bootstrap', bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020' '2020': 'OpenWrt 2020'
}, bump(repaint(prefs.applyPalette)), label)), }, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'), compact: _('Compact', 'footstrap'),
@@ -154,7 +353,7 @@ function build() {
}, bump(prefs.applyDensity), label)), }, bump(prefs.applyDensity), label)),
group(_('Rounding', 'footstrap'), group(_('Rounding', 'footstrap'),
(label) => sliderCtl(prefs.currentRadius(), 0, 20, bump(prefs.applyRadius), label)), (label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
/* The top layout has no accordion, so this switch is meaningless there: always built, /* The top layout has no accordion, so this switch is meaningless there: always built,
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
@@ -173,7 +372,7 @@ function build() {
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
* would look for the router-identity cue under it */ * would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), { colourGroup(_('Tint (router identification)', 'footstrap'), {
current: prefs.currentTint, apply: prefs.applyTint current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', { }, 'var(--fs-bg)', {
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
* token this axis must not move, so the ratio is reported instead of corrected */ * token this axis must not move, so the ratio is reported instead of corrected */
@@ -186,7 +385,7 @@ function build() {
* Not called "Density": that is the select above, and this string is both the caption and * Not called "Density": that is the select above, and this string is both the caption and
* the aria-label, so a screen reader would announce two rows under one name. */ * the aria-label, so a screen reader would announce two rows under one name. */
group(_('Tint strength', 'footstrap'), group(_('Tint strength', 'footstrap'),
(label) => sliderCtl(prefs.currentTintStrength(), 0, 200, bump(repaint(prefs.applyTintStrength)), label, { (label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
step: 5 step: 5
}), { cls: 'fs-ap-tint fs-ap-tintstr' }), }), { cls: 'fs-ap-tint fs-ap-tintstr' }),
@@ -195,29 +394,16 @@ function build() {
* as a link or status label it carries only itself. It is also what answers #20 ("sometimes * as a link or status label it carries only itself. It is also what answers #20 ("sometimes
* you want grey or black"), taking any #rrggbb the colour-chip presets that once sat here * you want grey or black"), taking any #rrggbb the colour-chip presets that once sat here
* are not coming back. */ * are not coming back. */
colourGroup(_('Accent', 'footstrap'), { /* Four status roles, one shape: the role's own colour read against a card. Written out
current: prefs.currentAccent, apply: prefs.applyAccent * eight times between here and the surfaces below, they cost their repeated literals in
}, 'var(--fs-accent)', { * full a string is not mangled so the rows are data and the row is stated once. */
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') ...[
}), [ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
colourGroup(_('Good', 'footstrap'), { [ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
current: prefs.currentGood, apply: prefs.applyGood [ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
}, 'var(--fs-good)', { ].map(([ label, current, apply, ink ]) =>
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
}),
colourGroup(_('Warning', 'footstrap'), {
current: prefs.currentWarn, apply: prefs.applyWarn
}, 'var(--fs-warn)', {
fg: 'var(--fs-warn)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Danger', 'footstrap'), {
current: prefs.currentDanger, apply: prefs.applyDanger
}, 'var(--fs-danger)', {
fg: 'var(--fs-danger)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
})
]; ];
/* ---- the surfaces: the sheet the UI is drawn on ---- /* ---- the surfaces: the sheet the UI is drawn on ----
@@ -229,30 +415,15 @@ function build() {
* and below that it is decoration, which a hairline is entitled to be, so the readout states * and below that it is decoration, which a hairline is entitled to be, so the readout states
* the number and leaves the call to the admin. */ * the number and leaves the call to the admin. */
const surfaces = [ const surfaces = [
colourGroup(_('Cards', 'footstrap'), { /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
current: prefs.currentCard, apply: prefs.applyCard * for the three that carry body text and the hairline itself for the border. */
}, 'var(--fs-panel)', { ...[
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap') [ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
}), [ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
colourGroup(_('Controls', 'footstrap'), { [ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
current: prefs.currentControl, apply: prefs.applyControl ].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
}, 'var(--fs-panel2)', { colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
fg: 'var(--fs-text)', bg: 'var(--fs-panel2)', label: _('on a control', 'footstrap')
}),
colourGroup(_('Sidebar and bar', 'footstrap'), {
current: prefs.currentBar, apply: prefs.applyBar
}, 'var(--fs-bar-bg)', {
fg: 'var(--fs-text)', bg: 'var(--fs-bar-bg)', label: _('in the sidebar', 'footstrap')
}),
colourGroup(_('Borders', 'footstrap'), {
current: prefs.currentLine, apply: prefs.applyLine
}, 'var(--fs-border)', {
fg: 'var(--fs-border)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap'), kind: 'shape'
})
]; ];
/* ---- section 3: the wallpaper and the rows each value brings ---- /* ---- section 3: the wallpaper and the rows each value brings ----
@@ -301,30 +472,30 @@ function build() {
group(_('Pattern', 'footstrap'), group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }), { extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600, group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20 })), bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100, group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5 })), bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), { group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
theme: _('Theme', 'footstrap'), theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap') original: _('As in file', 'footstrap')
}, bump(prefs.applyPatternInk), lbl)) }, bump(axes.applyPatternInk), lbl))
]; ];
/* …and the rows the FILE photo brings. */ /* …and the rows the FILE photo brings. */
const fileRows = [ const fileRows = [
group(_('File', 'footstrap'), group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }), { extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100, group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(prefs.applyPhotoDim), lbl, { step: 5 })) bump(axes.applyPhotoDim), lbl, { step: 5 }))
]; ];
function reflect(tok) { function reflect(tok) {
if (tok) { preview.src = prefs.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; } if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
} }
function reflectPattern(tok) { function reflectPattern(tok) {
if (tok) { patPreview.src = prefs.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; } if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
} }
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating /* `hidden` on the row, which 80-appearance.css restates at a specificity beating
@@ -335,64 +506,59 @@ function build() {
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
} }
reflect(prefs.currentLoginBg()); reflect(axes.currentLoginBg());
reflectPattern(prefs.currentPattern()); reflectPattern(axes.currentPattern());
togglePanel(prefs.currentWallpaper()); togglePanel(axes.currentWallpaper());
const setWallpaper = (v) => { prefs.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); }; const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
patChoose.addEventListener('click', () => { patErr.hidden = true; patInput.click(); }); /* Both uploads present the same three controls and the same four states pick, upload,
patInput.addEventListener('change', () => { * report, remove so the wiring is stated once. What differs is `after`: the pattern also
const f = patInput.files && patInput.files[0]; * has to move the Wallpaper dropdown, because the upload switched this browser onto the
patInput.value = ''; /* so re-picking the same file fires change again */ * tile and the page would otherwise paint it while the control still read Off.
if (!f) return; *
patErr.hidden = true; patChoose.disabled = true; * The file input is cleared on every change so re-picking the SAME file fires `change`
patChoose.textContent = _('Uploading…', 'footstrap'); * again, and the button carries its own busy state: the label is restored in `finally`, or
prefs.uploadPattern(f) * a failed upload leaves "Uploading…" standing for the life of the form. */
.then((tok) => { const wireUploader = (u) => {
reflectPattern(tok); const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
/* uploadPattern already switched this browser onto the pattern, so the control u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
* must catch up or the page paints the tile while the dropdown reads Off. u.input.addEventListener('change', () => {
* `dom.callClassMethod` is how LuCI moves its own widgets from outside; const f = u.input.files && u.input.files[0];
* setWallpaper is then called directly, because a programmatic setValue emits u.input.value = '';
* no `widget-change`. */ if (!f) return;
dom.callClassMethod(seg, 'setValue', 'pattern'); u.err.hidden = true; u.choose.disabled = true;
setWallpaper('pattern'); u.choose.textContent = _('Uploading…', 'footstrap');
}) u.upload(f)
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; }); .catch(fail)
}); .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
patRemove.addEventListener('click', () => { });
patErr.hidden = true; patRemove.disabled = true; u.remove.addEventListener('click', () => {
prefs.removePattern() u.err.hidden = true; u.remove.disabled = true;
.then(() => reflectPattern('')) u.drop().then(() => u.reflect('')).catch(fail)
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; }) .finally(() => { u.remove.disabled = false; });
.finally(() => { patRemove.disabled = false; }); });
};
wireUploader({
choose: patChoose, remove: patRemove, input: patInput, err: patErr,
label: patChooseLabel, reflect: reflectPattern,
upload: assets.uploadPattern, drop: assets.removePattern,
/* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is
* then called directly, because a programmatic setValue emits no `widget-change`. */
after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); }
}); });
chooseBtn.addEventListener('click', () => { err.hidden = true; fileInput.click(); }); wireUploader({
fileInput.addEventListener('change', () => { choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
const f = fileInput.files && fileInput.files[0]; label: chooseLabel, reflect: reflect,
fileInput.value = ''; /* so re-picking the same file fires change again */ upload: assets.uploadLoginBg, drop: assets.removeLoginBg
if (!f) return;
err.hidden = true; chooseBtn.disabled = true;
chooseBtn.textContent = _('Uploading…', 'footstrap');
prefs.uploadLoginBg(f)
.then(reflect)
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { chooseBtn.disabled = false; chooseBtn.textContent = chooseLabel; });
});
removeBtn.addEventListener('click', () => {
err.hidden = true; removeBtn.disabled = true;
prefs.removeLoginBg()
.then(() => reflect(''))
.catch((e) => { err.textContent = String((e && e.message) || e); err.hidden = false; })
.finally(() => { removeBtn.disabled = false; });
}); });
let seg; let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(prefs.currentWallpaper(), { seg = selectCtl(axes.currentWallpaper(), {
off: _('Off', 'footstrap'), off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'), pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap') file: _('File', 'footstrap')
@@ -442,14 +608,14 @@ function build() {
saveErr.hidden = false; saveErr.hidden = false;
return; return;
} }
const saved = prefs.matchesSavedDefault(); const saved = axes.matchesSavedDefault();
saveBtn.disabled = saved; saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap'); saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
} }
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
saveBtn.disabled = true; saveBtn.disabled = true;
saveErr.hidden = true; saveErr.hidden = true;
prefs.saveAsDefault() axes.saveAsDefault()
.then(() => { saveErr.hidden = true; }) .then(() => { saveErr.hidden = true; })
/* on failure refreshSave re-enables the button so the user can retry; the usual cause /* on failure refreshSave re-enables the button so the user can retry; the usual cause
* is a stale session, which a reload fixes. The raw rpc error stays in a title * is a stale session, which a reload fixes. The raw rpc error stays in a title
@@ -485,8 +651,8 @@ function build() {
location.reload(); location.reload();
}); });
} }
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved); twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), axes.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin); twoClick(resetBtn, _('Reset to default', 'footstrap'), axes.resetToBuiltin);
refreshSave(); /* correct label and enabled state before the first paint */ refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', { const versionLink = E('a', {
@@ -536,7 +702,8 @@ function build() {
function foldable(title, rows, key) { function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq); const id = 'fs-ap-fold-' + (++foldSeq);
let open = (prefs.lsGet(key) === 'on'); let open = (prefs.lsGet(key) === 'on');
const body = E('div', { 'class': 'fs-ap-body', 'id': id }, rows); /* id only: `aria-controls` needs one, and no rule has ever styled the panel itself */
const body = E('div', { 'id': id }, rows);
const btn = E('button', { const btn = E('button', {
'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id 'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id
}, [ }, [
@@ -577,8 +744,8 @@ function build() {
]); ]);
/* The first fill, deferred one microtask so the tree above is finished. It does not wait for /* The first fill, deferred one microtask so the tree above is finished. It does not wait for
* the form to be in the document: every readout resolves through widgets.probeColor(), whose * the form to be in the document: every readout resolves inside fs-widgets against a hidden
* hidden probe is attached to <body>, so a detached form still reads the live palette. */ * probe attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours); Promise.resolve().then(refreshColours);
return page; return page;
} }
@@ -761,6 +928,5 @@ function wire() {
} }
return baseclass.extend({ return baseclass.extend({
wire, wire
render
}); });
@@ -0,0 +1,336 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-axes as axes';
/* fs-assets putting a file ON THE ROUTER, and taking it off again.
*
* Two uploads live here, the pattern tile and the login photo, and everything they need that the
* rest of the theme does not: a DOMParser pass over an SVG, a canvas re-encode of a photo, the
* chmod that makes a freshly written 0600 file servable, and the rollback that runs when the token
* write fails after the bytes have landed.
*
* It is a module of its own because of WHERE it is needed: this machinery is reached only from the
* Appearance tab, one page out of nearly two hundred, and it was ~4 KB of DOMParser, canvas and rpc
* plumbing downloaded to a router's browser on the way to the DHCP page.
*
* The token accessors and the two live appliers live in `fs-axes` beside the axes themselves the
* Appearance previews and head.ut's pre-paint read the same fields so this file requires that one
* and nothing else of the theme's. */
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
* the resolved value measured on the router, a per-config ACL refusal resolves with 6
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
* Save button over a write that never happened. */
/* The four messages each said twice or three times below. Hoisted because a string literal is not
* mangled, so every repeat is paid in full on flash and because a message with two spellings is a
* message that gets fixed in one of them. The msgid and its 'footstrap' context stay literal
* arguments here, which is what update-po.sh's extractor reads. */
const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap');
const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap');
const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap');
const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap');
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
*
* The bytes come from the admin, never from a third-party host: a theme in a package feed does not
* reach out at run time.
*
* Router-side, like the login photo and for the same reason a file cannot live in localStorage,
* and a pattern is something a router wears. The path is a fixed server-side constant matched
* exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
* package upgrade cannot delete it (keep.d carries it across a sysupgrade), and the served name
* ends in .svg because uhttpd types a file by extension.
*
* How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
* supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
* under every palette.
*
* What is refused: an SVG is a document, not a picture, and while a masked or background image
* never executes script, the same file fetched from its own URL would. Uploading already needs an
* authenticated admin session with uci write rights, so this is defence in depth but the check is
* cheap and the failure mode is somebody else's browser. */
const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
/* What makes an uploaded SVG unacceptable, decided on the PARSED document and not on its text: a
* regex over the source guesses at a grammar the browser already implements, and guesses in both
* directions a handler pattern also matches an ordinary `only_selected="false"`, while an entity
* or odd whitespace hides a real handler from it.
*
* DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
* no subresource is fetched, no handler is bound. So the questions are exact ones about nodes:
*
* - is it an SVG at all (a parsererror, or a root that is not <svg>, is not an image)
* - does it carry an element that executes or embeds (script, foreignObject, iframe, )
* - does it carry a real event-handler attribute `^on[a-z]+$`
* - does any value start a `javascript:` url
* - does any href point off this router; `#fragment` and `data:` stay allowed, being how a tile
* refers to its own <defs> and embeds a bitmap
*
* The check is for the way the file can be reached that a mask does not cover: its own URL, opened
* directly, same-origin with the session.
*
* `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
* time (`<set attributename="href" to="javascript:…">`), and a tile that animates repaints a
* full-viewport layer behind every page. */
const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set' ];
const SVG_NS = 'http://www.w3.org/2000/svg';
/* null if the parsed document is fine, otherwise the sentence to show. */
function _svgObjection(text) {
let doc;
try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
catch (e) { return MSG_NOT_SVG; }
const root = doc && doc.documentElement;
/* An SVG is its ROOT'S NAMESPACE, not its root's spelling. `nodeName` is the qualified name, so
* it answers both questions wrong at once: `<svg xmlns="http://www.w3.org/1999/xhtml">` reads as
* `svg` and is admitted although it is an XHTML document that executes on all three engines,
* while `<s:svg xmlns:s="http://www.w3.org/2000/svg">` reads as `s:svg` and is turned away
* although it is an ordinary picture. */
if (!root || doc.querySelector('parsererror') ||
root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== SVG_NS)
return MSG_NOT_SVG;
const refused = _('That SVG contains script or external references, which this theme will not install.', 'footstrap');
/* A processing instruction can attach an XSLT stylesheet carried INSIDE this same document, and
* the transform's output is a document this walk never sees: `<xsl:element name="script">`
* builds the element by name, so nothing here is called script. Measured executing on Firefox
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
* one, and without the PI the embedded stylesheet is never applied. */
for (const n of doc.childNodes) if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) return refused;
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
for (const el of els) {
/* localName, never nodeName: in an XML document nodeName carries the namespace PREFIX, so
* `<s:script xmlns:s="http://www.w3.org/2000/svg">` reads as `s:script` and walks straight
* past a list of names measured executing on all three engines, as does the same element
* put in the xhtml namespace. localName is `script` for every one of those spellings. */
if (PAT_BAD_TAGS.indexOf((el.localName || el.nodeName).toLowerCase()) >= 0) return refused;
const attrs = el.attributes || [];
for (let i = 0; i < attrs.length; i++) {
const n = attrs[i].name.toLowerCase();
const v = String(attrs[i].value || '').trim();
/* a REAL handler is `on` + letters and nothing else; `only_selected` is not one. The
* qualified name is right here, unlike on the element above: a prefixed `s:onload` or
* `xlink:onload` fires on none of the three engines, so matching localName would only
* refuse files that do nothing. */
if ((/^on[a-z]+$/).test(n)) return refused;
if ((/^javascript:/i).test(v)) return refused;
/* off-router reference. A leading `//` is protocol-relative and just as external. */
if ((/(?:^|:)href$/).test(n) && (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v)) return refused;
}
}
return null;
}
/* read the picked file as text so it can be inspected before upload, and so what reaches the
* router is exactly the bytes that were checked */
function _readText(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
fr.readAsText(file);
});
}
/* ---- login/page background upload: router-side, and deliberately not an axis ----
* The other axes are per-browser with a router default; this one has no browser layer. An admin
* uploads an image once, it becomes the router-wide background for every device and shows
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() it must not
* move the Save button and needs no factory, so tools/axes.mjs never sees it.
*
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
* a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
* a path. */
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
* gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
* resolves with the ubus status as a number, which this code can branch on. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
* overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
* removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
* which is what an admin removing a background believes they have stopped. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
const code = (typeof res === 'number') ? res : parseInt(res, 10);
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
});
}
/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
* (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
* commands chmod 644 on the two files this module uploads with no caller-controlled
* argument. */
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
/* and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
* payload, so a chmod that ran and failed still comes back as a successful call and the upload
* then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
function _chmodServeable(path) {
return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
if (res && res.code)
throw new Error(MSG_UPLOAD_FAILED + ' (chmod ' + res.code + ')');
return res;
});
}
/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
* the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
* dropped and the uploaded blob is exactly what the browser drew.
*
* The whole body is guarded, because a throw inside an event handler does not reject the promise it
* sits in it escapes as an uncaught error and leaves the promise pending forever. Two real ways
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
* drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
* and lying until the form is rebuilt on a later arrival at the page. */
function _downscale(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
try {
const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.drawImage(img, 0, 0, w, h);
cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(MSG_BAD_IMAGE)),
'image/jpeg', BG_QUALITY);
} catch (e) { reject(new Error(MSG_BAD_IMAGE)); }
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
img.src = url;
});
}
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
* below write the file first and the token second, and the second half can fail on its own (no
* `settings` section, a narrowed uci ACL, ubus busy) the image then sits at mode 0644 and is
* served to anyone through the /www symlink, which does not depend on the token, while Remove is
* hidden precisely because the token is empty. Roll the file back and report the failure that
* started it; a rollback that itself fails is appended, because the admin has to know the file is
* there. */
function _rollbackUpload(path, cause) {
return _removeServed(path).then(
() => Promise.reject(cause),
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
);
}
/* ---- one upload, two assets ----
*
* Both wallpapers travel the same road: refuse what should not be sent, turn the picked file into
* the bytes that will actually be stored, POST them to cgi-upload, take the md5 `checksum` back as
* the cache-bust token, make the file servable, write the token to uci, and only then paint it.
* Every step of that was written out twice, and the two copies had already drifted one quoted
* the url() it wrote with `"` and the other with `'`.
*
* What genuinely differs is one function: what `prepare` hands back to be uploaded. The SVG is read
* as text and inspected, because an SVG is a document and the check has to see the parsed tree; the
* photo is redrawn on a canvas, which both bounds it and drops EXIF, because a raster has nothing
* to inspect. Everything either side of that is the same road.
*
* `rollback` is the reason the order matters. The bytes land before the token does, and the second
* half can fail on its own no `settings` section, a narrowed uci ACL, ubus busy leaving a file
* at 0644 served through the /www symlink while Remove stays hidden, because Remove keys off the
* token being non-empty. So a failure after the write takes the file away again. */
function assetAxis(o) {
const upload = (file) => Promise.resolve()
.then(() => o.prepare(file))
.then((blob) => {
const fd = new FormData();
fd.append('sessionid', rpc.getSessionID());
fd.append('filename', o.path);
fd.append('filedata', blob, o.filename);
return fetch(L.env.cgi_base + '/cgi-upload',
{ method: 'POST', body: fd, credentials: 'same-origin' })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
})
.then((reply) => {
/* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
if (!reply || reply.failure)
return Promise.reject(new Error((reply && reply.failure && reply.failure[1])
|| MSG_UPLOAD_FAILED));
const tok = String(reply.checksum || '').toLowerCase();
if (!axes.tokenOk(tok)) return Promise.reject(new Error(MSG_UPLOAD_FAILED));
/* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
* (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not
* just the ubus call's */
return _chmodServeable(o.path)
/* uci gets the token and nothing else: putting a file on the router is not the same
* act as making every other device paint it */
.then(() => _uciSet('footstrap', 'settings', { [o.field]: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(o.path, e))
.then(() => {
/* switch this browser onto it: the ordinary axis path, localStorage only */
axes.applyWallpaper(o.wallpaper);
o.apply(tok);
return tok;
});
});
/* Remove: delete the file, blank the token (uci `set` to '', not delete the scoped ACL grants
* set/commit only), clear the url() live. */
const remove = () => _removeServed(o.path)
.then(() => _uciSet('footstrap', 'settings', { [o.field]: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { o.apply(''); });
return { upload, remove };
}
/* The tile. No canvas step, which is what strips a photo's EXIF: an SVG redrawn to a canvas comes
* back a raster, so the parsed-document check above stands in for it. */
const PATTERN = assetAxis({
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
apply: (tok) => axes.applyPattern(tok),
prepare: (file) => {
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
return _readText(file).then((text) => {
const objection = _svgObjection(text);
if (objection) return Promise.reject(new Error(objection));
return new Blob([ text ], { type: 'image/svg+xml' });
});
}
});
/* The photo. cgi-upload is the endpoint L.ui.uploadFile uses session in the `sessionid` field,
* path in `filename`, bytes in `filedata` and it authorises the write against the ACL's `file`
* grant for BG_PATH. */
const LOGIN_BG = assetAxis({
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
apply: (tok) => axes.applyLoginBg(tok),
prepare: (file) => {
if (!file || !(/^image\//).test(file.type || ''))
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
if (file.size > BG_SRC_MAX)
return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
return _downscale(file);
}
});
return baseclass.extend({
uploadPattern: PATTERN.upload,
removePattern: PATTERN.remove,
uploadLoginBg: LOGIN_BG.upload,
removeLoginBg: LOGIN_BG.remove
});
@@ -0,0 +1,482 @@
'use strict';
'require baseclass';
'require rpc';
'require fs-prefs as prefs';
/* fs-axes the nineteen Appearance axes and the Save-as-default machinery.
*
* Split out of fs-prefs.js for one reason: WHERE it is needed. `fs-prefs` is required by the
* chrome, the menu and the search palette, so it is fetched on every admin page and of its
* sixty-one exports the cold path called eight. Everything here is reached only from
* `fs-appearance` (the form) and `fs-assets` (the two uploads), both page modules, so the router
* fetches it on the Appearance tab and nowhere else. Measured: 6.6 KB off what every page
* downloads.
*
* What stayed behind in `fs-prefs`, and why:
* - the localStorage wrappers and `sd()`, which everything here calls through `prefs.`
* - dark mode, because `guardDarkStamp` defends against a third-party app on every page and
* `tools/chrome-fence.mjs` holds `stampDark()` to that file by path
* - layout, density, rail and auto-collapse, because the chrome and the menu apply them live
* and because `tools/scroll-anchor.mjs` and `tools/scroll-jank.mjs` stamp layout and density
* through `L.require('fs-prefs')` to sweep their matrix
*
* The pre-paint in head.ut has already stamped every axis before the first frame, so nothing here
* is needed to PAINT a page correctly only to change one from the form. tools/axes.mjs reads the
* whole resources directory rather than a path, precisely so an axis may live in a second file. */
const FS_RADIUS_DEFAULT = 12;
const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
* string, or a number from a config written before the axes took colours) or a caller. Anything
* unrecognised reads as off, the built-in default. */
function normColor(v) {
if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
if (typeof v !== 'string') return 0;
const s = v.trim();
if (FS_HEX_RE.test(s)) return s.toLowerCase();
const h = parseInt(s, 10);
return (h >= 1 && h <= 360) ? h : 0;
}
function colorAxis(key, attr, hueProp, colorProp) {
/* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
* hyphen fold changes nothing; it is here because the failure when one is not would be silent
* (see enumAxis). */
const sdKey = key.slice(3).replace(/-/g, '_');
const def = () => normColor(prefs.sd(sdKey));
return {
def,
current() {
const raw = prefs.lsGet(key);
return (raw !== null) ? normColor(raw) : def();
},
apply(val) {
const root = document.documentElement;
const v = normColor(val);
prefs.lsSet(key, String(v));
if (!v) {
root.removeAttribute(attr);
root.style.removeProperty(hueProp);
root.style.removeProperty(colorProp);
} else if (typeof v === 'number') {
root.style.removeProperty(colorProp);
/* the hue first, then the attribute that switches the rotation on: the other
* order paints one frame in the previous colour on a fresh load */
root.style.setProperty(hueProp, String(v));
root.setAttribute(attr, 'hue');
} else {
root.style.removeProperty(hueProp);
root.style.setProperty(colorProp, v);
root.setAttribute(attr, 'hex');
}
}
};
}
/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
* [min,max], stores the choice explicitly (including the default, so it overrides a router default)
* and removes the property AT the default, so 02-tokens' own value shows through; they differ only
* in how the number formats onto the property, which is the one varying argument. The prefs.sd() field
* name is passed explicitly because one instance needs a rename rather than a spelling
* ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
* colorAxis name above. */
function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
const def = () => { const d = prefs.sd(sdKey); return inRange(d) ? d : dfl; };
return {
def,
current() {
const raw = prefs.lsGet(key);
if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
return def();
},
apply(n) {
const root = document.documentElement;
const v = Math.max(min, Math.min(max, n | 0));
prefs.lsSet(key, String(v));
if (v === dfl) root.style.removeProperty(prop);
else root.style.setProperty(prop, fmt(v));
}
};
}
/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
* value, defined in styles/03-palettes.css.
*
* Not the enumAxis shape, which has one `on` name and reads every other stored string including a
* real palette as the default. The array is what VALIDATES a stored value: a name added to the
* CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
* it and the first touch of any other control takes it away.
*
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
* recoloured 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
* data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
* only decides whether THIS browser paints one, so a router-wide backdrop comes from
* Save-as-default, including the pre-login page.
*
* The list validates a stored value, so adding one means this line, the head.ut whitelist, the
* Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
* in the list falls back to 'off'. */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
* and no re-render.
*
* Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
* fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
* otherwise the bar stays stacked or stays unstacked and overflows until the next resize. */
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
const currentTint = TINT.current, applyTint = TINT.apply;
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
* separate because they carry separate meanings and every derived tint is a color-mix() of the
* role, so each follows its own axis. They are not protected from recolouring: a status colour is
* information, and an admin who paints Danger green has said so. What the theme owes them is
* readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
*
* The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
* more colorAxis instances, because:
*
* - there is no hue mode rotating the hue of a near-white card keeps its chroma (~0.003), so
* every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
* - there is no derived ink what reads on these is --fs-text, a palette token these axes must
* not move, so the Appearance page reports the contrast instead;
* - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
* and every derived token follows because each is a color-mix() of the one this sets. That is
* why --fs-bar-bg is a surface of its own an admin who wants a dark chrome over light cards
* has to be able to say so.
*
* Off is prefs.lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
* it". */
function surfaceAxis(key, sdKey, prop) {
const norm = (v) => {
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
return FS_HEX_RE.test(s) ? s : 0;
};
const def = () => norm(prefs.sd(sdKey));
return {
def,
current() {
const raw = prefs.lsGet(key);
return (raw !== null) ? norm(raw) : def();
},
apply(val) {
const v = norm(val);
prefs.lsSet(key, String(v));
if (v) document.documentElement.style.setProperty(prop, v);
else document.documentElement.style.removeProperty(prop);
}
};
}
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
const currentCard = CARD.current, applyCard = CARD.apply;
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const currentBar = BAR.current, applyBar = BAR.apply;
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
const currentLine = LINE.current, applyLine = LINE.apply;
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
const currentGood = GOOD.current, applyGood = GOOD.apply;
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
const currentWarn = WARN.current, applyWarn = WARN.apply;
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
* observes the attribute and folds the accordion into dropdowns or restores it.
*
* Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
* the pre-paint script overrides it, so it always carries an explicit value. localStorage would
* report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
const AXIS_KEYS = [
'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper', 'fs-tint',
'fs-accent', 'fs-good', 'fs-warn', 'fs-danger', 'fs-card', 'fs-control',
'fs-bar', 'fs-line', 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength',
'fs-density', 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength',
'fs-pattern-ink'
];
/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
* strength and 200% the cap. 0 is not quite "no tint" the relative colour that applies the tint
* replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
* untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
* is moot under the File wallpaper, where the photo covers the canvas.
*
* This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis
* instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole
* module throws, taking the chrome and the menu with it. */
const FS_TSTR_DEFAULT = 100;
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
/* Photo dim: the scrim opacity over the FILE photo (0100%). The photo is shared; how strongly
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
*
* Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
* the artwork. Strength is the layer's opacity 0-100, which is the knob a `<g opacity>` baked into
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
const FS_PSIZE_DEFAULT = 440;
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
const FS_PSTR_DEFAULT = 20;
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
* the resolved value measured on the router, a per-config ACL refusal resolves with 6
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
* Save button over a write that never happened. */
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
function snapshotAxes() {
return {
layout: prefs.currentLayout(),
darkmode: prefs.currentMode(),
palette: currentPalette(),
wallpaper: currentWallpaper(),
tint: String(currentTint()),
accent: String(currentAccent()),
good: String(currentGood()),
warn: String(currentWarn()),
danger: String(currentDanger()),
card: String(currentCard()),
control: String(currentControl()),
bar: String(currentBar()),
line: String(currentLine()),
rounding: String(currentRadius()),
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
tint_strength: String(currentTintStrength()),
density: prefs.currentDensity(),
photo_dim: String(currentPhotoDim()),
pattern_size: String(currentPatternSize()),
pattern_strength: String(currentPatternStrength()),
pattern_ink: currentPatternInk()
};
}
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
* match without a reload.
*
* Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
* matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
* since prefs.currentLayout() reads the attribute its fallback must stay `top`, matching head.ut's
* stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
* resetToSaved() lands on the wrong layout. */
function _resolvedDefault() {
return {
layout: prefs.sd('layout') || 'top',
darkmode: prefs.modeDefault(),
palette: PALETTE.def(),
wallpaper: WALLPAPER.def(),
tint: String(TINT.def()),
accent: String(ACCENT.def()),
good: String(GOOD.def()),
warn: String(WARN.def()),
danger: String(DANGER.def()),
card: String(CARD.def()),
control: String(CONTROL.def()),
bar: String(BAR.def()),
line: String(LINE.def()),
rounding: String(radiusDefault()),
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
tint_strength: String(tintStrengthDefault()),
density: prefs.densityDefault(),
photo_dim: String(photoDimDefault()),
pattern_size: String(patternSizeDefault()),
pattern_strength: String(patternStrengthDefault()),
pattern_ink: PINK.def()
};
}
let _savedDefault = _resolvedDefault();
function matchesSavedDefault() {
const cur = snapshotAxes();
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
}
/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
* Every axis is per-browser. An axis that wrote through on change on the argument that the photo
* it relates to is router-side re-pointed the router-wide default for every other device from one
* browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
* preference must never mutate shared state invisibly.
*
* Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
* `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
* all. */
function saveAsDefault() {
const snap = snapshotAxes();
return _uciSet('footstrap', 'settings', snap)
.then(() => _uciCommit('footstrap'))
.then(() => { _savedDefault = snap; });
}
/* ---- the two resets, which are not the same escape hatch ----
*
* Both drop this browser's tweaks and differ in what is underneath:
*
* resetToSaved() clears the keys, so every axis falls back to the router default where one is
* set and to the built-in where it is not. The browser goes back to inheriting.
* resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
* ships" — clearing the keys is the sentence that means "inherit the router
* default".
*
* Both leave /etc/config/footstrap alone: neither un-saves a router default.
*
* The caller reloads so head.ut re-applies everything in one pass the appliers repaint correctly,
* but the controls on the page were built from the values they had at render time. */
function resetToSaved() {
AXIS_KEYS.forEach(prefs.lsDel);
}
/* The built-in defaults, written through the ordinary appliers so each validates its own value and
* stamps :root as usual. Stated rather than derived: a default is a default because it is what a
* bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
* CSS. */
function resetToBuiltin() {
/* `top` for layout, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci
* says nothing), so it is what "as the theme ships" means. The colour and surface axes reset to
* 0, which is "the palette's own".
*
* Stated as calls rather than carried in AXES: a fourth column of thunks measured 297 B against
* this list, and a wrong value here is what the Save button's "Reset to default" shows on the
* first click the one state no static gate can see and the live check does. */
prefs.applyLayout('top');
prefs.applyMode('auto');
applyPalette('footstrap');
applyWallpaper('off');
applyTint(0);
applyAccent(0);
applyGood(0);
applyWarn(0);
applyDanger(0);
applyCard(0);
applyControl(0);
applyBar(0);
applyLine(0);
applyRadius(FS_RADIUS_DEFAULT);
prefs.applyAutoCollapse('off');
applyTintStrength(FS_TSTR_DEFAULT);
prefs.applyDensity('normal');
applyPhotoDim(FS_PDIM_DEFAULT);
applyPatternSize(FS_PSIZE_DEFAULT);
applyPatternStrength(FS_PSTR_DEFAULT);
applyPatternInk('theme');
}
/* ---- the two uploaded wallpapers, browser side ----
*
* What the router last saved, what URL that is, and how to paint it. Putting the file THERE is
* fs-assets.js: a DOMParser pass, a canvas re-encode, a chmod and a rollback, reached only from the
* Appearance tab and so not worth downloading on every admin page. The token accessors stay here
* because `prefs.sd()` is private to this module and because head.ut's pre-paint reads the same fields.
*
* Neither is an axis: an axis is per-browser with a router default, and these have no browser
* layer one admin uploads once and every device sees it, pre-login included. So they are absent
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
const PAT_SERVE = '/luci-static/footstrap/pattern.svg'; /* the uhttpd symlink the uci-default makes */
function currentPattern() {
const t = prefs.sd('pattern');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
function applyPattern(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
else root.style.removeProperty('--fs-pattern-url');
prefs.setSD('pattern', tok || '');
}
const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink the uci-default makes */
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
* module see the axes contract in head.ut. */
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
/* the same question asked from fs-assets.js, which validates the checksum an upload replies with.
* A predicate rather than the pattern itself, so the charset stays stated once. */
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
* pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = prefs.sd('login_bg');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
function applyLoginBg(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
else root.style.removeProperty('--fs-login-bg-url');
prefs.setSD('login_bg', tok || '');
}
return baseclass.extend({
currentPalette, applyPalette,
currentWallpaper, applyWallpaper,
currentTint, applyTint,
currentAccent, applyAccent,
currentGood, applyGood,
currentWarn, applyWarn,
currentDanger, applyDanger,
currentCard, applyCard,
currentControl, applyControl,
currentBar, applyBar,
currentLine, applyLine,
currentRadius, applyRadius,
currentTintStrength, applyTintStrength,
currentPhotoDim, applyPhotoDim,
currentPatternSize, applyPatternSize,
currentPatternStrength, applyPatternStrength,
currentPatternInk, applyPatternInk,
currentPattern, patternUrl, applyPattern,
currentLoginBg, loginBgUrl, applyLoginBg,
tokenOk,
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
});
@@ -151,7 +151,6 @@ _mqDark.addEventListener('change', () => {
/* Corner radius: the card radius (020px) as an inline --fs-radius-base on :root, from which /* Corner radius: the card radius (020px) as an inline --fs-radius-base on :root, from which
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head * 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
* to this one number, hence the named const. */ * to this one number, hence the named const. */
const FS_RADIUS_DEFAULT = 12;
/* ---- the four axis shapes, each written once ---- /* ---- the four axis shapes, each written once ----
* *
@@ -171,34 +170,50 @@ const FS_RADIUS_DEFAULT = 12;
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third * `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
* one landed, `autoCollapse` has no :root attribute. */ * one landed, `autoCollapse` has no :root attribute. */
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root (no /* An axis whose values are a list, with one of them stamped as nothing.
* attribute). */ *
function enumAxis(key, attr, on, off) { * `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
* is what a stray or missing value falls back to. `after` runs once the attribute is stamped, for
* the axis that has to re-take a measurement.
*
* The list IS the validation: a name added to the stylesheet and not here is one head.ut pre-paints
* and this rejects, so the page paints it and the first touch of any other control takes it away. */
function listAxis(key, attr, values, dflt, after) {
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the /* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The underscore is the point: the
* localStorage key is hyphenated and the uci option is not, so a bare slice(3) names a field * localStorage key is hyphenated and the uci option is not, so a bare slice(3) names a field
* head.ut never emits, sd() returns undefined forever, and the axis reports the built-in * head.ut never emits, sd() returns undefined forever, and the axis reports the built-in
* default however the router is set Save-as-default then writes it over the admin's value. */ * default however the router is set Save-as-default then writes it over the admin's value. */
const sdKey = key.slice(3).replace(/-/g, '_'); const sdKey = key.slice(3).replace(/-/g, '_');
const def = () => (sd(sdKey) === on ? on : off); const ok = (v) => (values.indexOf(v) >= 0);
const def = () => (ok(sd(sdKey)) ? sd(sdKey) : dflt);
return { return {
def, def,
current() { current() {
const s = lsGet(key); const s = lsGet(key);
if (s === on) return on; if (ok(s)) return s;
if (s === off) return off; if (s === dflt) return dflt;
if (s === null) return def(); if (s === null) return def();
return off; /* a stray value reads as the built-in default */ return dflt; /* a stray value reads as the built-in default */
}, },
apply(val) { apply(val) {
const root = document.documentElement; const root = document.documentElement;
const isOn = (val === on); const v = ok(val) ? val : dflt;
lsSet(key, isOn ? on : off); /* stored explicitly (including the default), so it overrides a router default */
if (isOn) root.setAttribute(attr, on); lsSet(key, v);
else root.removeAttribute(attr); if (v === dflt) root.removeAttribute(attr);
else root.setAttribute(attr, v);
if (after) after();
} }
}; };
} }
/* A two-value axis: `on` is stamped as the attribute's value, `off` is a bare :root. The list shape
* with a list of one kept as its own name because tools/axes.mjs matches the call, and because
* "two-valued" is what most of these axes are. */
function enumAxis(key, attr, on, off) {
return listAxis(key, attr, [ on ], off);
}
/* A colour axis Tint, Accent and the three status colours are one axis pointed at five tokens: /* A colour axis Tint, Accent and the three status colours are one axis pointed at five tokens:
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the * same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
* attribute, or a fresh load paints one frame in the previous colour). * attribute, or a fresh load paints one frame in the previous colour).
@@ -215,253 +230,15 @@ function enumAxis(key, attr, on, off) {
* a third to say which is in effect, and that third is the one a pre-paint script forgets. * a third to say which is in effect, and that third is the one a pre-paint script forgets.
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode * `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
* clears the other's property, so the two can never both be half-applied. */ * clears the other's property, so the two can never both be half-applied. */
const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
/* 0 | 1..360 | '#rrggbb', from anything: localStorage (always a string), the router default (a uci
* string, or a number from a config written before the axes took colours) or a caller. Anything
* unrecognised reads as off, the built-in default. */
function normColor(v) {
if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
if (typeof v !== 'string') return 0;
const s = v.trim();
if (FS_HEX_RE.test(s)) return s.toLowerCase();
const h = parseInt(s, 10);
return (h >= 1 && h <= 360) ? h : 0;
}
function colorAxis(key, attr, hueProp, colorProp) {
/* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key is one word today, so the
* hyphen fold changes nothing; it is here because the failure when one is not would be silent
* (see enumAxis). */
const sdKey = key.slice(3).replace(/-/g, '_');
const def = () => normColor(sd(sdKey));
return {
def,
current() {
const raw = lsGet(key);
return (raw !== null) ? normColor(raw) : def();
},
apply(val) {
const root = document.documentElement;
const v = normColor(val);
lsSet(key, String(v));
if (!v) {
root.removeAttribute(attr);
root.style.removeProperty(hueProp);
root.style.removeProperty(colorProp);
} else if (typeof v === 'number') {
root.style.removeProperty(colorProp);
/* the hue first, then the attribute that switches the rotation on: the other
* order paints one frame in the previous colour on a fresh load */
root.style.setProperty(hueProp, String(v));
root.setAttribute(attr, 'hue');
} else {
root.style.removeProperty(hueProp);
root.style.setProperty(colorProp, v);
root.setAttribute(attr, 'hex');
}
}
};
}
/* A numeric slider axis that sets an inline custom property and no attribute. Each validates to
* [min,max], stores the choice explicitly (including the default, so it overrides a router default)
* and removes the property AT the default, so 02-tokens' own value shows through; they differ only
* in how the number formats onto the property, which is the one varying argument. The sd() field
* name is passed explicitly because one instance needs a rename rather than a spelling
* ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
* colorAxis name above. */
function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
const def = () => { const d = sd(sdKey); return inRange(d) ? d : dfl; };
return {
def,
current() {
const raw = lsGet(key);
if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
return def();
},
apply(n) {
const root = document.documentElement;
const v = Math.max(min, Math.min(max, n | 0));
lsSet(key, String(v));
if (v === dfl) root.style.removeProperty(prop);
else root.style.setProperty(prop, fmt(v));
}
};
}
/* Palette: footstrap is the default (bare :root); every other colourway is an opt-in data-palette
* value, defined in styles/03-palettes.css.
*
* Not the enumAxis shape, which has one `on` name and reads every other stored string including a
* real palette as the default. The array is what VALIDATES a stored value: a name added to the
* CSS and not here is one head.ut pre-paints and the live applier then rejects, so the page paints
* it and the first touch of any other control takes it away.
*
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
function paletteDefault() {
const d = sd('palette');
return (PALETTES.indexOf(d) >= 0) ? d : 'footstrap';
}
function currentPalette() {
const s = lsGet('fs-palette');
if (PALETTES.indexOf(s) >= 0) return s;
if (s === 'footstrap') return 'footstrap';
if (s === null) return paletteDefault();
return 'footstrap';
}
function applyPalette(val) {
const root = document.documentElement;
const v = (PALETTES.indexOf(val) >= 0) ? val : 'footstrap';
/* stored explicitly (including 'footstrap'), so it overrides a router default see the
* header */
lsSet('fs-palette', v);
if (v === 'footstrap') root.removeAttribute('data-palette');
else root.setAttribute('data-palette', v);
}
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
* recoloured 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
* data-wallpaper carries the value, or is absent for 'off'. Both images are router-side; this axis
* only decides whether THIS browser paints one, so a router-wide backdrop comes from
* Save-as-default, including the pre-login page.
*
* The list validates a stored value, so adding one means this line, the head.ut whitelist, the
* Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css. A value that is no longer
* in the list falls back to 'off'. */
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
function wallpaperDefault() {
const d = sd('wallpaper');
return (WALLPAPERS.indexOf(d) >= 0) ? d : 'off';
}
function currentWallpaper() {
const s = lsGet('fs-wallpaper');
if (WALLPAPERS.indexOf(s) >= 0) return s;
if (s === 'off') return 'off';
if (s === null) return wallpaperDefault();
return 'off';
}
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
* and no re-render.
*
* Beyond stamping the attribute it must re-run the measured decisions (fitChrome, fitTables,
* fitShell), which were taken against the old metrics: Compact makes more fit and Large less, so
* otherwise the bar stays stacked or stays unstacked and overflows until the next resize. */
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */ const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
function densityDefault() { const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const d = sd('density'); const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
return (DENSITIES.indexOf(d) >= 0) ? d : 'normal'; densityDefault = DENSITY.def;
}
function currentDensity() {
const s = lsGet('fs-density');
if (DENSITIES.indexOf(s) >= 0) return s;
if (s === 'normal') return 'normal';
if (s === null) return densityDefault();
return 'normal';
}
function applyDensity(val) {
const root = document.documentElement;
const v = (DENSITIES.indexOf(val) >= 0) ? val : 'normal';
lsSet('fs-density', v);
if (v === 'normal') root.removeAttribute('data-density');
else root.setAttribute('data-density', v);
fit.schedule();
}
function applyWallpaper(val) {
const root = document.documentElement;
const v = (WALLPAPERS.indexOf(val) >= 0) ? val : 'off';
lsSet('fs-wallpaper', v);
if (v === 'off') root.removeAttribute('data-wallpaper');
else root.setAttribute('data-wallpaper', v);
}
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one /* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status * colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
* colours keep the palette's values the cue colours the paper, not the UI. On a hue it is mixed * colours keep the palette's values the cue colours the paper, not the UI. On a hue it is mixed
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the * in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */ * canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
const currentTint = TINT.current, applyTint = TINT.apply;
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
* separate because they carry separate meanings and every derived tint is a color-mix() of the
* role, so each follows its own axis. They are not protected from recolouring: a status colour is
* information, and an admin who paints Danger green has said so. What the theme owes them is
* readable ink over the fill (03-palettes.css) and the contrast readout beside each field. */
/* ---- the surface axes: the sheet the UI is drawn on, rather than the marks on it ----
*
* The cards, the chrome, the inset controls and the hairlines. Their own factory rather than four
* more colorAxis instances, because:
*
* - there is no hue mode rotating the hue of a near-white card keeps its chroma (~0.003), so
* every angle produces the same white. The Tint axis colours a surface by SETTING a chroma;
* - there is no derived ink what reads on these is --fs-text, a palette token these axes must
* not move, so the Appearance page reports the contrast instead;
* - they therefore need no attribute: an inline custom property on :root is the whole mechanism,
* and every derived token follows because each is a color-mix() of the one this sets. That is
* why --fs-bar-bg is a surface of its own an admin who wants a dark chrome over light cards
* has to be able to say so.
*
* Off is lsSet('0'), not a deleted key: once a router default exists, clearing means "inherit
* it". */
function surfaceAxis(key, sdKey, prop) {
const norm = (v) => {
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
return FS_HEX_RE.test(s) ? s : 0;
};
const def = () => norm(sd(sdKey));
return {
def,
current() {
const raw = lsGet(key);
return (raw !== null) ? norm(raw) : def();
},
apply(val) {
const v = norm(val);
lsSet(key, String(v));
if (v) document.documentElement.style.setProperty(prop, v);
else document.documentElement.style.removeProperty(prop);
}
};
}
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
const currentCard = CARD.current, applyCard = CARD.apply;
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
const currentBar = BAR.current, applyBar = BAR.apply;
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
const currentLine = LINE.current, applyLine = LINE.apply;
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
const currentGood = GOOD.current, applyGood = GOOD.apply;
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
const currentWarn = WARN.current, applyWarn = WARN.apply;
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
* observes the attribute and folds the accordion into dropdowns or restores it.
*
* Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side from the router default and
* the pre-paint script overrides it, so it always carries an explicit value. localStorage would
* report 'sidebar' on a router defaulting to 'top' until the user first touched the toggle. */
function currentLayout() { function currentLayout() {
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar'; return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
} }
@@ -525,524 +302,23 @@ function currentRail() {
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so * snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding, * Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */ * and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
const AXIS_KEYS = [ /* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper',
'fs-tint', 'fs-accent', 'fs-good', 'fs-warn', 'fs-danger',
'fs-card', 'fs-control', 'fs-bar', 'fs-line',
'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength', 'fs-density',
'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength', 'fs-pattern-ink'
];
/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
* strength and 200% the cap. 0 is not quite "no tint" the relative colour that applies the tint
* replaces chroma outright, so 0 leaves a neutral canvas at the same lightness rather than the
* untinted one; clearing the Tint hue is the real off. It only bites while a Tint hue is set, and
* is moot under the File wallpaper, where the photo covers the canvas.
* *
* This axis and its default live above _resolvedDefault()'s module-init call below: a propAxis * Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
* instance is a `const`, so declaring it further down leaves it in the TDZ at init and the whole * and 188 B larger after minification three lists of short literals compress better than
* module throws, taking the chrome and the menu with it. */ * twenty-one rows of data, because a function name is mangled and a row is not. The copies are
const FS_TSTR_DEFAULT = 100; * held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100)); * field against header.ut's FS_AXES. */
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
/* Photo dim: the scrim opacity over the FILE photo (0100%). The photo is shared; how strongly
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
const FS_PDIM_DEFAULT = 74;
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
*
* Size is the tile's edge in px, with a wide range because "how big is one repeat" is a property of
* the artwork. Strength is the layer's opacity 0-100, which is the knob a `<g opacity>` baked into
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
const FS_PSIZE_DEFAULT = 440;
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
const FS_PSTR_DEFAULT = 20;
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
const PINK = enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
* the resolved value measured on the router, a per-config ACL refusal resolves with 6
* (permission denied) and every `.then()` below runs as if the file had been written, greying the
* Save button over a write that never happened. */
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
function snapshotAxes() {
return {
layout: currentLayout(),
darkmode: currentMode(),
palette: currentPalette(),
wallpaper: currentWallpaper(),
tint: String(currentTint()),
accent: String(currentAccent()),
good: String(currentGood()),
warn: String(currentWarn()),
danger: String(currentDanger()),
card: String(currentCard()),
control: String(currentControl()),
bar: String(currentBar()),
line: String(currentLine()),
rounding: String(currentRadius()),
autocollapse: currentAutoCollapse() ? 'on' : 'off',
tint_strength: String(currentTintStrength()),
density: currentDensity(),
photo_dim: String(currentPhotoDim()),
pattern_size: String(currentPatternSize()),
pattern_strength: String(currentPatternStrength()),
pattern_ink: currentPatternInk()
};
}
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
* form, so the Appearance tab can grey the Save button when this browser already shows exactly it.
* Seeded from window.__fsSD at load and replaced with the just-saved snapshot, so a save flips the
* match without a reload.
*
* Every field is the axis's own def(): a second copy of a validation drifts with no symptom beyond
* matchesSavedDefault() lying, which is the one thing the Save button is. `layout` is the exception,
* since currentLayout() reads the attribute its fallback must stay `top`, matching head.ut's
* stamp and resetToBuiltin(), or a fresh install shows dirty before anything is touched and
* resetToSaved() lands on the wrong layout. */
function _resolvedDefault() {
return {
layout: sd('layout') || 'top',
darkmode: modeDefault(),
palette: paletteDefault(),
wallpaper: wallpaperDefault(),
tint: String(TINT.def()),
accent: String(ACCENT.def()),
good: String(GOOD.def()),
warn: String(WARN.def()),
danger: String(DANGER.def()),
card: String(CARD.def()),
control: String(CONTROL.def()),
bar: String(BAR.def()),
line: String(LINE.def()),
rounding: String(radiusDefault()),
autocollapse: autoCollapseDefault() ? 'on' : 'off',
tint_strength: String(tintStrengthDefault()),
density: densityDefault(),
photo_dim: String(photoDimDefault()),
pattern_size: String(patternSizeDefault()),
pattern_strength: String(patternStrengthDefault()),
pattern_ink: PINK.def()
};
}
let _savedDefault = _resolvedDefault();
function matchesSavedDefault() {
const cur = snapshotAxes();
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
}
/* ---- no axis reaches /etc/config/footstrap except through Save-as-default ----
* Every axis is per-browser. An axis that wrote through on change on the argument that the photo
* it relates to is router-side re-pointed the router-wide default for every other device from one
* browser, and moved the Save baseline with it, so the button did not even light up. A per-browser
* preference must never mutate shared state invisibly.
*
* Only the photo's bytes and its cache-bust token are router-side. Whether a browser paints it is
* `fs-wallpaper` and how dim is `fs-photo-dim`: ordinary axes, saved with the rest or not at
* all. */
function saveAsDefault() {
const snap = snapshotAxes();
return _uciSet('footstrap', 'settings', snap)
.then(() => _uciCommit('footstrap'))
.then(() => { _savedDefault = snap; });
}
/* ---- the two resets, which are not the same escape hatch ----
*
* Both drop this browser's tweaks and differ in what is underneath:
*
* resetToSaved() clears the keys, so every axis falls back to the router default where one is
* set and to the built-in where it is not. The browser goes back to inheriting.
* resetToBuiltin() writes the theme's own defaults explicitly, the only way to say "as the theme
* ships" — clearing the keys is the sentence that means "inherit the router
* default".
*
* Both leave /etc/config/footstrap alone: neither un-saves a router default.
*
* The caller reloads so head.ut re-applies everything in one pass the appliers repaint correctly,
* but the controls on the page were built from the values they had at render time. */
function resetToSaved() {
AXIS_KEYS.forEach(lsDel);
}
/* The built-in defaults, written through the ordinary appliers so each validates its own value and
* stamps :root as usual. Stated rather than derived: a default is a default because it is what a
* bare :root paints, and the five with a named const use it, so the numbers cannot drift from the
* CSS. */
function resetToBuiltin() {
/* top, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci says
* nothing), so it is what "as the theme ships" means */
applyLayout('top');
applyMode('auto');
applyPalette('footstrap');
applyDensity('normal');
applyWallpaper('off');
applyAutoCollapse('off');
applyRadius(FS_RADIUS_DEFAULT);
applyTintStrength(FS_TSTR_DEFAULT);
applyPhotoDim(FS_PDIM_DEFAULT);
applyPatternSize(FS_PSIZE_DEFAULT);
applyPatternStrength(FS_PSTR_DEFAULT);
applyPatternInk('theme');
/* every colour and surface axis back to "the palette's own" */
[ applyTint, applyAccent, applyGood, applyWarn, applyDanger,
applyCard, applyControl, applyBar, applyLine ].forEach((fn) => fn(0));
}
/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ----
*
* The bytes come from the admin, never from a third-party host: a theme in a package feed does not
* reach out at run time.
*
* Router-side, like the login photo and for the same reason a file cannot live in localStorage,
* and a pattern is something a router wears. The path is a fixed server-side constant matched
* exactly by the rpcd ACL, so nothing user-controlled reaches a path. It lives under /etc so a
* package upgrade cannot delete it (keep.d carries it across a sysupgrade), and the served name
* ends in .svg because uhttpd types a file by extension.
*
* How it is made to fit is 15-wallpaper.css's mask, not anything done to the bytes: the file
* supplies the alpha and the theme the colour, so one upload reads correctly in both modes and
* under every palette.
*
* What is refused: an SVG is a document, not a picture, and while a masked or background image
* never executes script, the same file fetched from its own URL would. Uploading already needs an
* authenticated admin session with uci write rights, so this is defence in depth but the check is
* cheap and the failure mode is somebody else's browser. */
const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
const PAT_SERVE = '/luci-static/footstrap/pattern.svg'; /* the uhttpd symlink to PAT_PATH (uci-defaults) */
const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
/* What makes an uploaded SVG unacceptable, decided on the PARSED document and not on its text: a
* regex over the source guesses at a grammar the browser already implements, and guesses in both
* directions a handler pattern also matches an ordinary `only_selected="false"`, while an entity
* or odd whitespace hides a real handler from it.
*
* DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs,
* no subresource is fetched, no handler is bound. So the questions are exact ones about nodes:
*
* - is it an SVG at all (a parsererror, or a root that is not <svg>, is not an image)
* - does it carry an element that executes or embeds (script, foreignObject, iframe, )
* - does it carry a real event-handler attribute `^on[a-z]+$`
* - does any value start a `javascript:` url
* - does any href point off this router; `#fragment` and `data:` stay allowed, being how a tile
* refers to its own <defs> and embeds a bitmap
*
* The check is for the way the file can be reached that a mask does not cover: its own URL, opened
* directly, same-origin with the session.
*
* `animate`/`set` are listed for a second reason as well: they can retarget an attribute at run
* time (`<set attributename="href" to="javascript:…">`), and a tile that animates repaints a
* full-viewport layer behind every page. */
const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set' ];
/* null if the parsed document is fine, otherwise the sentence to show. */
function _svgObjection(text) {
let doc;
try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
catch (e) { return _('That file is not an SVG image.', 'footstrap'); }
const root = doc && doc.documentElement;
if (!root || doc.querySelector('parsererror') || root.nodeName.toLowerCase() !== 'svg')
return _('That file is not an SVG image.', 'footstrap');
const refused = _('That SVG contains script or external references, which this theme will not install.', 'footstrap');
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
for (const el of els) {
if (PAT_BAD_TAGS.indexOf(el.nodeName.toLowerCase()) >= 0) return refused;
const attrs = el.attributes || [];
for (let i = 0; i < attrs.length; i++) {
const n = attrs[i].name.toLowerCase();
const v = String(attrs[i].value || '').trim();
/* a REAL handler is `on` + letters and nothing else; `only_selected` is not one */
if ((/^on[a-z]+$/).test(n)) return refused;
if ((/^javascript:/i).test(v)) return refused;
/* off-router reference. A leading `//` is protocol-relative and just as external. */
if ((/(?:^|:)href$/).test(n) && (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v)) return refused;
}
}
return null;
}
/* read the picked file as text so it can be inspected before upload, and so what reaches the
* router is exactly the bytes that were checked */
function _readText(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
fr.readAsText(file);
});
}
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and the
* pre-paint use. '' = nothing uploaded. */
function currentPattern() {
const t = sd('pattern');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
* axis (data-wallpaper="pattern"). */
function _applyPattern(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
else root.style.removeProperty('--fs-pattern-url');
setSD('pattern', tok || '');
}
/* Upload flow, the login photo's exactly: validate -> multipart POST to cgi-upload -> take the md5
* `checksum` as the cache-bust token -> save it in uci -> apply live. No canvas step, which is what
* strips a photo's EXIF: an SVG redrawn to a canvas comes back a raster. The text check above
* stands in for it. */
function uploadPattern(file) {
if (!file) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
if (!isSvg) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
return _readText(file).then((text) => {
const objection = _svgObjection(text);
if (objection) return Promise.reject(new Error(objection));
const fd = new FormData();
fd.append('sessionid', rpc.getSessionID());
fd.append('filename', PAT_PATH);
fd.append('filedata', new Blob([ text ], { type: 'image/svg+xml' }), 'pattern.svg');
return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
}).then((reply) => {
if (!reply || reply.failure)
return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
const tok = String(reply.checksum || '').toLowerCase();
if (!BG_TOKEN_RE.test(tok))
return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
/* cgi-upload writes 0600 and uhttpd refuses to serve a file that is not world-readable
* (0600 -> 403, 0644 -> 200); _chmodServeable checks the command's exit status, not just
* the ubus call's */
return _chmodServeable(PAT_PATH)
/* uci gets the token and nothing else: putting a file on the router is not the same act
* as making every other device paint it */
.then(() => _uciSet('footstrap', 'settings', { pattern: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(PAT_PATH, e))
.then(() => {
/* switch this browser onto it: the ordinary axis path, localStorage only */
applyWallpaper('pattern');
_applyPattern(tok);
return tok;
});
});
}
/* Remove: delete the file, blank the token (uci `set` to '', not delete the scoped ACL grants
* set/commit only), clear the tile live. */
function removePattern() {
return _removeServed(PAT_PATH)
.then(() => _uciSet('footstrap', 'settings', { pattern: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyPattern(''); });
}
/* ---- login/page background upload: router-side, and deliberately not an axis ----
* The other axes are per-browser with a router default; this one has no browser layer. An admin
* uploads an image once, it becomes the router-wide background for every device and shows
* pre-login, so it is absent from AXIS_KEYS, snapshotAxes() and matchesSavedDefault() it must not
* move the Save button and needs no factory, so tools/axes.mjs never sees it.
*
* The image is a served file, uhttpd having no gzip to make inlining it in every <head> viable;
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The path is
* a fixed server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches
* a path. */
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink to BG_PATH (uci-defaults) */
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
const BG_QUALITY = 0.9;
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
/* No `reject: true` here, unlike every other declare in this file: with it, "the file was already
* gone" and "the router refused to delete it" arrive as the same Error. Without it the promise
* resolves with the ubus status as a number, which this code can branch on. */
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
/* Delete, treating "not found" as done. Anything else is a real refusal (a read-only or full
* overlay, an immutable flag, a path replaced by a directory) and must not be reported as a
* removal: the file stays on flash and stays fetchable WITHOUT a session through the /www symlink,
* which is what an admin removing a background believes they have stopped. */
const UBUS_NOT_FOUND = 4;
function _removeServed(path) {
return _fileRemoveStatus(path).then((res) => {
const code = (typeof res === 'number') ? res : parseInt(res, 10);
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
return Promise.reject(new Error(
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
});
}
/* cgi-upload writes the file 0600 and uhttpd refuses to serve a file that is not world-readable
* (0600 -> 403, 0644 -> 200), so make it 0644 first. The rpcd ACL grants exec on exactly two fixed
* commands chmod 644 on the two files this module uploads with no caller-controlled
* argument. */
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
/* and the ubus status is only half of it: `file.exec` reports the command's exit status inside the
* payload, so a chmod that ran and failed still comes back as a successful call and the upload
* then reports success for a file uhttpd will 403, leaving every device a scrim over nothing. */
function _chmodServeable(path) {
return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
if (res && res.code)
throw new Error(_('Upload failed.', 'footstrap') + ' (chmod ' + res.code + ')');
return res;
});
}
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
* module see the axes contract in head.ut. */
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
* pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
function currentLoginBg() {
const t = sd('login_bg');
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
}
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
/* _applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
function _applyLoginBg(tok) {
const root = document.documentElement;
if (tok) root.style.setProperty('--fs-login-bg-url', "url('" + loginBgUrl(tok) + "')");
else root.style.removeProperty('--fs-login-bg-url');
setSD('login_bg', tok || '');
}
/* Re-encode the picked image to a bounded JPEG on a canvas. A security step as much as a size one:
* the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image are
* dropped and the uploaded blob is exactly what the browser drew.
*
* The whole body is guarded, because a throw inside an event handler does not reject the promise it
* sits in it escapes as an uncaught error and leaves the promise pending forever. Two real ways
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed, and
* drawImage/toBlob can throw. A pending promise leaves the caller's "Uploading…" button disabled
* and lying until the form is rebuilt on a later arrival at the page. */
function _downscale(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
try {
const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.drawImage(img, 0, 0, w, h);
cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(_('Could not process the image.', 'footstrap'))),
'image/jpeg', BG_QUALITY);
} catch (e) { reject(new Error(_('Could not process the image.', 'footstrap'))); }
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
img.src = url;
});
}
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
* below write the file first and the token second, and the second half can fail on its own (no
* `settings` section, a narrowed uci ACL, ubus busy) the image then sits at mode 0644 and is
* served to anyone through the /www symlink, which does not depend on the token, while Remove is
* hidden precisely because the token is empty. Roll the file back and report the failure that
* started it; a rollback that itself fails is appended, because the admin has to know the file is
* there. */
function _rollbackUpload(path, cause) {
return _removeServed(path).then(
() => Promise.reject(cause),
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
);
}
/* Upload flow: validate -> canvas re-encode -> multipart POST to cgi-upload (the endpoint
* L.ui.uploadFile uses; session in the `sessionid` field, path in `filename`, bytes in `filedata`)
* -> take the md5 `checksum` as the cache-bust token -> save it in uci -> apply live. cgi-upload
* authorises the write against the ACL's `file` grant for BG_PATH. */
function uploadLoginBg(file) {
if (!file || !(/^image\//).test(file.type || ''))
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
if (file.size > BG_SRC_MAX)
return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
return _downscale(file).then((blob) => {
const fd = new FormData();
fd.append('sessionid', rpc.getSessionID());
fd.append('filename', BG_PATH);
fd.append('filedata', blob, 'login-bg');
return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
.then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)));
}).then((reply) => {
/* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
if (!reply || reply.failure)
return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
const tok = String(reply.checksum || '').toLowerCase();
if (!BG_TOKEN_RE.test(tok))
return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
/* make the just-written 0600 file world-readable, or uhttpd 403s it (see _fileExec) */
return _chmodServeable(BG_PATH)
/* uci gets the token and nothing else: which browsers paint it is the wallpaper axis,
* and writing `wallpaper:file` here would re-point every other device's default from
* one upload */
.then(() => _uciSet('footstrap', 'settings', { login_bg: tok }))
.then(() => _uciCommit('footstrap'))
.catch((e) => _rollbackUpload(BG_PATH, e))
.then(() => {
/* switch this browser to the photo: the ordinary axis path, localStorage only */
applyWallpaper('file');
_applyLoginBg(tok);
return tok;
});
});
}
/* removePattern's twin for the photo. */
function removeLoginBg() {
return _removeServed(BG_PATH)
.then(() => _uciSet('footstrap', 'settings', { login_bg: '' }))
.then(() => _uciCommit('footstrap'))
.then(() => { _applyLoginBg(''); });
}
return baseclass.extend({ return baseclass.extend({
lsGet, lsSet, lsDel, lsGetArr, storageBroken, /* the storage wrappers and the router-default reader: fs-axes.js is built on these */
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
/* the two axis shapes, so the nineteen axes in fs-axes.js can be built from them */
listAxis, enumAxis,
currentMode, applyMode, guardDarkStamp, currentMode, applyMode, modeDefault, guardDarkStamp,
currentPalette, applyPalette, currentDensity, applyDensity, densityDefault,
currentWallpaper, applyWallpaper, currentLayout, applyLayout, isTopLayout,
currentDensity, applyDensity, currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
currentRadius, applyRadius, currentRail, applyRail
currentTint, applyTint,
currentAccent, applyAccent,
currentGood, applyGood,
currentCard, applyCard,
currentControl, applyControl,
currentBar, applyBar,
currentLine, applyLine,
currentWarn, applyWarn,
currentDanger, applyDanger,
currentLayout, isTopLayout, applyLayout,
currentAutoCollapse, applyAutoCollapse,
currentRail, applyRail,
currentLoginBg, loginBgUrl, uploadLoginBg, removeLoginBg,
currentPattern, patternUrl, uploadPattern, removePattern,
currentPatternSize, applyPatternSize,
currentPatternStrength, applyPatternStrength,
currentPatternInk, applyPatternInk,
currentTintStrength, applyTintStrength,
currentPhotoDim, applyPhotoDim,
saveAsDefault, resetToSaved, resetToBuiltin, matchesSavedDefault
}); });
@@ -1155,36 +1155,52 @@ function bootDocumentIsOurs() {
* *
* The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own * The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own
* guards at their use, being optional there. */ * guards at their use, being optional there. */
const CONTRACT = [ const CONTRACT_FNS = [
[ 'L.require', () => typeof window.L.require === 'function' ], 'L.require',
/* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */ /* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */
[ 'L.Class', () => typeof window.L.Class === 'function' ], 'L.Class',
[ 'L.dom.content', () => window.L.dom && typeof window.L.dom.content === 'function' ], 'L.dom.content',
/* a slash in the leaf is several functions on one object: the pair is only ever there or gone
* together, so one name in the report is the whole finding */
'L.Poll.start/stop',
'L.Request.addInterceptor',
'rpc.addInterceptor',
'ui.instantiateView',
'ui.hideModal',
'ui.hideIndicator',
'ui.addNotification'
];
/* the roots are resolved per probe, not once: `window.L` is what the two-L trap makes load-bearing
* (docs/spa-router.md), and `ui`/`rpc` are this module's own requires */
function hasFns(path) {
const seg = path.split('.');
const leaf = seg.pop();
let node = { L: window.L, ui: ui, rpc: rpc }[seg.shift()];
for (const k of seg) node = node[k];
return leaf.split('/').every((n) => typeof node[n] === 'function');
}
/* the two surfaces that are not functions */
const CONTRACT_REST = [
/* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */ /* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => { [ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env; const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ] return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env); .every((k) => k in env);
} ], } ],
[ 'L.Poll.queue', () => window.L.Poll && Array.isArray(window.L.Poll.queue) ], [ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
[ 'L.Poll.start/stop', () => window.L.Poll &&
typeof window.L.Poll.start === 'function' && typeof window.L.Poll.stop === 'function' ],
[ 'L.Request.addInterceptor', () => window.L.Request &&
typeof window.L.Request.addInterceptor === 'function' ],
[ 'rpc.addInterceptor', () => typeof rpc.addInterceptor === 'function' ],
[ 'ui.instantiateView', () => typeof ui.instantiateView === 'function' ],
[ 'ui.hideModal', () => typeof ui.hideModal === 'function' ],
[ 'ui.hideIndicator', () => typeof ui.hideIndicator === 'function' ],
[ 'ui.addNotification', () => typeof ui.addNotification === 'function' ]
]; ];
/* -> the names that are not there, in list order; empty means the document can be navigated. A /* -> the names that are not there, in list order; empty means the document can be navigated. A
* probe that throws counts as missing: `L` itself may be a shape nobody here expected. */ * probe that throws counts as missing: `L` itself may be a shape nobody here expected. */
function contractBreaks() { function contractBreaks() {
return CONTRACT.filter(([ , present ]) => { const gone = (probe) => {
try { return !present(); } try { return !probe(); }
catch (e) { return true; } catch (e) { return true; }
}).map(([ name ]) => name); };
return CONTRACT_FNS.filter((path) => gone(() => hasFns(path)))
.concat(CONTRACT_REST.filter(([ , probe ]) => gone(probe)).map(([ name ]) => name));
} }
function wireRouter() { function wireRouter() {
@@ -143,69 +143,28 @@ function search(q, limit) {
const RECENT_KEY = 'fs-recent'; const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8; const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the /* The list is WRITTEN by menu-footstrap-common.js, which is on every page this module is not any
* "these are paths" filter belongs here */ * more, and a palette that only loads when it is opened cannot be what records where the admin has
function loadRecent() { * been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string'); * corruption guard and the Array check; only the "these are paths" filter belongs here. */
}
let _recent = loadRecent();
function remember(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const path = segs.join('/');
_recent = [ path ].concat(_recent.filter((p) => p !== path)).slice(0, RECENT_MAX);
prefs.lsSet(RECENT_KEY, JSON.stringify(_recent));
}
function recentEntries() { function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
const byPath = new Map(index().map((e) => [ e.path, e ])); const byPath = new Map(index().map((e) => [ e.path, e ]));
return _recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX); return recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to
* a page still pays for its module chain. The recents list is the best predictor available and is
* already on disk; warming the whole menu instead would pull every view module on the box
* (docs/spa-router.md).
*
* The current page is skipped wire() has just remembered it and it is loaded by definition.
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
* deliberate hover or tap. */
const RECENT_WARM = 5;
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
const paths = _recent.filter((p) => p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
/* Nothing waits on this, so it runs at idle, with a timeout for a page that never goes idle (a
* busy poll). The fallback delay is long on purpose: this competes with the view's own module
* fetches and RPCs and must lose that race. */
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
else
window.setTimeout(go, 2000);
} }
/* ---- the palette -------------------------------------------------------- */ /* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20; const MAX_RESULTS = 20;
function wire() { /* Built on the first open and kept the overlay, its listeners and the index survive for the life
const btn = document.getElementById('fs-search-btn'); * of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
if (!btn) return; * menu-footstrap-common.js holds the shortcut and requires this on the first gesture. */
let _built = null;
/* remember the page this full load landed on: onNavigate below covers the SPA path, this function build() {
* covers an F5, a non-SPA-able node and the first page of a session */ const btn = document.getElementById('fs-search-btn');
remember(L.env.dispatchpath || []); if (!btn) return null;
/* the callback is handed the resolved segments of the INCOMING page; L.env still points at
* the outgoing one when the router fires its callbacks */
router.onNavigate(remember);
/* after remember(), so the page we stand on heads the list and is the one skipped */
warmRecent();
const input = E('input', { const input = E('input', {
'type': 'text', 'type': 'text',
@@ -384,8 +343,16 @@ function wire() {
ev.preventDefault(); ev.preventDefault();
open(); open();
}); });
return { open, close };
}
/* the one entry point: build if this is the first gesture, then open */
function openPalette() {
if (!_built) _built = build();
if (_built) _built.open();
} }
return baseclass.extend({ return baseclass.extend({
wire open: openPalette
}); });
@@ -66,200 +66,9 @@ function wireDismiss(opts) {
* base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here. * base/30-forms.css, `.cbi-range-slider` in theme/60-inputs.css), and cannot be got wrong here.
* Do not re-add a segmented control or range wrapper of our own. */ * Do not re-add a segmented control or range wrapper of our own. */
/* ---- colour: reading what the page is actually painted ----
*
* Two questions no stored value answers: what colour a role is right now (the palette's own while
* the axis is off there is deliberately no copy of the palette in JS), and what contrast the
* user's colour lands at. Both are about the computed cascade, so both are asked of the browser.
*
* `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property
* computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back
* unevaluated. Setting the expression as a real `color` and reading it back makes the browser
* resolve it relative colour, color-mix() and the tint's calc() are what the theme is made of.
* One hidden probe is reused; an element per query would thrash layout on every slider drag. */
let _probe = null;
function probeColor(expr) {
if (!_probe) {
/* Off-screen rather than display:none, so the reading does not depend on a display:none
* element computing `color` in every engine. It has no text and no size, so it paints
* nothing.
*
* Every declaration is !important (issue #19): this is an unmarked element in a document
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
document.body.appendChild(_probe);
}
/* cleared first: an expression the engine rejects leaves the previous colour standing, which
* would report a stale answer as a fresh one */
_probe.style.setProperty('color', '');
_probe.style.setProperty('color', expr, 'important');
return getComputedStyle(_probe).color;
}
/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
* wrong units and produce a colour nobody chose measured: #010078, graded "Too faint to read",
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
* forms can appear. */
let _cx = null;
function rasterCtx() {
if (_cx !== null) return _cx;
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 1;
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
} catch (e) { _cx = false; }
return _cx;
}
function parseColor(s) {
const str = String(s || '');
const cx = rasterCtx();
if (cx) {
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
* report the previous one as a fresh reading the trap probeColor() clears for */
cx.fillStyle = '#000';
cx.fillStyle = str;
cx.clearRect(0, 0, 1, 1);
cx.fillRect(0, 0, 1, 1);
const d = cx.getImageData(0, 0, 1, 1).data;
if (d[3] === 255) return [ d[0], d[1], d[2] ];
/* translucent: composite over nothing is meaningless for a readout, so fall through */
}
const nums = str.match(/[\d.]+/g);
if (!nums || nums.length < 3) return null;
const unit = (/^color\(/i).test(str) ? 255 : 1;
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
}
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
* what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css
* derives the ink over a fill, which is a different question). */
function luminance(rgb) {
const c = rgb.map((v) => {
const x = v / 255;
return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4);
});
return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]);
}
function contrastRatio(fgExpr, bgExpr) {
const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr));
if (!fg || !bg) return null;
const a = luminance(fg), b = luminance(bg);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
/* #rrggbb, because <input type="color"> accepts nothing else. An unparseable colour becomes black
* rather than throwing: the text field beside the swatch is the authoritative one. */
function toHex(s) {
const rgb = parseColor(s) || [ 0, 0, 0 ];
return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour.
* Reports through onPick as a hex string, or 0 for "back to the palette", either of which the
* caller hands straight to fs-prefs.js's colorAxis.
*
* There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma,
* so no angle of it reaches a grey. The axis still accepts a stored hue (1360) and the stylesheet
* still rotates the palette by one, so a saved value goes on working.
*
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
* the pair whose ratio is reported under the row. */
function colorControl(current, onPick, label, opts) {
const o = opts || {};
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
* fallback where the browser draws no picker. */
const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' });
const field = E('input', {
'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off',
'inputmode': 'text', 'maxlength': '7', 'aria-label': label || ''
});
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
* to default), so a private copy would go stale. `current` is only the build-time value. */
const currentOf = o.read || (() => current);
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
* refresh() after a preset, palette switch or dark-mode flip each changes what the palette's
* own colour is while this axis stays off. */
function reflect(v) {
const live = probeColor(o.probe);
const hex = (typeof v === 'string') ? v : toHex(live);
swatch.value = hex;
/* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on
* every keystroke made the input impossible to type into */
if (document.activeElement !== field) field.value = hex;
/* the button back to the palette doubles as the axis state readout: enabled means the axis
* holds a colour of its own, disabled means the field shows the palette's */
clear.disabled = !v;
if (!ratio) return;
const r = contrastRatio(o.contrast.fg, o.contrast.bg);
if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; }
/* The readout states what the ratio means; the number itself stays in the title.
* Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a
* hairline is graded on the second (`kind: 'shape'`) and warns rather than fails a faint
* border is a legitimate choice.
*
* Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching
* fs-* tokens in the source, and a concatenated name is invisible to it. */
const where = o.contrast.label;
const grade = (o.contrast.kind === 'shape')
? ((r >= 3)
? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) }
: { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) })
: (r >= 4.5)
? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) }
: (r >= 3)
? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) }
: { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) };
ratio.className = 'fs-color-contrast ' + grade.cls;
ratio.textContent = grade.text;
ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap')
.format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5');
}
const pick = (v) => { onPick(v); reflect(v); };
swatch.addEventListener('input', () => pick(swatch.value.toLowerCase()));
/* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page
* under the cursor. An unparseable value snaps back to what the axis holds, so the field
* cannot claim a colour the page is not painted in. */
const commit = () => {
const v = field.value.trim().toLowerCase();
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
else reflect(currentOf());
};
field.addEventListener('blur', commit);
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
clear.addEventListener('click', () => pick(0));
const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [
E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ])
].concat(ratio ? [ ratio ] : []));
/* the caller decides when this runs: probeColor() needs the document, and this control is not
* in it yet */
wrap.fsRefresh = () => reflect(currentOf());
return wrap;
}
return baseclass.extend({ return baseclass.extend({
svgIcon, svgIcon,
setOpen, setOpen,
wireSpaceKey, wireSpaceKey,
wireDismiss, wireDismiss
colorControl,
probeColor,
toHex
}); });
@@ -7,7 +7,6 @@
'require fs-router as router'; 'require fs-router as router';
'require fs-prefs as prefs'; 'require fs-prefs as prefs';
'require fs-sheets as sheets'; 'require fs-sheets as sheets';
'require fs-search as search';
/* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each /* Page modules: `fs-appearance` (System -> System) and `fs-overview` (Status -> Overview) each
* serve one page and are required only on it. A `require` pragma would make them a hard * serve one page and are required only on it. A `require` pragma would make them a hard
@@ -42,6 +41,85 @@ function wirePageModules() {
load(); load();
} }
/* ---- the search palette, held at arm's length ----
*
* The palette is 5 KB and opens on a keystroke most sessions never press, so it is not required
* here: this holds the shortcut and fetches the module on the first gesture. What CANNOT wait is
* the recents list it has to be written on every navigation, or it is empty on the first open
* and the warm pass that uses it, so both live here, in the file every page already loads.
*
* The palette reads the list back from localStorage when it opens, so the two halves share the key
* and nothing else. */
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
const RECENT_WARM = 5;
function remember(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const path = segs.join('/');
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
prefs.lsSet(RECENT_KEY, JSON.stringify([ path ].concat(recent.filter((p) => p !== path)).slice(0, RECENT_MAX)));
}
/* ---- warm the pages this admin actually uses ----
*
* The router's per-link prefetch needs a hover, tap or focus first, so a session's first visit to a
* page still pays for its module chain. The recents list is the best predictor available and is
* already on disk; warming the whole menu instead would pull every view module on the box
* (docs/spa-router.md).
*
* The current page is skipped remember() has just recorded it and it is loaded by definition.
* Under saveData nothing speculative runs; the per-link prefetch stays, since it follows a
* deliberate hover or tap. Nothing waits on this, so it runs at idle, with a long fallback delay:
* it competes with the view's own module fetches and RPCs and must lose that race. */
function warmRecent() {
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
const here = (L.env.dispatchpath || []).join('/');
const paths = prefs.lsGetArr(RECENT_KEY)
.filter((p) => typeof p === 'string' && p !== here).slice(0, RECENT_WARM);
if (!paths.length) return;
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
if (typeof window.requestIdleCallback === 'function')
window.requestIdleCallback(go, { timeout: 4000 });
else
window.setTimeout(go, 2000);
}
function wireSearch() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
const RT = window.L;
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
remember(L.env.dispatchpath || []);
router.onNavigate(remember);
warmRecent();
/* One fetch, on the first gesture. The module builds its overlay and opens itself; every later
* gesture reaches the same instance, `require` being a singleton. */
let pending = false;
const open = () => {
if (pending) return;
pending = true;
RT.require('fs-search').then((m) => { pending = false; m.open(); },
(e) => { pending = false; console.error('footstrap: fs-search did not load', e); });
};
btn.addEventListener('click', open);
/* the same two shortcuts the palette used to own, with the same guard: `/` must not steal a
* keystroke from someone typing into a field, a contenteditable, or a .cbi-dropdown, where
* fs-select.js's typeahead reads it as a search character */
document.addEventListener('keydown', (ev) => {
if (ev.defaultPrevented) return;
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && (ev.key === 'k' || ev.key === 'K')) {
ev.preventDefault(); open(); return;
}
if (ev.key !== '/' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.target.closest?.('input, textarea, select, [contenteditable], .cbi-dropdown')) return;
ev.preventDefault(); open();
});
}
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed. /* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
* *
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script * `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -140,9 +218,7 @@ return baseclass.extend({
fit.add(chrome.fitChrome); fit.add(chrome.fitChrome);
chrome.renderChrome(); chrome.renderChrome();
/* after setTree(): the palette indexes that tree on first open, and records recent wireSearch();
* pages from the first navigation onwards */
search.wire();
chrome.wireRail(); chrome.wireRail();
chrome.wireIndicatorCounts(); chrome.wireIndicatorCounts();
/* before router.wire(): the router restamps body[data-page] on every SPA navigation, /* before router.wire(): the router restamps body[data-page] on every SPA navigation,
+63 -7
View File
@@ -33,19 +33,44 @@ set -e
CSS="${1:-}" CSS="${1:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; } [ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; }
shift shift
# --rewrite <dir>… : the seam names are mangled TOO, and the same map is applied to the JS and
# templates in those directories. Without it they are reserved, which is the safe default and what
# an SDK build (no second pass to rewrite) needs.
#
# The seam is safe to rename only because every `--fs-` reference on the far side is a WHOLE string
# literal — `setProperty('--fs-accent', …)`, never `'--fs-' + role`. Checked across all 89 sites;
# if one is ever composed, this flag renames the CSS and the JS keeps asking for a name that no
# longer exists, silently. The 36 seam names cost 8,574 B in the sheet, `--fs-accent` alone 1,452.
REWRITE=""
RESERVE_DIRS=""
REWRITE_DIRS=""
seen=""
for a in "$@"; do
if [ "$a" = "--rewrite" ]; then seen=1; REWRITE=1; continue; fi
if [ -n "$seen" ]; then REWRITE_DIRS="$REWRITE_DIRS $a"; else RESERVE_DIRS="$RESERVE_DIRS $a"; fi
done
# shellcheck disable=SC2086 -- the dirs are ours, and a path with a space would already have broken
# every other loop in this package's build
set -- $RESERVE_DIRS
[ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; } [ $# -gt 0 ] || { echo "mangle-tokens: no reserved-source dir given" >&2; exit 1; }
RES="$CSS.reserved.$$" RES="$CSS.reserved.$$"
MAP="$CSS.map.$$" MAP="$CSS.map.$$"
trap 'rm -f "$RES" "$MAP" "$CSS.tmp.$$"' EXIT trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$CSS.tmp.$$"' EXIT
# every --fs- name mentioned anywhere in the JS or the templates keeps its name if [ -n "$REWRITE" ]; then
for d in "$@"; do # nothing is reserved: every name is renamed here and in the far side together
[ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; } : > "$RES"
find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} + else
done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES" # every --fs- name mentioned anywhere in the JS or the templates keeps its name
for d in "$@"; do
[ -d "$d" ] || { echo "mangle-tokens: $d is not a directory" >&2; exit 1; }
find "$d" -type f \( -name '*.js' -o -name '*.ut' \) -exec cat {} +
done | grep -oE -- '--fs-[a-z0-9-]+' | sort -u > "$RES"
[ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; } [ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; }
fi
awk -v RESFILE="$RES" -v MAPFILE="$MAP" ' awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) } function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
@@ -122,3 +147,34 @@ before=$(wc -c < "$CSS")
mv "$CSS.tmp.$$" "$CSS" mv "$CSS.tmp.$$" "$CSS"
after=$(wc -c < "$CSS") after=$(wc -c < "$CSS")
echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved" echo "mangle-tokens: $before -> $after bytes (-$((before - after))), $(wc -l < "$RES") name(s) reserved"
# ---- the far side of the seam, renamed with the same map ----
if [ -n "$REWRITE" ]; then
[ -s "$MAP" ] || { echo "mangle-tokens: --rewrite asked for, but the map is empty" >&2; exit 1; }
# NEVER the checkout. This rewrites files in place, so a target under the directory this script
# itself lives in is the source tree, and renaming the seam there destroys it — measured the
# hard way: a mistake in the argument split sent $SRC here instead of $STAGE and rewrote eight
# shipped modules and a template before anything noticed.
SELF_DIR=$(cd "$(dirname "$0")" && pwd -P)
for d in $REWRITE_DIRS; do
abs=$(cd "$d" 2>/dev/null && pwd -P) || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
case "$abs/" in
"$SELF_DIR"/*) echo "mangle-tokens: --rewrite target $d is inside the source tree ($SELF_DIR) — refusing, this rewrites in place" >&2; exit 1 ;;
esac
done
# longest first, or `--fs-accent` would rewrite the head of `--fs-accent-h`
awk '{ print $1, $3 }' "$MAP" | awk '{ print length($1), $0 }' | sort -rn | cut -d" " -f2- > "$MAP.ord"
touched=0
for d in $REWRITE_DIRS; do
[ -d "$d" ] || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
for f in $(find "$d" -type f \( -name '*.js' -o -name '*.ut' \)); do
awk -v MAPF="$MAP.ord" '
BEGIN { while ((getline l < MAPF) > 0) { split(l, a, " "); from[++k] = a[1]; to[k] = a[2] } }
{ for (x = 1; x <= k; x++) gsub(from[x], to[x]); print }
' "$f" > "$f.tmp$$" && mv "$f.tmp$$" "$f"
touched=$((touched + 1))
done
done
rm -f "$MAP.ord"
echo "mangle-tokens: seam renamed in $touched file(s)"
fi
@@ -4,3 +4,4 @@ config footstrap 'settings'
option darkmode 'dark' option darkmode 'dark'
option wallpaper 'pattern' option wallpaper 'pattern'
option layout 'sidebar' option layout 'sidebar'
option autocollapse 'on'
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
# Strip the two static assets nothing else strips: the SVG favicon's comment and the manifest's
# indentation. Over a BUILD TREE, never the checkout — the comment is a "why" git keeps, and
# `logo.svg` is also the source `tools/build-icons.mjs` rasterises the PNGs from.
#
# Small, but honest bytes: the favicon is fetched by every browser on every cold visit, and uhttpd
# serves it uncompressed like everything else. 1,360 -> ~590 B for the SVG, 366 -> ~310 B for the
# manifest.
#
# Why not `strip-templates.sh`: that one is line-oriented and only removes comments from column one,
# which is right for a template and wrong for a single-line XML document.
#
# Usage: strip-assets.sh <dir>
set -eu
DIR="${1:-}"
[ -n "$DIR" ] && [ -d "$DIR" ] || { echo "usage: strip-assets.sh <dir>" >&2; exit 2; }
found=0
# ---- SVG: drop XML comments, then the whitespace BETWEEN tags only ----
# Never inside a tag: `viewBox="-9 -1 100 100"` and `d="M2 3 L4 5"` are attribute values whose
# spaces are data. Only `> <` is collapsed to `><`.
for f in $(find "$DIR" -type f -name '*.svg' | sort); do
tmp="$f.tmp$$"
awk '
BEGIN { RS = "\0" }
{
# comments first: they may span lines and may contain angle brackets
while (match($0, /<!--([^-]|-[^-]|--[^>])*-->/)) {
$0 = substr($0, 1, RSTART - 1) substr($0, RSTART + RLENGTH)
}
gsub(/>[ \t\r\n]+</, "><")
gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "")
printf "%s", $0
}
' "$f" > "$tmp"
# a truncated write must never ship: the same floor build-css.sh keeps
if [ ! -s "$tmp" ] || [ "$(wc -c < "$tmp")" -lt 100 ]; then
rm -f "$tmp"
echo "strip-assets: $f came out implausibly small — refusing" >&2
exit 1
fi
mv "$tmp" "$f"
found=$((found + 1))
done
# ---- JSON: one line, no indentation ----
# Structure only. A value keeps every byte, so a name or a URL with a space survives.
for f in $(find "$DIR" -type f -name '*.json' ! -path '*/rpcd/acl.d/*' | sort); do
tmp="$f.tmp$$"
# acl.d is excluded on purpose: rpcd skips a malformed ACL SILENTLY, so the grant would go to
# nobody and only Save-as-default and the upload would break, on someone else's router. Those
# files are also never fetched over the wire. Not worth the risk for ~200 B.
awk '
BEGIN { RS = "\0"; q = 0 }
{
out = ""
n = length($0)
for (i = 1; i <= n; i++) {
c = substr($0, i, 1)
if (q) {
out = out c
if (c == "\\") { out = out substr($0, i + 1, 1); i++; continue }
if (c == "\"") q = 0
continue
}
if (c == "\"") { q = 1; out = out c; continue }
if (c == " " || c == "\t" || c == "\n" || c == "\r") continue
out = out c
}
printf "%s", out
}
' "$f" > "$tmp"
if [ ! -s "$tmp" ]; then
rm -f "$tmp"
echo "strip-assets: $f came out empty — refusing" >&2
exit 1
fi
mv "$tmp" "$f"
found=$((found + 1))
done
[ "$found" -gt 0 ] || { echo "strip-assets: no .svg or .json in $DIR" >&2; exit 1; }
echo "strip-assets: $found file(s)"
+6 -1
View File
@@ -210,7 +210,12 @@
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); } :root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
:root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); } :root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ---- */ /* ---- footstrap GitHub Primer (DEFAULT, also fills bare :root) ----
* `[data-palette="footstrap"]` never matches both appliers REMOVE the attribute for the
* default rather than stamp it (fs-prefs.js listAxis, partials/head.ut:216) and it is kept
* anyway: dropping it leaves a bare `:root` that duplicates the derive rule above, and merging
* those two would put one palette's raw values in the block that derives every palette's. 86 B
* to keep the two roles apart. */
:root, :root,
:root[data-palette="footstrap"] { :root[data-palette="footstrap"] {
/* LIGHT */ /* LIGHT */
@@ -22,10 +22,6 @@
border-spacing: 0; border-spacing: 0;
} }
ol, ul {
list-style: none;
}
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that /* `hidden` is how a VIEW says an element is not there, and the only way available to code that
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any * ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
* `display` a theme sets on a class beats it and the element paints anyway: measured here on * `display` a theme sets on a class beats it and the element paints anyway: measured here on
@@ -203,14 +203,11 @@
const root = document.querySelector(':root'), const root = document.querySelector(':root'),
sd = window.__fsSD || {}; sd = window.__fsSD || {};
let p = lsGet('fs-palette'); let p = lsGet('fs-palette');
/* Legacy palette names migrated to the current explicit value, never cleared: /* A stray or retired palette name needs no migration here: it fails the list
an absent key means "inherit the router default", which is not "the built-in below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
default". The migration is already redundant — fs-prefs.js's currentPalette() way, so the live applier agrees. The three legacy names ('rvht', 'roman',
ends `return 'footstrap'`, so a stray value reads as the built-in default there 'github') were rewritten here until this comment replaced them — the block
too — and deleting this block changes nothing unless that line goes with it. */ was measured as changing nothing, and it ran on every page load. */
if (p === 'rvht' || p === 'roman' || p === 'github') {
localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
}
/* the default palette is a bare :root; every other colourway is opt-in. The list /* the default palette is a bare :root; every other colourway is opt-in. The list
is the same one fs-prefs.js validates against — a name in one and not the is the same one fs-prefs.js validates against — a name in one and not the
other paints here and is taken away by the first live change. */ other paints here and is taken away by the first live change. */
@@ -307,7 +304,7 @@
failure would be silent. */ failure would be silent. */
const lb = sd.login_bg; const lb = sd.login_bg;
if (lb && (/^[a-f0-9]{6,64}$/).test(lb)) if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
root.style.setProperty('--fs-login-bg-url', "url('/luci-static/footstrap/bg?v=" + lb + "')"); root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + lb + '")');
/* the Tint's strength (default 100% = the designed chroma) */ /* the Tint's strength (default 100% = the designed chroma) */
const tsRaw = lsGet('fs-tint-strength'); const tsRaw = lsGet('fs-tint-strength');
let ts = parseInt(tsRaw, 10); let ts = parseInt(tsRaw, 10);
+52
View File
@@ -0,0 +1,52 @@
#
# Copyright (C) 2026 jjm2473@gmail.com
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
MISE_ARCH_x86_64:=x64
MISE_ARCH_aarch64:=arm64
MISE_ARCH:=$(MISE_ARCH_$(ARCH))
MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c25806222647e
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.8.14
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=utils
CATEGORY:=Utilities
TITLE:=mise - polyglot tool version manager
DEPENDS:=@(x86_64||aarch64)
URL:=https://mise.jdx.dev
endef
define Package/$(PKG_NAME)/description
mise manages development tool versions, environment variables, and tasks.
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/bin/mise $(1)/usr/bin/mise
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+2 -2
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=upx PKG_NAME:=upx
PKG_VERSION:=5.2.0 PKG_VERSION:=5.2.1
PKG_RELEASE:=2 PKG_RELEASE:=3
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-src.tar.xz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-src.tar.xz
PKG_SOURCE_URL:=https://github.com/upx/upx/releases/download/v$(PKG_VERSION) PKG_SOURCE_URL:=https://github.com/upx/upx/releases/download/v$(PKG_VERSION)
+60 -44
View File
@@ -13,15 +13,28 @@
# wwand-esim - eSIM management (DEPENDS wwand-qmi + wwand-lpac) # wwand-esim - eSIM management (DEPENDS wwand-qmi + wwand-lpac)
# A typical QMI router installs `wwand-qmi` (which pulls in `wwand`); add # A typical QMI router installs `wwand-qmi` (which pulls in `wwand`); add
# `wwand-mbim` / `wwand-ncm` for those modems, `wwand-mhi` for a PCIe/MHI modem. # `wwand-mbim` / `wwand-ncm` for those modems, `wwand-mhi` for a PCIe/MHI modem.
# The ucode tree ships as SOURCE by default. It can be precompiled to bytecode # The ucode tree ships PRECOMPILED to bytecode (repo-root CMakeLists.txt,
# (repo-root CMakeLists.txt, alongside wwand_io.so) via CONFIG_WWAND_UCODE_ # alongside wwand_io.so). `CONFIG_WWAND_UCODE_SOURCE` opts out and ships the
# PRECOMPILE, but that is opt-in and only sound when ucode and wwand are built # readable sources instead — the same polarity the wwand repo's CMakeLists.txt
# in the SAME tree: bytecode carries a format version (UCODE_BYTECODE_VERSION, # and files/wwand.init already assume.
# ucode's include/ucode/vm.h) that an interpreter upgraded past it refuses to #
# load, and no package relation can express that coupling — it is independent # The polarity matters beyond taste. It used to be an opt-in
# of libucode's PKG_ABI_VERSION/SONAME, so an ABI-versioned dependency does not # (WWAND_UCODE_PRECOMPILE, default n), which meant the desired outcome depended
# capture it either. In a feed, where ucode is upgraded on its own, that is a # on a symbol SURVIVING defconfig — and in a per-package SDK build it does not:
# coupling we must not create. # defconfig dropped it together with the CONFIG_PACKAGE_wwand it depends on, and
# the build then shipped source while reporting success. As an opt-out, a
# dropped symbol lands on the default we want, so the failure mode is gone.
#
# KNOWN COUPLING, accepted deliberately: bytecode carries a format version
# (UCODE_BYTECODE_VERSION, ucode's include/ucode/vm.h) that an interpreter
# upgraded past it refuses to load, and no package relation can express that —
# it is independent of libucode's PKG_ABI_VERSION/SONAME, so an ABI-versioned
# dependency does not capture it either. Upgrading ucode alone can therefore
# leave the daemon unable to start. That is not silent: files/wwand.init
# recognises "Bytecode version mismatch" / "Invalid file magic" and refuses with
# an explanatory line instead of respawning forever. The remedy is to reinstall
# wwand built against the running ucode, or to build with
# CONFIG_WWAND_UCODE_SOURCE.
# #
# NOTE: the split needs the ucode source that lazy-loads every backend # NOTE: the split needs the ucode source that lazy-loads every backend
# (qmi_lazy.uc + the backend-neutral daemon). Bump PKG_SOURCE_VERSION (and # (qmi_lazy.uc + the backend-neutral daemon). Bump PKG_SOURCE_VERSION (and
@@ -30,26 +43,24 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_RELEASE:=10 PKG_RELEASE:=11
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=648283c23470d602459ce2c54a60adf9109370da PKG_SOURCE_VERSION:=fc013f140d423867b993c6026fd435ec750cbb91
PKG_SOURCE_DATE:=2026-08-26 PKG_SOURCE_DATE:=2026-08-27
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:= PKG_MAINTAINER:=
# host ucode is the bytecode compiler, needed ONLY on the opt-in precompile # host ucode is the bytecode compiler, and it is now needed on the DEFAULT path,
# path — so a default build does not pay for a host ucode nothing consumes. # so the dependency is unconditional. It was `WWAND_UCODE_PRECOMPILE:ucode/host`
# # — a conditional that could only ever be as reliable as the symbol behind it,
# SYMBOL:pkg, not $(if $(CONFIG_...),...): Build-Depends is emitted by the # and when the symbol went missing the compiler went with it and cmake fell back
# metadata scan, which runs with DUMP=1, and rules.mk skips $(TOPDIR)/.config in # to shipping source. A build that opts out with CONFIG_WWAND_UCODE_SOURCE pays
# that case — a $(if ...) wrapper is always empty there and would drop the # for a host ucode it does not use; that is the cheaper mistake by far.
# dependency unconditionally, leaving the opt-in to silently fall back to PKG_BUILD_DEPENDS:=ucode/host
# source. package-metadata.pl builds the conditional from the prefix instead.
PKG_BUILD_DEPENDS:=WWAND_UCODE_PRECOMPILE:ucode/host
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk include $(INCLUDE_DIR)/cmake.mk
@@ -65,12 +76,11 @@ CMAKE_BINARY_SUBDIR:=build
CMAKE_OPTIONS += \ CMAKE_OPTIONS += \
-DUCODE_COMPILER=$(STAGING_DIR_HOSTPKG)/bin/ucode \ -DUCODE_COMPILER=$(STAGING_DIR_HOSTPKG)/bin/ucode \
-DUCODE_PRECOMPILE=$(if $(CONFIG_WWAND_UCODE_PRECOMPILE),ON,OFF) -DUCODE_PRECOMPILE=$(if $(CONFIG_WWAND_UCODE_SOURCE),OFF,ON)
# every install section takes the ucode tree from here: the bytecode output of # every install section takes the ucode tree from here: the cmake bytecode
# the plain sources, or the cmake bytecode output when CONFIG_WWAND_UCODE_ # output, or the plain sources when CONFIG_WWAND_UCODE_SOURCE opts out.
# PRECOMPILE is set. WWAND_UCODE=$(if $(CONFIG_WWAND_UCODE_SOURCE),$(PKG_BUILD_DIR)/src-ucode,$(CMAKE_BINARY_DIR)/ucode/wwand)
WWAND_UCODE=$(if $(CONFIG_WWAND_UCODE_PRECOMPILE),$(CMAKE_BINARY_DIR)/ucode/wwand,$(PKG_BUILD_DIR)/src-ucode)
UCDIR:=/usr/share/ucode/wwand UCDIR:=/usr/share/ucode/wwand
@@ -89,30 +99,34 @@ WWAND_BASE_CODEC:=arfcn_bands.uc hex.uc qmux.uc tlv.uc
WWAND_BASE_SCHEMA:=ctl.uc dms.uc dsd.uc loc.uc loc_lazy.uc merge.uc nas.uc rat.uc \ WWAND_BASE_SCHEMA:=ctl.uc dms.uc dsd.uc loc.uc loc_lazy.uc merge.uc nas.uc rat.uc \
uim.uc wda.uc wds.uc wms.uc wms_lazy.uc uim.uc wda.uc wds.uc wms.uc wms_lazy.uc
# DEVELOPERS: leave CONFIG_WWAND_UCODE_PRECOMPILE off (the default) # DEVELOPERS: set CONFIG_WWAND_UCODE_SOURCE to ship readable .uc source
# to ship readable .uc source instead of bytecode — for editing modules live # instead of bytecode — for editing modules live under /usr/share/ucode/wwand
# under /usr/share/ucode/wwand and for source-line tracebacks; bytecode # and for source-line tracebacks; bytecode tracebacks report offsets only.
# tracebacks report offsets only. PKG_CONFIG_DEPENDS:=CONFIG_WWAND_UCODE_SOURCE
PKG_CONFIG_DEPENDS:=CONFIG_WWAND_UCODE_PRECOMPILE
define Package/wwand/config define Package/wwand/config
config WWAND_UCODE_PRECOMPILE config WWAND_UCODE_SOURCE
bool "Precompile the ucode tree to bytecode" bool "Ship the ucode tree as source instead of bytecode"
depends on PACKAGE_wwand depends on PACKAGE_wwand
default n default n
help help
Compile the ucode tree to bytecode instead of shipping the By default the ucode tree is compiled to bytecode, so the daemon
readable sources. The daemon then starts without a parse step starts without a parse step (measured 41 ms -> 5 ms for the core
(measured 41 ms -> 5 ms for the core imports on x86; more on a imports on x86; more on a router CPU). Enable this to ship the
router CPU). readable sources instead: modules can then be edited in place
under /usr/share/ucode/wwand and tracebacks name source lines
rather than bytecode offsets.
Only enable this when ucode and wwand come from the SAME build Note that bytecode carries a format version that an interpreter
tree, e.g. a self-built image. Bytecode carries a format version upgraded past it refuses to load, and nothing in the package
that an interpreter upgraded past it refuses to load, and nothing metadata can express that dependency — so upgrading ucode alone
in the package metadata can express that dependency, so a plain can leave the daemon unable to start. The init script detects
package upgrade of ucode would leave the daemon unable to start. exactly that case and refuses with an explanatory log line rather
The init script detects exactly that case and refuses with an than respawning forever; the remedy is to reinstall wwand built
explanatory log line rather than respawning forever. against the running ucode, or to enable this option.
A build whose host ucode cannot emit bytecode does not fail: the
cmake capability probe falls back to shipping source on its own.
endef endef
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -255,6 +269,8 @@ define Package/wwand-mbim/install
$(INSTALL_DATA) $(WWAND_UCODE)/mbim_client.uc $(1)$(UCDIR)/ $(INSTALL_DATA) $(WWAND_UCODE)/mbim_client.uc $(1)$(UCDIR)/
$(INSTALL_DATA) $(WWAND_UCODE)/qmi_over_mbim.uc $(1)$(UCDIR)/ $(INSTALL_DATA) $(WWAND_UCODE)/qmi_over_mbim.uc $(1)$(UCDIR)/
$(INSTALL_DATA) $(WWAND_UCODE)/mbim_lazy.uc $(1)$(UCDIR)/ $(INSTALL_DATA) $(WWAND_UCODE)/mbim_lazy.uc $(1)$(UCDIR)/
$(INSTALL_DATA) $(WWAND_UCODE)/atcmd_mbim.uc $(1)$(UCDIR)/
$(INSTALL_DATA) $(WWAND_UCODE)/atcmd_mbim_lazy.uc $(1)$(UCDIR)/
$(INSTALL_DATA) $(WWAND_UCODE)/codec/mbim.uc $(1)$(UCDIR)/codec/ $(INSTALL_DATA) $(WWAND_UCODE)/codec/mbim.uc $(1)$(UCDIR)/codec/
$(INSTALL_DATA) $(WWAND_UCODE)/codec/mbim_schema/*.uc $(1)$(UCDIR)/codec/mbim_schema/ $(INSTALL_DATA) $(WWAND_UCODE)/codec/mbim_schema/*.uc $(1)$(UCDIR)/codec/mbim_schema/
endef endef