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