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
+++ b/luci-theme-footstrap/root/etc/config/footstrap
@@ -1,2 +1,6 @@
@@ -1,2 +1,7 @@
config footstrap 'settings'
+ option pattern 'a583df0ec986a6b8454f7be514e8cb32'
+ option darkmode 'dark'
+ option wallpaper 'pattern'
+ option layout 'sidebar'
+ option autocollapse 'on'
--- a/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
PKG_NAME:=dae
PKG_VERSION:=2026.08.21
PKG_RELEASE:=37
PKG_VERSION:=2026.08.26
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_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
@@ -39,7 +39,7 @@ GO_PKG_LDFLAGS:= \
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/cmd.Version=$(PKG_VERSION) \
$(GO_PKG)/common/consts.MaxMatchSetLen_=1024
GO_PKG_TAGS:=trace
GO_PKG_TAGS:=trace,timetzdata
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/bpf.mk
+1 -1
View File
@@ -27,7 +27,7 @@ start_service() {
config_get log_maxsize "config" "log_maxsize" "1"
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_append_param command --config "$config_file"
procd_append_param command --disable-timestamp
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.08.21
PKG_RELEASE:=48
PKG_VERSION:=2026.08.26
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_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
PKG_VERSION:=1.14.7
PKG_RELEASE:=40
PKG_RELEASE:=41
PKG_MAINTAINER:=kenzok8
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-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-info{color:#7fc7a8;background:rgba(127,199,168,.08)}',
'.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" ... */
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) {
// daed/dae logs use lvl=info / [INFO] / level=warning style
@@ -65,22 +66,29 @@ function detectLevel(line) {
return 'dd-error';
}
/* 化时间戳并按北京时间显示:
/* 格式化时间戳并按北京时间显示:
* - 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 (/^\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);
if (!isNaN(d.getTime())) {
const beijing = new Date(d.getTime() + 8 * 60 * 60 * 1000);
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 m ? m[1] : raw;
return raw;
}
function lvlShort(level) {
@@ -97,22 +105,42 @@ function lvlClass(level) {
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) {
const cls = detectLevel(ln);
const m = ln.match(RE_LINE);
if (!m) {
const parsed = parseLine(ln);
if (!parsed) {
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 = [
E('span', { 'class': 'dd-ts' }, ts),
E('span', { 'class': 'dd-lvl ' + lvlClass(lvl) }, lvlShort(lvl)),
E('span', { 'class': 'dd-msg' }, msg)
E('span', { 'class': 'dd-ts' }, parsed.ts),
E('span', { 'class': 'dd-lvl ' + lvlClass(parsed.lvl) }, lvlShort(parsed.lvl)),
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);
}
@@ -53,6 +53,7 @@ DAE_INIT="/etc/init.d/dae"
if [ -f "$DAE_INIT" ]; then
sed -i 's/--disable-timestamp//' "$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
# 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_VERSION:=26.8.26
PKG_RELEASE:=247
PKG_RELEASE:=248
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -829,6 +829,8 @@ function rollback_rules()
if geo2rule == "1" and rules ~= "" then
luci.sys.call("lua /usr/share/passwall/rule_update.lua log '" .. rules .. "' rollback > /dev/null")
end
uci_set("@global[0]", "flush_set", "1")
uci_save(true)
http_write_json_ok()
end
@@ -402,6 +402,7 @@ end
---- DNS Forward
o = s:option(Value, "remote_dns", translate("Remote DNS"))
o.datatype = "or(ipaddr,ipaddrport(1))"
o.default = "1.1.1.1"
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
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 = 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:value("223.5.5.5")
o:value("223.6.6.6")
@@ -389,7 +389,7 @@ o:depends({dns_mode = "dns2socks"})
---- DNS Forward
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:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
@@ -69,13 +69,6 @@ local function convert_geofile()
end
local function convert(file_path, prefix, tags)
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
geo_convert_srs({["geo_path"] = file_path, ["prefix"] = prefix, ["rule_name"] = k})
end
@@ -1664,8 +1664,12 @@ function gen_config(var)
_direct_dns.port = port
_direct_dns.address = direct_dns_udp_server
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
_direct_dns.address = "tcp://" .. direct_dns_tcp_server .. ":" .. port
_direct_dns.port = port
end
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
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.port = tonumber(remote_dns_tcp_port) or 53
elseif remote_dns_doh then
local _a = api.parseDoH(remote_dns_doh)
@@ -44,6 +44,7 @@ FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18"
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() {
if [ -z "$1" ] || [ -z "$2" ]; then
@@ -746,15 +747,14 @@ load_acl() {
}
filter_haproxy() {
for item in ${haproxy_items}; do
local ip=$(get_host_ip ipv4 $(echo $item | awk -F ":" '{print $1}') 1)
ipset -q add $IPSET_VPS $ip
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)
[ -n "$ip" ] && ! echo "$ip" | grep -Eq "$EXCLUDE_VPSIP" && ipset -q add $IPSET_VPS $ip
done
echolog " - [$?]加入负载均衡的节点到ipset[$IPSET_VPS]直连完成"
}
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
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
@@ -802,7 +802,9 @@ filter_node() {
local port=$(config_n_get "$node" port)
local hop=$(config_n_get "$node" hysteria2_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"
}
@@ -50,6 +50,7 @@ FAKE_IP="198.18.0.0/15"
FAKE_IP_6="fc00::/18"
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() {
local ports="$1"
@@ -809,24 +810,23 @@ load_acl() {
}
filter_haproxy() {
for item in ${haproxy_items}; do
get_host_ip ipv4 $(echo $item | awk -F ":" '{print $1}') 1
done | insert_nftset $NFTSET_VPS
for item in $(uci show $CONFIG | grep ".lbss=" | cut -d "'" -f 2); do
get_host_ip "ipv4" "$(echo $item | awk -F ":" '{print $1}')" 1
done | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS
echolog " - [$?]加入负载均衡的节点到nftset[$NFTSET_VPS]直连完成"
}
filter_vps_addr() {
for server_host in "$@"; do
get_host_ip "ipv4" ${server_host}
done | insert_nftset $NFTSET_VPS
get_host_ip "ipv4" "${server_host}"
done | grep -Ev "$EXCLUDE_VPSIP" | insert_nftset $NFTSET_VPS
for server_host in "$@"; do
get_host_ip "ipv6" ${server_host}
get_host_ip "ipv6" "${server_host}"
done | insert_nftset $NFTSET_VPS6
}
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
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
@@ -864,7 +864,9 @@ filter_node() {
local port=$(config_n_get "$node" port)
local hop=$(config_n_get "$node" hysteria2_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"
}
@@ -877,7 +879,6 @@ filter_direct_node_list() {
done
}
del_script_mwan3() {
[ -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
-- 如果是手动更新(arg2存在)始终生成规则
if arg2 then geoip_update_ok, geosite_update_ok = true, true end
chnroute_update, chnroute6_update, gfwlist_update, chnlist_update = "1", "1", "1", "1"
if arg2 then
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 fs.access(asset_location .. "geoip.dat") then
safe_call(fetch_chnroute, "生成chnroute发生错误...")
safe_call(fetch_chnroute6, "生成chnroute6发生错误...")
if chnroute_update == "1" then
safe_call(fetch_chnroute, "生成chnroute发生错误...")
end
if chnroute6_update == "1" then
safe_call(fetch_chnroute6, "生成chnroute6发生错误...")
end
else
log("geoip.dat 文件不存在,跳过规则生成。")
end
@@ -740,8 +748,12 @@ if geo2rule == "1" then
if geosite_update_ok then
if fs.access(asset_location .. "geosite.dat") then
safe_call(fetch_gfwlist, "生成gfwlist发生错误...")
safe_call(fetch_chnlist, "生成chnlist发生错误...")
if gfwlist_update == "1" then
safe_call(fetch_gfwlist, "生成gfwlist发生错误...")
end
if chnlist_update == "1" then
safe_call(fetch_chnlist, "生成chnlist发生错误...")
end
else
log("geosite.dat 文件不存在,跳过规则生成。")
end
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.8.27
PKG_RELEASE:=102
PKG_RELEASE:=103
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -271,7 +271,7 @@ o:depends("_show_dns_option", "1")
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
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:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
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)
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:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
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
}
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
local _remote_dns_ip = nil
local _remote_dns = {
tag = remote_dns_tag,
@@ -1635,14 +1633,15 @@ function gen_config(var)
_remote_dns.address = remote_dns_udp_server
_remote_dns.port = tonumber(remote_dns_udp_port) or 53
_remote_dns_proto = "udp"
_remote_dns_ip = remote_dns_udp_server
end
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.port = tonumber(remote_dns_tcp_port) or 53
_remote_dns_proto = "tcp"
_remote_dns_ip = remote_dns_tcp_server
end
if remote_dns_doh_url and remote_dns_doh_host then
@@ -1651,7 +1650,6 @@ function gen_config(var)
end
_remote_dns.address = remote_dns_doh_url
_remote_dns.port = tonumber(remote_dns_doh_port) or 443
_remote_dns_ip = remote_dns_doh_ip
end
if _remote_dns.address then
@@ -69,7 +69,7 @@ check_run_environment() {
run_xray() {
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
eval_set_val $@
node_protocol=$(config_n_get $node protocol)
@@ -100,6 +100,7 @@ run_xray() {
}
}
[ -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)
@@ -129,24 +130,16 @@ run_xray() {
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
udp)
local _dns=$(get_first_dns remote_dns_udp_server 53 | sed 's/#/:/g')
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}')
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}"
json_add_string "remote_dns_udp_server" "${remote_dns_udp_server}"
json_add_string "remote_dns_udp_port" "${remote_dns_udp_port}"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "udp://${remote_dns_udp_server}:${remote_dns_udp_port}")"
;;
tcp)
local _dns=$(get_first_dns remote_dns_tcp_server 53 | sed 's/#/:/g')
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}')
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}"
json_add_string "remote_dns_tcp_server" "${remote_dns_tcp_server}"
json_add_string "remote_dns_tcp_port" "${remote_dns_tcp_port}"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "tcp://${remote_dns_tcp_server}:${remote_dns_tcp_port}")"
;;
doh)
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_host" "${_doh_host}"
[ -n "$_doh_bootstrap" ] && json_add_string "remote_dns_doh_ip" "${_doh_bootstrap}"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${_doh_url}")"
;;
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_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}"
log_out="${dns_msg})"
}
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}"
@@ -176,6 +176,7 @@ run_xray() {
json_add_string "redir_port" "${redir_port}"
set_cache_var "node_${node}_redir_port" "${redir_port}"
json_add_string "tcp_proxy_way" "${TCP_PROXY_WAY}"
[ -n "${log_out}" ] && log_out="Xray[${redir_port}] ${log_out}"
}
json_add_string "node" "${node}"
@@ -189,6 +190,8 @@ run_xray() {
$XRAY_BIN run -test -c "$config_file" > $test_log_file; local status=$?
if [ "${status}" == 0 ]; then
ln_run ${QUEUE_RUN} "$XRAY_BIN" xray $log_file run -c "$config_file"
[ -n "${log_out}" ] && log 2 ${log_out}
unset log_out
else
_error_log_file=$test_log_file
return ${status}
@@ -197,7 +200,7 @@ run_xray() {
run_singbox() {
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
eval_set_val $@
local type=$(echo $(config_n_get $node type) | tr 'A-Z' 'a-z')
@@ -239,6 +242,9 @@ run_singbox() {
}
}
[ -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)
[ "${write_ipset_direct}" = "1" ] && {
direct_dnsmasq_listen_port=$(get_new_port auto)
@@ -270,21 +276,17 @@ run_singbox() {
case "$remote_dns_protocol" in
udp|\
quic)
local _dns=$(get_first_dns remote_dns_udp_server 53 | sed 's/#/:/g')
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}')
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}"
json_add_string "remote_dns_udp_server" "${remote_dns_udp_server}"
json_add_string "remote_dns_udp_port" "${remote_dns_udp_port}"
[ "$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|\
tls)
local _dns=$(get_first_dns remote_dns_tcp_server 53 | sed 's/#/:/g')
local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}')
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}"
json_add_string "remote_dns_tcp_server" "${remote_dns_tcp_server}"
json_add_string "remote_dns_tcp_port" "${remote_dns_tcp_port}"
[ "$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|\
http3)
@@ -302,17 +304,19 @@ run_singbox() {
json_add_string "remote_dns_doh_url" "${_doh_url}"
json_add_string "remote_dns_doh_host" "${_doh_host}"
[ "$remote_dns_protocol" == "http3" ] && json_add_string "remote_dns_http3" "1"
dns_msg="${dns_msg} $(i18n "Remote DNS: %s" "${_doh_url}")"
;;
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_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 "$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}"
log_out="${dns_msg})"
}
json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}"
json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}"
@@ -322,6 +326,7 @@ run_singbox() {
json_add_string "redir_port" "${redir_port}"
set_cache_var "node_${node}_redir_port" "${redir_port}"
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}"
@@ -335,6 +340,8 @@ run_singbox() {
$SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; local status=$?
if [ "${status}" == 0 ]; then
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
_error_log_file=$test_log_file
return ${status}
@@ -803,13 +810,28 @@ run_ipset_dnsmasq() {
}
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
[ -n "${XRAY_BIN}" ] && run_func="run_xray"
[ -n "${SINGBOX_BIN}" ] && run_func="run_singbox"
local acl_node_num=0
for nid in $(jsonfilter -s "${acl_json}" -e '$.node_order[*]'); do
[ ! -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)
eval local ${_var}
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 commit dhcp
json_init
json_add_string "LOG" "1"
lua $APP_PATH/helper_dnsmasq.lua logic_restart "$(json_dump)"
lua $APP_PATH/helper_dnsmasq.lua logic_restart
else
#Run a copy dnsmasq instance, DNS hijack for that need proxy devices.
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}"
#dhcp.leases to hosts
$APP_PATH/lease2hosts.sh > /dev/null 2>&1 &
log 2 "Dnsmasq[${dnsmasq_port}]:(127.0.0.1:${dns_listen_port})"
fi
else
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}"
#dhcp.leases to hostsMore actions
$APP_PATH/lease2hosts.sh > /dev/null 2>&1 &
log 2 "Dnsmasq[${dnsmasq_port}]:(127.0.0.1:${dns_listen_port})"
fi
rm -f ${TMP_ACL_PATH}/acl_node_${nid}
done
[ ! -f ${TMP_ACL_PATH}/acl_node_default ] && ENABLED_DEFAULT_ACL=0
[ "${acl_node_num}" == 0 ] && ENABLED_ACLS=0 && ENABLED_DEFAULT_ACL=0
}
start() {
@@ -892,16 +912,6 @@ start() {
nftflag=0
USE_TABLES=""
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
set_cache_var "USE_TABLES" "$USE_TABLES"
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_file = config_path .. ".json"
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, "node", node[".name"])
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, "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_tcp_server", v.remote_dns)
add_args(run_args, "remote_dns_udp_server", v.remote_dns)
add_args(run_args, "remote_dns_tcp_server", dns_server)
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_client_ip", v.remote_dns_client_ip)
add_args(run_args, "remote_dns_detour", v.remote_dns_detour)
@@ -366,6 +366,6 @@ if arg[1] then
if arg[2] then
var = jsonc.parse(arg[2])
end
func(var)
func(var or {})
end
end
@@ -256,9 +256,9 @@ add_shunt_t_rule() {
}
load_acl() {
log_i18n 1 "Access Control:"
acl_json=$(lua $APP_PATH/app_acl.lua)
acl_node
log_i18n 1 "Access Control:"
for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do
eval local $(cat "${TMP_ACL_PATH}/${sid}/var")
@@ -294,9 +294,9 @@ add_shunt_t_rule() {
}
load_acl() {
log_i18n 1 "Access Control:"
acl_json=$(lua $APP_PATH/app_acl.lua)
acl_node
log_i18n 1 "Access Control:"
for sid in $(jsonfilter -s "${acl_json}" -e '$.acl[*].flag'); do
eval $(cat "${TMP_ACL_PATH}/${sid}/var")
@@ -253,28 +253,6 @@ hosts_foreach() {
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() {
local port=$1
local protocol=$2
+3 -3
View File
@@ -15,12 +15,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand
PKG_RELEASE:=6
PKG_RELEASE:=7
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=a716b595603f2b1bc01d71be199c63bacc1ba9fc
PKG_SOURCE_DATE:=2026-08-25
PKG_SOURCE_VERSION:=8dab3e346939ddc633f86e36b4b30365f62d74e0
PKG_SOURCE_DATE:=2026-08-27
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
+5 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=38
PKG_RELEASE:=39
endif
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
# …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
# 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))'#" \
$(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js
endef
+55 -7
View File
@@ -33,14 +33,52 @@ done
TMP="$OUT.tmp.$$"
# $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")"
# 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
cat "$D"/styles/*.css \
"$D"/styles/base/*.css \
"$D"/styles/theme/*.css \
"$D"/styles/pages/*.css > "$TMP"
{
emit_layer tokens "$D"/styles/*.css
emit_layer base "$D"/styles/base/*.css
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.
#
@@ -135,9 +173,19 @@ squeeze() {
prev = (length(out) ? substr(out, length(out), 1) : lastc)
nxt = (i <= n ? substr(line, i, 1) : "")
# 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
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "")
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
continue
out = out " "; lastreal = " "
continue
@@ -3,7 +3,8 @@
'require ui';
'require dom';
'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';
/* 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
* 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.
*
* 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 ----
*
* 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,
* `contrast` the pair it reports */
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,
read: axis.current,
contrast: contrast,
@@ -137,7 +336,7 @@ function build() {
dark: _('Dark', 'footstrap')
}, bump(repaint(prefs.applyMode)), label)),
group(_('Palette', 'footstrap'), (label) => selectCtl(prefs.currentPalette(), {
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
footstrap: 'Footstrap',
hicontrast: 'Hi-Contrast',
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is
@@ -145,7 +344,7 @@ function build() {
bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020'
}, bump(repaint(prefs.applyPalette)), label)),
}, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
compact: _('Compact', 'footstrap'),
@@ -154,7 +353,7 @@ function build() {
}, bump(prefs.applyDensity), label)),
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,
* 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
* would look for the router-identity cue under it */
colourGroup(_('Tint (router identification)', 'footstrap'), {
current: prefs.currentTint, apply: prefs.applyTint
current: axes.currentTint, apply: axes.applyTint
}, 'var(--fs-bg)', {
/* 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 */
@@ -186,7 +385,7 @@ function build() {
* 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. */
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
}), { 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
* you want grey or black"), taking any #rrggbb the colour-chip presets that once sat here
* are not coming back. */
colourGroup(_('Accent', 'footstrap'), {
current: prefs.currentAccent, apply: prefs.applyAccent
}, 'var(--fs-accent)', {
fg: 'var(--fs-accent)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Good', 'footstrap'), {
current: prefs.currentGood, apply: prefs.applyGood
}, 'var(--fs-good)', {
fg: 'var(--fs-good)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
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')
})
/* Four status roles, one shape: the role's own colour read against a card. Written out
* eight times between here and the surfaces below, they cost their repeated literals in
* full a string is not mangled so the rows are data and the row is stated once. */
...[
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
].map(([ label, current, apply, ink ]) =>
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
];
/* ---- 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
* the number and leaves the call to the admin. */
const surfaces = [
colourGroup(_('Cards', 'footstrap'), {
current: prefs.currentCard, apply: prefs.applyCard
}, 'var(--fs-panel)', {
fg: 'var(--fs-text)', bg: 'var(--fs-panel)', label: _('on a card', 'footstrap')
}),
colourGroup(_('Controls', 'footstrap'), {
current: prefs.currentControl, apply: prefs.applyControl
}, 'var(--fs-panel2)', {
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'
})
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
* for the three that carry body text and the hairline itself for the border. */
...[
[ _('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') ],
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
];
/* ---- section 3: the wallpaper and the rows each value brings ----
@@ -301,30 +472,30 @@ function build() {
group(_('Pattern', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
{ extra: [ patInput, patPreview, patErr ] }),
group(scaleLabel, (lbl) => sliderCtl(prefs.currentPatternSize(), 40, 1600,
bump(prefs.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(prefs.currentPatternStrength(), 0, 100,
bump(prefs.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(prefs.currentPatternInk(), {
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
bump(axes.applyPatternSize), lbl, { step: 20 })),
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
bump(axes.applyPatternStrength), lbl, { step: 5 })),
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
theme: _('Theme', 'footstrap'),
original: _('As in file', 'footstrap')
}, bump(prefs.applyPatternInk), lbl))
}, bump(axes.applyPatternInk), lbl))
];
/* …and the rows the FILE photo brings. */
const fileRows = [
group(_('File', 'footstrap'),
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
{ extra: [ fileInput, preview, err ] }),
group(dimLabel, (lbl) => sliderCtl(prefs.currentPhotoDim(), 0, 100,
bump(prefs.applyPhotoDim), lbl, { step: 5 }))
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
bump(axes.applyPhotoDim), lbl, { step: 5 }))
];
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; }
}
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; }
}
/* `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'); });
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
}
reflect(prefs.currentLoginBg());
reflectPattern(prefs.currentPattern());
togglePanel(prefs.currentWallpaper());
reflect(axes.currentLoginBg());
reflectPattern(axes.currentPattern());
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(); });
patInput.addEventListener('change', () => {
const f = patInput.files && patInput.files[0];
patInput.value = ''; /* so re-picking the same file fires change again */
if (!f) return;
patErr.hidden = true; patChoose.disabled = true;
patChoose.textContent = _('Uploading…', 'footstrap');
prefs.uploadPattern(f)
.then((tok) => {
reflectPattern(tok);
/* uploadPattern already switched this browser onto the pattern, so the control
* must catch up or the page paints the tile while the dropdown reads Off.
* `dom.callClassMethod` is how LuCI moves its own widgets from outside;
* setWallpaper is then called directly, because a programmatic setValue emits
* no `widget-change`. */
dom.callClassMethod(seg, 'setValue', 'pattern');
setWallpaper('pattern');
})
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patChoose.disabled = false; patChoose.textContent = patChooseLabel; });
});
patRemove.addEventListener('click', () => {
patErr.hidden = true; patRemove.disabled = true;
prefs.removePattern()
.then(() => reflectPattern(''))
.catch((e) => { patErr.textContent = String((e && e.message) || e); patErr.hidden = false; })
.finally(() => { patRemove.disabled = false; });
/* Both uploads present the same three controls and the same four states pick, upload,
* report, remove so the wiring is stated once. What differs is `after`: the pattern also
* has to move the Wallpaper dropdown, because the upload switched this browser onto the
* tile and the page would otherwise paint it while the control still read Off.
*
* The file input is cleared on every change so re-picking the SAME file fires `change`
* again, and the button carries its own busy state: the label is restored in `finally`, or
* a failed upload leaves "Uploading…" standing for the life of the form. */
const wireUploader = (u) => {
const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; };
u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); });
u.input.addEventListener('change', () => {
const f = u.input.files && u.input.files[0];
u.input.value = '';
if (!f) return;
u.err.hidden = true; u.choose.disabled = true;
u.choose.textContent = _('Uploading…', 'footstrap');
u.upload(f)
.then((tok) => { u.reflect(tok); if (u.after) u.after(tok); })
.catch(fail)
.finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; });
});
u.remove.addEventListener('click', () => {
u.err.hidden = true; u.remove.disabled = true;
u.drop().then(() => u.reflect('')).catch(fail)
.finally(() => { u.remove.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(); });
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
fileInput.value = ''; /* so re-picking the same file fires change again */
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; });
wireUploader({
choose: chooseBtn, remove: removeBtn, input: fileInput, err: err,
label: chooseLabel, reflect: reflect,
upload: assets.uploadLoginBg, drop: assets.removeLoginBg
});
let seg;
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
seg = selectCtl(prefs.currentWallpaper(), {
seg = selectCtl(axes.currentWallpaper(), {
off: _('Off', 'footstrap'),
pattern: _('Pattern', 'footstrap'),
file: _('File', 'footstrap')
@@ -442,14 +608,14 @@ function build() {
saveErr.hidden = false;
return;
}
const saved = prefs.matchesSavedDefault();
const saved = axes.matchesSavedDefault();
saveBtn.disabled = saved;
saveBtn.textContent = saved ? _('Saved as default', 'footstrap') : _('Save as default', 'footstrap');
}
saveBtn.addEventListener('click', () => {
saveBtn.disabled = true;
saveErr.hidden = true;
prefs.saveAsDefault()
axes.saveAsDefault()
.then(() => { saveErr.hidden = true; })
/* 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
@@ -485,8 +651,8 @@ function build() {
location.reload();
});
}
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), prefs.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), prefs.resetToBuiltin);
twoClick(resetSavedBtn, _('Reset to saved', 'footstrap'), axes.resetToSaved);
twoClick(resetBtn, _('Reset to default', 'footstrap'), axes.resetToBuiltin);
refreshSave(); /* correct label and enabled state before the first paint */
const versionLink = E('a', {
@@ -536,7 +702,8 @@ function build() {
function foldable(title, rows, key) {
const id = 'fs-ap-fold-' + (++foldSeq);
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', {
'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 form to be in the document: every readout resolves through widgets.probeColor(), whose
* hidden probe is attached to <body>, so a detached form still reads the live palette. */
* the form to be in the document: every readout resolves inside fs-widgets against a hidden
* probe attached to <body>, so a detached form still reads the live palette. */
Promise.resolve().then(refreshColours);
return page;
}
@@ -761,6 +928,5 @@ function wire() {
}
return baseclass.extend({
wire,
render
wire
});
@@ -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
* 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. */
const FS_RADIUS_DEFAULT = 12;
/* ---- 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
* 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
* attribute). */
function enumAxis(key, attr, on, off) {
/* An axis whose values are a list, with one of them stamped as nothing.
*
* `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
* 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
* 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 def = () => (sd(sdKey) === on ? on : off);
const ok = (v) => (values.indexOf(v) >= 0);
const def = () => (ok(sd(sdKey)) ? sd(sdKey) : dflt);
return {
def,
current() {
const s = lsGet(key);
if (s === on) return on;
if (s === off) return off;
if (ok(s)) return s;
if (s === dflt) return dflt;
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) {
const root = document.documentElement;
const isOn = (val === on);
lsSet(key, isOn ? on : off);
if (isOn) root.setAttribute(attr, on);
else root.removeAttribute(attr);
const v = ok(val) ? val : dflt;
/* stored explicitly (including the default), so it overrides a router default */
lsSet(key, v);
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:
* 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).
@@ -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.
* `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. */
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 */
function densityDefault() {
const d = sd('density');
return (DENSITIES.indexOf(d) >= 0) ? d : 'normal';
}
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);
}
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
densityDefault = DENSITY.def;
/* 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
* 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
* 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() {
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
* 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. */
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.
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
*
* 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 = 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(''); });
}
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
* and 188 B larger after minification three lists of short literals compress better than
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
* field against header.ut's FS_AXES. */
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,
currentPalette, applyPalette,
currentWallpaper, applyWallpaper,
currentDensity, applyDensity,
currentRadius, applyRadius,
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
currentMode, applyMode, modeDefault, guardDarkStamp,
currentDensity, applyDensity, densityDefault,
currentLayout, applyLayout, isTopLayout,
currentAutoCollapse, applyAutoCollapse, autoCollapseDefault,
currentRail, applyRail
});
@@ -1155,36 +1155,52 @@ function bootDocumentIsOurs() {
*
* The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own
* guards at their use, being optional there. */
const CONTRACT = [
[ 'L.require', () => typeof window.L.require === 'function' ],
const CONTRACT_FNS = [
'L.require',
/* 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.dom.content', () => window.L.dom && typeof window.L.dom.content === 'function' ],
'L.Class',
'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 */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env);
} ],
[ 'L.Poll.queue', () => window.L.Poll && 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' ]
[ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
];
/* -> 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. */
function contractBreaks() {
return CONTRACT.filter(([ , present ]) => {
try { return !present(); }
const gone = (probe) => {
try { return !probe(); }
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() {
@@ -143,69 +143,28 @@ function search(q, limit) {
const RECENT_KEY = 'fs-recent';
const RECENT_MAX = 8;
/* prefs.lsGetArr owns the parse, the corruption guard and the Array check; only the
* "these are paths" filter belongs here */
function loadRecent() {
return prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
}
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));
}
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page this module is not any
* more, and a palette that only loads when it is opened cannot be what records where the admin has
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
* corruption guard and the Array check; only the "these are paths" filter belongs here. */
function recentEntries() {
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
const byPath = new Map(index().map((e) => [ e.path, e ]));
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);
return recent.map((p) => byPath.get(p)).filter(Boolean).slice(0, RECENT_MAX);
}
/* ---- the palette -------------------------------------------------------- */
const MAX_RESULTS = 20;
function wire() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return;
/* Built on the first open and kept the overlay, its listeners and the index survive for the life
* of the document, so a second Ctrl+K costs nothing. Until then this module is not even fetched:
* 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
* covers an F5, a non-SPA-able node and the first page of a session */
remember(L.env.dispatchpath || []);
/* 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();
function build() {
const btn = document.getElementById('fs-search-btn');
if (!btn) return null;
const input = E('input', {
'type': 'text',
@@ -384,8 +343,16 @@ function wire() {
ev.preventDefault();
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({
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.
* 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({
svgIcon,
setOpen,
wireSpaceKey,
wireDismiss,
colorControl,
probeColor,
toHex
wireDismiss
});
@@ -7,7 +7,6 @@
'require fs-router as router';
'require fs-prefs as prefs';
'require fs-sheets as sheets';
'require fs-search as search';
/* 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
@@ -42,6 +41,85 @@ function wirePageModules() {
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.
*
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -140,9 +218,7 @@ return baseclass.extend({
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* after setTree(): the palette indexes that tree on first open, and records recent
* pages from the first navigation onwards */
search.wire();
wireSearch();
chrome.wireRail();
chrome.wireIndicatorCounts();
/* 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:-}"
[ -n "$CSS" ] && [ -f "$CSS" ] || { echo "usage: mangle-tokens.sh <cascade.css> <dir>..." >&2; exit 1; }
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; }
RES="$CSS.reserved.$$"
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
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"
if [ -n "$REWRITE" ]; then
# nothing is reserved: every name is renamed here and in the far side together
: > "$RES"
else
# 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" '
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
@@ -122,3 +147,34 @@ before=$(wc -c < "$CSS")
mv "$CSS.tmp.$$" "$CSS"
after=$(wc -c < "$CSS")
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 wallpaper 'pattern'
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-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[data-palette="footstrap"] {
/* LIGHT */
@@ -22,10 +22,6 @@
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
* 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
@@ -203,14 +203,11 @@
const root = document.querySelector(':root'),
sd = window.__fsSD || {};
let p = lsGet('fs-palette');
/* Legacy palette names migrated to the current explicit value, never cleared:
an absent key means "inherit the router default", which is not "the built-in
default". The migration is already redundant — fs-prefs.js's currentPalette()
ends `return 'footstrap'`, so a stray value reads as the built-in default there
too — and deleting this block changes nothing unless that line goes with it. */
if (p === 'rvht' || p === 'roman' || p === 'github') {
localStorage.setItem('fs-palette', 'footstrap'); p = 'footstrap';
}
/* A stray or retired palette name needs no migration here: it fails the list
below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
way, so the live applier agrees. The three legacy names ('rvht', 'roman',
'github') were rewritten here until this comment replaced them — the block
was measured as changing nothing, and it ran on every page load. */
/* 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
other paints here and is taken away by the first live change. */
@@ -307,7 +304,7 @@
failure would be silent. */
const lb = sd.login_bg;
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) */
const tsRaw = lsGet('fs-tint-strength');
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
PKG_NAME:=upx
PKG_VERSION:=5.2.0
PKG_RELEASE:=2
PKG_VERSION:=5.2.1
PKG_RELEASE:=3
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-src.tar.xz
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)
# 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.
# The ucode tree ships as SOURCE by default. It can be precompiled to bytecode
# (repo-root CMakeLists.txt, alongside wwand_io.so) via CONFIG_WWAND_UCODE_
# PRECOMPILE, but that is opt-in and only sound when ucode and wwand are built
# in the SAME tree: 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 coupling — it is independent
# of libucode's PKG_ABI_VERSION/SONAME, so an ABI-versioned dependency does not
# capture it either. In a feed, where ucode is upgraded on its own, that is a
# coupling we must not create.
# The ucode tree ships PRECOMPILED to bytecode (repo-root CMakeLists.txt,
# alongside wwand_io.so). `CONFIG_WWAND_UCODE_SOURCE` opts out and ships the
# readable sources instead — the same polarity the wwand repo's CMakeLists.txt
# and files/wwand.init already assume.
#
# The polarity matters beyond taste. It used to be an opt-in
# (WWAND_UCODE_PRECOMPILE, default n), which meant the desired outcome depended
# on a symbol SURVIVING defconfig — and in a per-package SDK build it does not:
# 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
# (qmi_lazy.uc + the backend-neutral daemon). Bump PKG_SOURCE_VERSION (and
@@ -30,26 +43,24 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
PKG_RELEASE:=10
PKG_RELEASE:=11
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=648283c23470d602459ce2c54a60adf9109370da
PKG_SOURCE_DATE:=2026-08-26
PKG_SOURCE_VERSION:=fc013f140d423867b993c6026fd435ec750cbb91
PKG_SOURCE_DATE:=2026-08-27
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:=
# host ucode is the bytecode compiler, needed ONLY on the opt-in precompile
# path — so a default build does not pay for a host ucode nothing consumes.
#
# SYMBOL:pkg, not $(if $(CONFIG_...),...): Build-Depends is emitted by the
# metadata scan, which runs with DUMP=1, and rules.mk skips $(TOPDIR)/.config in
# that case — a $(if ...) wrapper is always empty there and would drop the
# dependency unconditionally, leaving the opt-in to silently fall back to
# source. package-metadata.pl builds the conditional from the prefix instead.
PKG_BUILD_DEPENDS:=WWAND_UCODE_PRECOMPILE:ucode/host
# host ucode is the bytecode compiler, and it is now needed on the DEFAULT path,
# 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,
# and when the symbol went missing the compiler went with it and cmake fell back
# to shipping source. A build that opts out with CONFIG_WWAND_UCODE_SOURCE pays
# for a host ucode it does not use; that is the cheaper mistake by far.
PKG_BUILD_DEPENDS:=ucode/host
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
@@ -65,12 +76,11 @@ CMAKE_BINARY_SUBDIR:=build
CMAKE_OPTIONS += \
-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
# the plain sources, or the cmake bytecode output when CONFIG_WWAND_UCODE_
# PRECOMPILE is set.
WWAND_UCODE=$(if $(CONFIG_WWAND_UCODE_PRECOMPILE),$(CMAKE_BINARY_DIR)/ucode/wwand,$(PKG_BUILD_DIR)/src-ucode)
# every install section takes the ucode tree from here: the cmake bytecode
# output, or the plain sources when CONFIG_WWAND_UCODE_SOURCE opts out.
WWAND_UCODE=$(if $(CONFIG_WWAND_UCODE_SOURCE),$(PKG_BUILD_DIR)/src-ucode,$(CMAKE_BINARY_DIR)/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 \
uim.uc wda.uc wds.uc wms.uc wms_lazy.uc
# DEVELOPERS: leave CONFIG_WWAND_UCODE_PRECOMPILE off (the default)
# to ship readable .uc source instead of bytecode — for editing modules live
# under /usr/share/ucode/wwand and for source-line tracebacks; bytecode
# tracebacks report offsets only.
PKG_CONFIG_DEPENDS:=CONFIG_WWAND_UCODE_PRECOMPILE
# DEVELOPERS: set CONFIG_WWAND_UCODE_SOURCE to ship readable .uc source
# instead of bytecode — for editing modules live under /usr/share/ucode/wwand
# and for source-line tracebacks; bytecode tracebacks report offsets only.
PKG_CONFIG_DEPENDS:=CONFIG_WWAND_UCODE_SOURCE
define Package/wwand/config
config WWAND_UCODE_PRECOMPILE
bool "Precompile the ucode tree to bytecode"
config WWAND_UCODE_SOURCE
bool "Ship the ucode tree as source instead of bytecode"
depends on PACKAGE_wwand
default n
help
Compile the ucode tree to bytecode instead of shipping the
readable sources. The daemon then starts without a parse step
(measured 41 ms -> 5 ms for the core imports on x86; more on a
router CPU).
By default the ucode tree is compiled to bytecode, so the daemon
starts without a parse step (measured 41 ms -> 5 ms for the core
imports on x86; more on a router CPU). Enable this to ship the
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
tree, e.g. a self-built image. Bytecode carries a format version
that an interpreter upgraded past it refuses to load, and nothing
in the package metadata can express that dependency, so a plain
package upgrade of ucode would leave the daemon unable to start.
The init script detects exactly that case and refuses with an
explanatory log line rather than respawning forever.
Note that bytecode carries a format version that an interpreter
upgraded past it refuses to load, and nothing in the package
metadata can express that dependency — so upgrading ucode alone
can leave the daemon unable to start. The init script detects
exactly that case and refuses with an explanatory log line rather
than respawning forever; the remedy is to reinstall wwand built
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
# ---------------------------------------------------------------------------
@@ -255,6 +269,8 @@ define Package/wwand-mbim/install
$(INSTALL_DATA) $(WWAND_UCODE)/mbim_client.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)/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_schema/*.uc $(1)$(UCDIR)/codec/mbim_schema/
endef