🦄 Sync 2026-07-26 23:49:39

This commit is contained in:
github-actions[bot] 2026-07-26 23:49:39 +08:00
parent 2450ea7cca
commit bf03fd23d8
20 changed files with 482 additions and 145 deletions

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.7.24
PKG_RELEASE:=193
PKG_RELEASE:=194
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \

View File

@ -259,18 +259,48 @@ end
function get_redir_log()
local name = http.formvalue("name")
local proto = http.formvalue("proto")
local proto = http.formvalue("proto"):upper()
local path = "/tmp/etc/passwall/acl/" .. name
proto = proto:upper()
if proto == "UDP" and (uci:get(appname, "@global[0]", "udp_node") or "nil") == "tcp" and not fs.access(path .. "/" .. proto .. ".log") then
proto = "TCP"
local function alert(msg)
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate(msg)))
end
if name == "default" then
if proto == "UDP" and (uci:get(appname, "@global[0]", "udp_node") or "nil") == "tcp" and not fs.access(path .. "/" .. proto .. ".log") then
proto = "TCP"
end
else
local global_tcp = uci:get(appname, "@global[0]", "tcp_node") or "nil"
local global_udp = uci:get(appname, "@global[0]", "udp_node") or "nil"
local acl_tcp = uci:get(appname, name, "tcp_node") or "nil"
local acl_udp = uci:get(appname, name, "udp_node") or "nil"
local global_enabled = uci:get(appname, "@global[0]", "enabled") == "1"
if proto == "TCP" and acl_tcp == global_tcp and global_enabled then
path = "/tmp/etc/passwall/acl/default"
if uci:get(appname, "@global[0]", "log_tcp") ~= "1" then
alert("The access control node is the same as the global node. Please enable global logging.")
return
end
end
if proto == "UDP" and acl_udp == global_udp and global_enabled then
path = "/tmp/etc/passwall/acl/default"
if uci:get(appname, "@global[0]", "log_udp") ~= "1" then
alert("The access control node is the same as the global node. Please enable global logging.")
return
end
end
if proto == "UDP" and acl_udp == "tcp" and not fs.access(path .. "/" .. proto .. ".log") then
proto = "TCP"
end
end
if fs.access(path .. "/" .. proto .. ".log") then
local content = luci.sys.exec("tail -n 19999 ".. path .. "/" .. proto .. ".log")
content = content:gsub("\n", "<br />")
http.write(content)
else
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
alert("Not enabled log")
end
end
@ -289,6 +319,18 @@ end
function get_chinadns_log()
local flag = http.formvalue("flag")
local path = "/tmp/etc/passwall/acl/" .. flag .. "/chinadns_ng.log"
if flag ~= "default" then
local global_tcp = uci:get(appname, "@global[0]", "tcp_node") or "nil"
local acl_tcp = uci:get(appname, flag, "tcp_node") or "nil"
if acl_tcp == global_tcp and uci:get(appname, "@global[0]", "enabled") == "1" then
path = "/tmp/etc/passwall/acl/default/chinadns_ng.log"
if uci:get(appname, "@global[0]", "log_chinadns_ng") ~= "1" then
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("The access control node is the same as the global node. Please enable global logging.")))
return
end
end
end
if fs.access(path) then
local content = luci.sys.exec("tail -n 5000 ".. path)
content = content:gsub("\n", "<br />")

View File

@ -7,7 +7,8 @@ m = Map(appname)
m.redirect = api.url("acl")
api.set_apply_on_parse(m)
if not arg[1] or not m:get(arg[1]) then
local cfgid = arg[1]
if not cfgid or not m:get(cfgid) then
luci.http.redirect(m.redirect)
end
@ -69,7 +70,7 @@ local dynamicList_write = function(self, section, value)
end
-- [[ ACLs Settings ]]--
s = m:section(NamedSection, arg[1], translate("ACLs"), translate("ACLs"))
s = m:section(NamedSection, cfgid, translate("ACLs"), translate("ACLs"))
s.addremove = false
s.dynamic = false
@ -80,9 +81,22 @@ o.rmempty = false
---- Remarks
o = s:option(Value, "remarks", translate("Remarks"))
o.default = arg[1]
o.default = cfgid
o.rmempty = false
---- Log
o = s:option(Flag, "log", translate("Log"))
o.default = 0
o.rmempty = false
o = s:option(ListValue, "loglevel", "Sing-Box/Xray " .. translate("Log Level"))
o.default = "warning"
o:value("debug")
o:value("info")
o:value("warning")
o:value("error")
o:depends("log", "1")
o = s:option(Value, "interface", translate("Source Interface"))
o:value("", translate("All"))
local iface = api.get_network_devices()
@ -238,8 +252,10 @@ o.template = appname .. "/cbi/nodes_listvalue"
o.group = {"",""}
o.remove = function(self, section)
local v = s.fields["shunt_udp_node"]:formvalue(section)
if not v then
if not v or v == "close" then
return m:del(section, self.option)
else
return m:set(section, self.option, "tcp")
end
end
@ -373,9 +389,6 @@ o:value("dnsmasq", "Dnsmasq")
o:value("chinadns-ng", translate("ChinaDNS-NG (recommended)"))
o:depends({ _tcp_node_bool = "1" })
o = s:option(DummyValue, "view_chinadns_log", " ")
o.template = appname .. "/acl/view_chinadns_log"
o = s:option(Flag, "filter_proxy_ipv6", translate("Filter Proxy Host IPv6"), translate("Experimental feature."))
o.default = "0"
o:depends({ _tcp_node_bool = "1" })
@ -603,4 +616,9 @@ for k, v in pairs(nodes_table) do
end
end
local footer = Template(appname .. "/acl/config_footer")
footer.api = api
footer.cfgid = cfgid
m:append(footer)
return api.return_map(m)

View File

@ -124,8 +124,10 @@ o.group = {"",""}
o:depends("_node_sel_other", "1")
o.remove = function(self, section)
local v = s.fields["shunt_udp_node"]:formvalue(section)
if not v then
if not v or v == "close" then
return m:del(section, self.option)
else
return m:set(section, self.option, "tcp")
end
end

View File

@ -0,0 +1,120 @@
<%
local api = self.api
local cfgid = self.cfgid
-%>
<script type="text/javascript">
//<![CDATA[
function to_edit_node(btn) {
if (!btn) return;
const idReg = /^cbid\..*\..*node$/;
let hidden_select = null;
const selects = document.querySelectorAll('select[id^="cbid."][id*="."][id$="node"]');
for (const sel of selects) {
if ( idReg.test(sel.id) && getComputedStyle(sel).display === "none" && (sel.compareDocumentPosition(btn) & Node.DOCUMENT_POSITION_FOLLOWING)) {
hidden_select = sel;
}
}
if (!hidden_select) return;
let node_select_value = hidden_select?.options[0]?.value;
if (!node_select_value || node_select_value.indexOf("tcp") === 0) {
return;
}
let to_url = '<%=api.url("node_config")%>/' + node_select_value;
if (node_select_value.indexOf("Socks_") === 0) {
to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length);
}
location.href = to_url;
}
document.addEventListener('DOMContentLoaded', function () {
function go() {
const node = document.querySelector(".cbi-section-node");
if (!node) return false;
let changed = false;
const all_node = node.querySelectorAll("[id]");
const reg1 = /^cbid\..*\.(tcp_node|udp_node)\.main$/;
for (const el of all_node) {
if (!reg1.test(el.id)) continue;
if (el.querySelector(".node-actions")) continue;
const cbid = el.id.replace(/\.main$/, "");
const hidden_select = document.getElementById(cbid);
const node_select_value = hidden_select?.options[0]?.value;
if (!node_select_value || node_select_value.indexOf("tcp") === 0) {
continue;
}
let html = '<a href="#" onclick="return to_edit_node(this);"><%:Edit%></a>';
const m = cbid.match(/\.(tcp|udp)_node$/);
if (m && (m[1] === "tcp" || m[1] === "udp")) {
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_redir_log") .. "?name=" .. cfgid%>&proto=' + m[1] + '\', \'_blank\')"><%:Log%></a>';
}
html = '<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">' + html + '</div>'
el.insertAdjacentHTML("beforeend", html);
changed = true;
}
return changed;
}
const target = document.querySelector('form') || document.body;
const observer = new MutationObserver(() => go() ? observer.disconnect() : 0);
observer.observe(target, { childList: true, subtree: true });
const timer = setInterval(() => go() ? (clearInterval(timer), observer.disconnect()) : 0, 300);
setTimeout(() => { clearInterval(timer); observer.disconnect(); }, 5000);
});
(function () {
const startTime = Date.now();
const TIMEOUT = 3000;
const waitForDnsSelect = () => {
const dns_select = document.querySelector("select[id*='dns_shunt']");
if (dns_select) {
initDnsSelect(dns_select);
return;
}
if (Date.now() - startTime >= TIMEOUT) {
return;
}
requestAnimationFrame(waitForDnsSelect);
};
waitForDnsSelect();
const initDnsSelect = (dns_select) => {
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
if (dns_select._dnsLogBinded) return;
dns_select._dnsLogBinded = true;
dns_select.addEventListener("change", () => {
const existingLogLink = dns_select.parentElement.querySelector("a.dns-log-link");
if (existingLogLink) {
existingLogLink.remove();
}
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
});
};
const addLogLink = (select) => {
if (select.parentElement.querySelector("a.dns-log-link")) {
return;
}
const logLink = document.createElement("a");
logLink.innerHTML = "<%:Log%>";
logLink.href = "#";
logLink.className = "dns-log-link";
logLink.style.marginLeft = "10px";
logLink.setAttribute("onclick", "window.open('" + '<%=api.url("get_chinadns_log") .. "?flag=" .. cfgid%>' + "', '_blank')");
select.insertAdjacentElement("afterend", logLink);
};
})();
//]]>
</script>

View File

@ -1,55 +0,0 @@
<%
local api = require "luci.passwall.api"
-%>
<script type="text/javascript">
//<![CDATA[
(function () {
const startTime = Date.now();
const TIMEOUT = 3000;
const waitForDnsSelect = () => {
const dns_select = document.querySelector("select[id*='dns_shunt']");
if (dns_select) {
initDnsSelect(dns_select);
return;
}
if (Date.now() - startTime >= TIMEOUT) {
return;
}
requestAnimationFrame(waitForDnsSelect);
};
waitForDnsSelect();
const initDnsSelect = (dns_select) => {
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
if (dns_select._dnsLogBinded) return;
dns_select._dnsLogBinded = true;
dns_select.addEventListener("change", () => {
const existingLogLink = dns_select.parentElement.querySelector("a.dns-log-link");
if (existingLogLink) {
existingLogLink.remove();
}
if (dns_select.value === "chinadns-ng") {
addLogLink(dns_select);
}
});
};
const addLogLink = (select) => {
if (select.parentElement.querySelector("a.dns-log-link")) {
return;
}
const logLink = document.createElement("a");
logLink.innerHTML = "<%:Log%>";
logLink.href = "#";
logLink.className = "dns-log-link";
logLink.style.marginLeft = "10px";
logLink.setAttribute("onclick", "window.open('" + '<%=api.url("get_chinadns_log") .. "?flag=" .. section%>' + "', '_blank')");
select.insertAdjacentElement("afterend", logLink);
};
})();
//]]>
</script>

View File

@ -1712,6 +1712,9 @@ msgstr "要执行的 Shell 命令,用 %s 代替日志内容。"
msgid "Not enabled log"
msgstr "未启用日志"
msgid "The access control node is the same as the global node. Please enable global logging."
msgstr "访问控制节点与全局节点相同,请开启全局日志。"
msgid "It is recommended to disable logging during regular use to reduce system overhead."
msgstr "正常使用时建议关闭日志,以减少系统开销。"

View File

@ -303,13 +303,13 @@ run_dns2socks() {
}
run_chinadns_ng() {
local _flag _listen_port _dns_local _dns_trust _no_ipv6_trust _use_direct_list _use_proxy_list _gfwlist _chnlist _default_mode _default_tag _no_logic_log _tcp_node _filter_https
local _flag _listen_port _dns_local _dns_trust _no_ipv6_trust _use_direct_list _use_proxy_list _gfwlist _chnlist _default_mode _default_tag _no_logic_log _tcp_node _filter_https _log
local _extra_param=""
eval_set_val "$@"
local _CONF_FILE=$TMP_ACL_PATH/$_flag/chinadns_ng.conf
local _LOG_FILE="/dev/null"
[ "$(config_t_get global log_chinadns_ng "0")" = "1" ] && _LOG_FILE=$TMP_ACL_PATH/$_flag/chinadns_ng.log
[ "${_log}" = "1" ] && _LOG_FILE=$TMP_ACL_PATH/$_flag/chinadns_ng.log
_extra_param="-FLAG ${_flag} -TCP_NODE ${_tcp_node} -LISTEN_PORT ${_listen_port} -DNS_LOCAL ${_dns_local} -DNS_TRUST ${_dns_trust}"
_extra_param="${_extra_param} -USE_DIRECT_LIST ${_use_direct_list} -USE_PROXY_LIST ${_use_proxy_list} -USE_BLOCK_LIST ${_use_block_list}"
@ -1430,7 +1430,8 @@ start_dns() {
_default_tag=$(config_t_get global chinadns_ng_default_tag smart) \
_no_logic_log=0 \
_tcp_node=${TCP_NODE} \
_filter_https=$(config_t_get global force_https_soa 0)
_filter_https=$(config_t_get global force_https_soa 0) \
_log=$(config_t_get global log_chinadns_ng 0)
USE_DEFAULT_DNS="chinadns_ng"
}
@ -1524,6 +1525,9 @@ acl_app() {
[ "$(config_n_get $sid enabled)" = "1" ] || continue
eval $(uci -q show "${CONFIG}.${item}" | cut -d'.' -sf 3-)
log=${log:-0}
loglevel=${loglevel:-warning}
if [ -n "${sources}" ]; then
for s in $sources; do
local s2
@ -1684,7 +1688,8 @@ acl_app() {
_default_tag=${chinadns_ng_default_tag:-smart} \
_no_logic_log=1 \
_tcp_node=${tcp_node} \
_filter_https=${force_https_soa:-0}
_filter_https=${force_https_soa:-0} \
_log=${log}
use_default_dns="chinadns_ng"
}
@ -1722,6 +1727,8 @@ acl_app() {
redir_port=$(get_new_port $(expr $redir_port + 1))
set_cache_var "node_${tcp_node}_redir_port" "${redir_port}"
tcp_port=$redir_port
local log_file="/dev/null"
[ "${log}" = "1" ] && log_file="${TMP_ACL_PATH}/${sid}/TCP.log"
if [ "${type}" = "sing-box" ] || [ "${type}" = "xray" ]; then
config_file="acl/${tcp_node}_TCP_${redir_port}.json"
@ -1744,12 +1751,12 @@ acl_app() {
}
config_file="$TMP_PATH/$config_file"
[ "${type}" = "sing-box" ] && type="singbox"
run_${type} flag=$tcp_node node=$tcp_node tcp_redir_port=$redir_port ${_extra_param} config_file=$config_file
run_${type} flag=$tcp_node node=$tcp_node tcp_redir_port=$redir_port ${_extra_param} config_file=$config_file log_file=$log_file loglevel=$loglevel
else
config_file="acl/${tcp_node}_SOCKS_${socks_port}.json"
run_socks flag=$tcp_node node=$tcp_node bind=127.0.0.1 socks_port=$socks_port config_file=$config_file
local log_file=$TMP_ACL_PATH/ipt2socks_${tcp_node}_${redir_port}.log
log_file="/dev/null"
# local log_file=$TMP_ACL_PATH/ipt2socks_${tcp_node}_${redir_port}.log
# log_file="/dev/null"
run_ipt2socks flag=acl_${tcp_node} tcp_tproxy=${is_tproxy} local_port=$redir_port socks_address=127.0.0.1 socks_port=$socks_port log_file=$log_file
fi
run_dns ${_dns_port}
@ -1801,6 +1808,8 @@ acl_app() {
redir_port=$(get_new_port $(expr $redir_port + 1))
set_cache_var "node_${udp_node}_redir_port" "${redir_port}"
udp_port=$redir_port
local log_file="/dev/null"
[ "${log}" = "1" ] && log_file="${TMP_ACL_PATH}/${sid}/UDP.log"
local type
if [ "$(config_get_type ${udp_node#Socks_})" = "socks" ]; then
@ -1816,12 +1825,12 @@ acl_app() {
config_file="acl/${udp_node}_UDP_${redir_port}.json"
config_file="$TMP_PATH/$config_file"
[ "${type}" = "sing-box" ] && type="singbox"
run_${type} flag=$udp_node node=$udp_node udp_redir_port=$redir_port config_file=$config_file
run_${type} flag=$udp_node node=$udp_node udp_redir_port=$redir_port config_file=$config_file log_file=$log_file loglevel=$loglevel
else
config_file="acl/${udp_node}_SOCKS_${socks_port}.json"
run_socks flag=$udp_node node=$udp_node bind=127.0.0.1 socks_port=$socks_port config_file=$config_file
local log_file=$TMP_ACL_PATH/ipt2socks_${udp_node}_${redir_port}.log
log_file="/dev/null"
# local log_file=$TMP_ACL_PATH/ipt2socks_${udp_node}_${redir_port}.log
# log_file="/dev/null"
run_ipt2socks flag=acl_${udp_node} local_port=$redir_port socks_address=127.0.0.1 socks_port=$socks_port log_file=$log_file
fi
fi
@ -1832,7 +1841,7 @@ acl_app() {
fi
}
unset enabled sid remarks sources interface tcp_no_redir_ports udp_no_redir_ports use_global_config tcp_node udp_node use_direct_list use_proxy_list use_block_list use_gfw_list chn_list tcp_proxy_mode udp_proxy_mode filter_proxy_ipv6 dns_mode remote_dns v2ray_dns_mode remote_dns_doh remote_dns_client_ip
unset _ip _mac _iprange _ipset _ip_or_mac source_list tcp_port udp_port config_file _extra_param dns_cache_key
unset _ip _mac _iprange _ipset _ip_or_mac source_list tcp_port udp_port config_file _extra_param dns_cache_key log loglevel
unset _china_ng_listen _chinadns_local_dns _direct_dns_mode chinadns_ng_default_tag dnsmasq_filter_proxy_ipv6 remote_fakedns force_https_soa use_fakedns
done
unset socks_port redir_port dns_port dnsmasq_port chinadns_port

View File

@ -57,6 +57,7 @@ const callLuciNetworkDevices = rpc.declare({
const callLuciWirelessDevices = rpc.declare({
object: 'luci-rpc',
method: 'getWirelessDevices',
nobatch: true,
expect: { '': {} }
});
@ -100,6 +101,9 @@ const callNetworkProtoHandlers = rpc.declare({
let _init = null;
let _state = null;
let _wirelessInit = null;
let _wirelessRadios = {};
let _wirelessHostapd = {};
const _protocols = {};
const _protospecs = {};
@ -352,6 +356,55 @@ function maskToPrefix(mask, v6) {
return bits;
}
function refreshWirelessState() {
if (_wirelessInit != null)
return _wirelessInit;
const wifiDevices = uci.sections('wireless', 'wifi-device');
const qcaOnly = wifiDevices.length > 0 && wifiDevices.every(function(device) {
return (device.type == 'qcawifi' || device.type == 'qcawificfg80211');
});
if (qcaOnly)
return Promise.resolve();
_wirelessInit = L.resolveDefault(callLuciWirelessDevices(), {}).then(function(radios) {
const objects = [];
_wirelessRadios = L.isObject(radios) ? radios : {};
for (let radio in _wirelessRadios)
if (L.isObject(_wirelessRadios[radio]) && Array.isArray(_wirelessRadios[radio].interfaces))
for (let ri of _wirelessRadios[radio].interfaces)
if (L.isObject(ri) && ri.ifname)
objects.push('hostapd.%s'.format(ri.ifname));
return (objects.length ? L.resolveDefault(rpc.list.apply(rpc, objects), {}) : Promise.resolve({}));
}).then(function(res) {
const hostapd = {};
for (let k in res) {
const m = k.match(/^hostapd\.(.+)$/);
if (m)
hostapd[m[1]] = res[k];
}
_wirelessHostapd = hostapd;
if (_state != null) {
_state.radios = _wirelessRadios;
_state.hostapd = _wirelessHostapd;
}
_wirelessInit = null;
}).catch(function() {
_wirelessInit = null;
});
return _wirelessInit;
}
function initNetworkState(refresh) {
if (_state == null || refresh) {
const hasWifi = L.hasSystemFeature('wifi');
@ -361,18 +414,17 @@ function initNetworkState(refresh) {
L.resolveDefault(callNetworkInterfaceDump(), []),
L.resolveDefault(callLuciBoardJSON(), {}),
L.resolveDefault(callLuciNetworkDevices(), {}),
L.resolveDefault(callLuciWirelessDevices(), {}),
L.resolveDefault(callLuciHostHints(), {}),
getProtocolHandlers(),
L.resolveDefault(uci.load('network')),
hasWifi ? L.resolveDefault(uci.load('wireless')) : L.resolveDefault(),
L.resolveDefault(uci.load('luci'))
]).then(function([netifd_ifaces, board_json, luci_devs, radios, hosts]) {
]).then(function([netifd_ifaces, board_json, luci_devs, hosts]) {
const s = {
isTunnel: {}, isBridge: {}, isSwitch: {}, isWifi: {},
ifaces: netifd_ifaces, radios: radios, hosts: hosts,
netdevs: {}, bridges: {}, switches: {}, hostapd: {}
ifaces: netifd_ifaces, radios: _wirelessRadios, hosts: hosts,
netdevs: {}, bridges: {}, switches: {}, hostapd: _wirelessHostapd
};
for (let name in luci_devs) {
@ -515,25 +567,9 @@ function initNetworkState(refresh) {
}
_init = null;
_state = s;
const objects = [];
if (L.isObject(s.radios))
for (let radio in s.radios)
if (L.isObject(s.radios[radio]) && Array.isArray(s.radios[radio].interfaces))
for (let ri of s.radios[radio].interfaces)
if (L.isObject(ri) && ri.ifname)
objects.push('hostapd.%s'.format(ri.ifname));
return (objects.length ? L.resolveDefault(rpc.list.apply(rpc, objects), {}) : Promise.resolve({})).then(function(res) {
for (let k in res) {
const m = k.match(/^hostapd\.(.+)$/);
if (m)
s.hostapd[m[1]] = res[k];
}
return (_state = s);
});
return s;
});
} // end if (refresh || !_init)
@ -1366,6 +1402,8 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
*/
getWifiDevice(devname) {
return initNetworkState().then(L.bind(function() {
refreshWirelessState();
const existingDevice = uci.get('wireless', devname);
if (existingDevice == null || existingDevice['.type'] != 'wifi-device')
@ -1386,6 +1424,8 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
*/
getWifiDevices() {
return initNetworkState().then(L.bind(function() {
refreshWirelessState();
const uciWifiDevices = uci.sections('wireless', 'wifi-device');
const rv = [];
@ -1429,8 +1469,11 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* be found.
*/
getWifiNetwork(netname) {
return initNetworkState()
.then(L.bind(this.lookupWifiNetwork, this, netname));
return initNetworkState().then(L.bind(function() {
refreshWirelessState();
return this.lookupWifiNetwork(netname);
}, this));
},
/**
@ -1444,6 +1487,8 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
*/
getWifiNetworks() {
return initNetworkState().then(L.bind(function() {
refreshWirelessState();
const wifiIfaces = uci.sections('wireless', 'wifi-iface');
const rv = [];

View File

@ -42,6 +42,10 @@ let cachedIwinfoResolver = null;
let cachedIwinfoResolverPromise = null;
let cachedAssocLists = Object.create(null);
let pendingAssocLists = Object.create(null);
let qcaRuntimeProbeHoldoffUntil = 0;
const qcaRuntimeProbeHoldoffKey = 'luci-qca-wireless-runtime-holdoff';
const qcaRuntimeProbeHoldoff = 30000;
function pushUnique(list, value) {
if (value && list.indexOf(value) < 0)
@ -286,6 +290,9 @@ function refreshIwinfoInfoMap() {
}
function startIwinfoInfoRefresh() {
if (areQcaRuntimeProbesSuspended())
return;
refreshIwinfoInfoMap().catch(() => {});
}
@ -300,6 +307,29 @@ function isQcaWifiHwtype(hwtype) {
return (hwtype == 'qcawifi' || hwtype == 'qcawificfg80211');
}
function suspendQcaRuntimeProbes() {
qcaRuntimeProbeHoldoffUntil = Date.now() + qcaRuntimeProbeHoldoff;
try {
window.sessionStorage.setItem(qcaRuntimeProbeHoldoffKey, String(qcaRuntimeProbeHoldoffUntil));
}
catch (e) {}
}
function areQcaRuntimeProbesSuspended() {
let deadline = qcaRuntimeProbeHoldoffUntil;
try {
deadline = Math.max(deadline, +window.sessionStorage.getItem(qcaRuntimeProbeHoldoffKey) || 0);
if (deadline <= Date.now())
window.sessionStorage.removeItem(qcaRuntimeProbeHoldoffKey);
}
catch (e) {}
return (deadline > Date.now());
}
function isConfigWifiDeviceDisabled(deviceName) {
return (uci.get('wireless', deviceName, 'disabled') == '1');
}
@ -1146,6 +1176,10 @@ function getAssocListForNetwork(radioNet) {
return Promise.resolve([]);
const hwtype = uci.get('wireless', radioNet.getWifiDeviceName(), 'type');
if (isQcaWifiHwtype(hwtype) && areQcaRuntimeProbesSuspended())
return Promise.resolve([]);
const candidates = getAssocListCandidates(radioNet);
const resolvedIfname = candidates[0];
@ -1464,9 +1498,13 @@ function radio_restart(id, ev) {
function network_updown(id, map, ev) {
const radio = uci.get('wireless', id, 'device');
const hwtype = uci.get('wireless', radio, 'type');
const disabled = (uci.get('wireless', id, 'disabled') == '1') ||
(uci.get('wireless', radio, 'disabled') == '1');
if (isQcaWifiHwtype(hwtype))
suspendQcaRuntimeProbes();
if (disabled) {
uci.unset('wireless', id, 'disabled');
uci.unset('wireless', radio, 'disabled');
@ -1548,14 +1586,19 @@ var CBIWifiFrequencyValue = form.Value.extend({
const chval = +cfg_channel;
const allow_auto = (hwtype == 'mt_dbdc' || isQcaWifiHwtype(hwtype) ||
cfg_channel == 'auto' || L.hasSystemFeature('hostapd', 'acs'));
const configOnly = isConfigWifiDeviceDisabled(device_section) ||
(isQcaWifiHwtype(hwtype) && areQcaRuntimeProbesSuspended());
const configDevice = configOnly
? network.getWifiDevicesFromConfig().find((device) => device.getName() == device_section)
: null;
return Promise.all([
network.getWifiDevice(device_section),
this.callFrequencyList(device_section),
this.callDeviceInfo(device_section),
L.resolveDefault(this.callWirelessStatus(), {}),
isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'dev' ]), '') : '',
isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'phy' ]), '') : ''
configOnly ? configDevice : network.getWifiDevice(device_section),
configOnly ? [] : this.callFrequencyList(device_section),
configOnly ? {} : this.callDeviceInfo(device_section),
configOnly ? {} : L.resolveDefault(this.callWirelessStatus(), {}),
!configOnly && isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'dev' ]), '') : '',
!configOnly && isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'phy' ]), '') : ''
]).then(L.bind(function(data) {
const wifidevs = data[0];
const freqlist = data[1];
@ -1613,6 +1656,10 @@ var CBIWifiFrequencyValue = form.Value.extend({
}
const configured_band = getConfiguredBand(hwtype, statuscfg.hwmode ?? hwval, devcfg.channel ?? cfg_channel, devcfg.band ?? bandval, statuscfg.htmode ?? htval);
if (configOnly && cfg_channel && cfg_channel != 'auto' && Array.isArray(this.channels[configured_band]))
this.channels[configured_band].push(cfg_channel, String(cfg_channel), { available: true });
const has_band_channels = (band) => {
const channels = this.channels[band];
const offset = (channels?.[0] == 'auto') ? 3 : 0;
@ -2228,8 +2275,10 @@ var CBIWifiTxPowerValue = form.ListValue.extend({
load: function(section_id) {
const device_section = this.getDeviceSection ? this.getDeviceSection(section_id) : section_id;
const hwtype = uci.get('wireless', device_section, 'type');
if (isConfigWifiDeviceDisabled(device_section)) {
if (isConfigWifiDeviceDisabled(device_section) ||
(isQcaWifiHwtype(hwtype) && areQcaRuntimeProbesSuspended())) {
this.powerval = this.wifiNetwork ? getDisplayTxPower(this.wifiNetwork) : null;
this.poweroff = this.wifiNetwork ? this.wifiNetwork.getTXPowerOffset() : null;
this.value('', _('driver default'));

View File

@ -1,5 +1,9 @@
/* The bar carries no colour transition: one would ease its bg/text over
--mega-menu-duration on every data-darkmode flip, lagging the bar behind
the untransitioned page in all three nav modes. The mega-menu wipe colour
lives on .desktop-menu-sheet below, which owns its transition. */
header {
@apply bg-bg sticky top-0 z-40 mb-2 transition-colors duration-(--mega-menu-duration,300ms) ease-[cubic-bezier(0.4,0,0.6,1)];
@apply bg-bg sticky top-0 z-40 mb-2;
& .header-content {
@apply relative z-10 flex h-14 items-center justify-between px-6 py-3 max-md:px-4 max-md:py-2;
@ -266,9 +270,11 @@ header {
@apply md:flex;
}
/* D1 底线呼吸展开态沿用短底线汉堡折叠后底线伸满三线齐整 =
侧栏收纳完毕dasharray 只改可见长度线帽形状不受影响 */
[data-nav-type="sidebar"].sidebar-collapsed
/* D1 底线呼吸2026-07 反转展开后底线伸满 三线齐整 = 内容
全部亮出折叠态回落到短底线 = 还有内容收着缺口招手可展开
与耦合滑动同向侧栏进场线补齐退场线让位dasharray 只改可见
长度线帽形状不受影响 */
[data-nav-type="sidebar"]:not(.sidebar-collapsed)
&
.navigation-toggle-line-bottom {
@apply md:[stroke-dasharray:15_15];
@ -284,6 +290,12 @@ header {
& .navigation-toggle-line {
transform-box: fill-box;
@apply origin-center transition-[transform,opacity,stroke-dasharray] duration-150 ease-in-out;
/* Desktop sidebar: the dasharray breath runs in step with the
250ms coupled slide (the mobile X morph keeps its snappy 150). */
[data-nav-type="sidebar"] & {
@apply md:duration-[250ms];
}
}
/* Tabler menu-2 geometry: all three lines are full-length equals.
@ -356,8 +368,11 @@ body[data-nav-type="sidebar"] {
overflow-x-clip keeps the sliding sidebar out of horizontal overflow. */
/* Single source of truth for the sidebar column, so collapsing only has to
zero this one value instead of restating the grid template. */
--sidebar-w: 17rem;
zero this one value instead of restating the grid template.
--sidebar-w-full stays constant: the inner panel width and the slide
keyframes read it, and must not see the zeroed collapsed value. */
--sidebar-w-full: 17rem;
--sidebar-w: var(--sidebar-w-full);
@apply md:grid md:grid-cols-[var(--sidebar-w)_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip;
@ -410,29 +425,60 @@ body[data-nav-type="sidebar"] {
}
& .sidebar-panel {
@apply border-hairline sticky top-0 hidden h-dvh w-full overflow-hidden rounded-none border-r transition-[visibility] duration-[250ms] md:block;
@apply sticky top-0 hidden h-dvh w-full overflow-hidden rounded-none md:block;
}
/* The grid column snaps closed to avoid a full-page layout animation.
Visibility removes collapsed controls from the tab order; on expansion,
the inner panel still enters via compositor-only translate + opacity. */
/* The grid column still snaps (one reflow, never a per-frame layout
animation); visibility removes collapsed controls from the tab order.
The hairline lives on the inner panel, not the frame, so it tracks the
sliding edge during the run animations below. */
&.sidebar-collapsed .sidebar-panel {
@apply invisible;
}
& .sidebar-panel-inner {
@apply bg-bg flex h-full w-68 flex-col overflow-hidden transition-[translate,opacity] duration-[250ms];
@apply bg-bg border-hairline flex h-full w-(--sidebar-w-full) flex-col overflow-hidden border-r;
}
&.sidebar-collapsed .sidebar-panel-inner {
@apply -translate-x-full opacity-0;
@apply -translate-x-full;
}
/* Coupled slide: the column width snaps in the same frame the
sidebar-anim-* class lands, then the sidebar and the whole content
column (header + #maincontent) slide the same 17rem with the same
curve compositor-only, so the seam moves as one wall with zero
per-frame reflow. menu-aurora.js swaps the class per direction
(distinct animation-names restart cleanly on rapid toggles) and
clears it after the run. */
&.sidebar-anim-open > header,
&.sidebar-anim-open > #maincontent,
&.sidebar-anim-open .sidebar-panel-inner {
@apply md:animate-[sidebar-run-in_250ms_var(--ease-in-out)_both];
}
&.sidebar-anim-close > header,
&.sidebar-anim-close > #maincontent {
@apply md:animate-[sidebar-run-back_250ms_var(--ease-in-out)_both];
}
/* Closing: the column is already zero, so the panel holds its old spot
as a fixed overlay for one run while its content slides off. */
&.sidebar-anim-close .sidebar-panel {
@apply md:visible md:fixed md:top-0 md:left-0 md:w-(--sidebar-w-full);
}
&.sidebar-anim-close .sidebar-panel-inner {
@apply md:animate-[sidebar-run-out_250ms_var(--ease-in-out)_both];
}
/* Sidebar head: logo + hostname. h-14 matches the header row so its
bottom hairline continues the header's border in one unbroken line
across the viewport. */
across the viewport. box-content: the header is auto-height (56px
content + border OUTSIDE), so this border must sit outside its 56px
too border-box would paint it one pixel higher than the header's. */
& .sidebar-head {
@apply border-hairline flex h-14 shrink-0 items-center gap-2.5 border-b px-4;
@apply border-hairline box-content flex h-14 shrink-0 items-center gap-2.5 border-b px-4;
& .sidebar-logo {
@apply size-5 shrink-0 rounded-md;
@ -475,6 +521,26 @@ body[data-nav-type="sidebar"] {
}
}
/* Shared travel distance for the coupled sidebar slide: content column and
panel move the same 17rem, so the seam between them never separates. */
@keyframes sidebar-run-in {
from {
translate: calc(-1 * var(--sidebar-w-full)) 0;
}
}
@keyframes sidebar-run-back {
from {
translate: var(--sidebar-w-full) 0;
}
}
@keyframes sidebar-run-out {
to {
translate: calc(-1 * var(--sidebar-w-full)) 0;
}
}
#maincontent {
/* Centred shells (top-bar navigation) clamp here and gutter the remainder.
The sidebar shell overrides this it caps the shell instead, see

View File

@ -33,7 +33,9 @@
}
.navigation-group-toggle {
@apply after:size-3.5 after:shrink-0 after:bg-current after:opacity-55 after:transition-[transform,opacity] after:duration-[250ms] after:content-[''] after:[mask:var(--icon-arrow-right)_center/cover_no-repeat] hover:after:opacity-100;
/* size-4.5 the row's text height 3.5 read as undersized next to the
text-lg category labels, on desktop and in the mobile drawer alike. */
@apply after:size-4.5 after:shrink-0 after:bg-current after:opacity-55 after:transition-[transform,opacity] after:duration-[250ms] after:content-[''] after:[mask:var(--icon-arrow-right)_center/cover_no-repeat] hover:after:opacity-100;
}
.nav-category-label {

View File

@ -47,7 +47,7 @@
}
.cmdk-panel {
@apply border-hairline bg-surface/85 fixed top-24 left-1/2 z-120 w-[min(36rem,calc(100vw-2.5rem))] -translate-x-1/2 overflow-hidden rounded-3xl border shadow-xl backdrop-blur-xl backdrop-saturate-150 transition-[opacity,translate] duration-150 ease-out starting:-translate-y-2 starting:opacity-0;
@apply border-hairline bg-surface/85 fixed top-24 left-1/2 z-120 w-[min(36rem,calc(100vw-2.5rem))] -translate-x-1/2 overflow-hidden rounded-2xl border shadow-xl backdrop-blur-xl backdrop-saturate-150 transition-[opacity,translate] duration-150 ease-out starting:-translate-y-2 starting:opacity-0;
/* <md: the same panel becomes a full-screen takeover (Algolia DocSearch
manner) opaque page background (no full-screen blur cost on phones),

View File

@ -30,6 +30,7 @@ return baseclass.extend({
const mobileList = overlay.querySelector("#mobile-nav-list");
const desktop = window.matchMedia("(min-width: 768px)");
const SIDEBAR_COLLAPSED_KEY = "aurora.sidebarCollapsed";
let sidebarAnimTimer;
const isDesktopSidebar = () =>
desktop.matches && document.body.dataset.navType === "sidebar";
@ -79,7 +80,23 @@ return baseclass.extend({
closeMobileNavigation();
const collapsed = !expanded;
document.body.classList.toggle("sidebar-collapsed", collapsed);
const body = document.body;
// Coupled slide (_layout.css): the class must land in the same
// frame as the column snap. Open/close carry distinct
// animation-names, so alternating toggles restart the run without
// a forced reflow; the timer (not animationend — three elements
// animate) clears the class once the 250ms run is over.
body.classList.remove("sidebar-anim-open", "sidebar-anim-close");
body.classList.add(
collapsed ? "sidebar-anim-close" : "sidebar-anim-open",
);
clearTimeout(sidebarAnimTimer);
sidebarAnimTimer = setTimeout(() => {
body.classList.remove("sidebar-anim-open", "sidebar-anim-close");
}, 300);
body.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
updateToggleState(expanded);
return;
@ -488,7 +505,7 @@ return baseclass.extend({
this.bindNavigationAccordion(list);
crumb.forEach((title, i) => {
if (i) crumbEl?.appendChild(E("li", { class: "crumb-sep" }, [""]));
if (i) crumbEl?.appendChild(E("li", { class: "crumb-sep" }, ["/"]));
crumbEl?.appendChild(
E("li", { class: i === crumb.length - 1 ? "current" : "" }, [title]),
);
@ -533,12 +550,14 @@ return baseclass.extend({
// index — matched and rendered like pages, grouped under _("Design")
// (the System → Language and Style label) — but execute header.ut's
// global setTheme() instead of navigating, so the panel stays open and
// previews the switch live. luci-base has no Light/Dark msgids — they
// stay English literals, wrapped in _() so a future catalog entry would
// take effect (the same trade the icon-only switcher makes).
// previews the switch live. luci-base carries no mode msgids, so the
// labels borrow luci-app-aurora-config's already-translated "Light
// Mode"/"Dark Mode" — matching its msgids verbatim is what makes the
// palette follow the UI language wherever the config app is installed,
// and they fall back to English where it isn't.
[
["light", _("Light")],
["dark", _("Dark")],
["light", _("Light Mode")],
["dark", _("Dark Mode")],
["device", _("Automatic")],
].forEach(([mode, title]) =>
this.paletteIndex.push({
@ -549,8 +568,9 @@ return baseclass.extend({
}),
);
// Only msgids that already exist in the luci-base catalog are used —
// the theme intentionally ships no translations of its own.
// Only msgids that already exist in a shipped catalog (luci-base, or the
// config app's as above) are used — the theme intentionally ships no
// translations of its own.
const isMac = /Mac|iP(ad|hone|od)/.test(navigator.platform);
this.paletteKey = isMac ? "⌘K" : "Ctrl+K";
toggle.setAttribute("aria-keyshortcuts", isMac ? "Meta+K" : "Control+K");
@ -1039,8 +1059,9 @@ return baseclass.extend({
);
}
// Both vars live on the header, not the container: the container
// inherits the height, and the header's own transition-colors reads
// the duration so the bar colour fades in lockstep with the wipe.
// inherits both, and the close fallback timer reads the duration
// back off the header (the bar itself must stay transition-free so
// theme flips repaint it in the same frame as the page).
header.style.setProperty("--mega-menu-height", `${canvasHeight}px`);
header.style.setProperty("--mega-menu-duration", `${revealDuration}ms`);
};

View File

@ -729,7 +729,7 @@ test("renders sidebar items, logout, and translated crumbs without duplication",
assert.equal(crumb.children.length, 3);
assert.deepEqual(
crumb.children.map((child) => textContent(child)),
["translated:Network", "", "translated:Wireless"],
["translated:Network", "/", "translated:Wireless"],
);
assert.equal(crumb.children[2].getAttribute("class"), "current");
assert.equal(list.dataset.accordionBound, "true");

View File

@ -83,7 +83,7 @@ test("shared navigation styles define active and expanded states", () => {
assertIncludesUtilities(sublink, [
"font-medium",
"hover:bg-hover-faint",
"rounded-lg",
"rounded-xl",
]);
assertIncludesUtilities(activeSublink, [
"text-brand",
@ -212,6 +212,20 @@ test("mega-menu reveal and retract share the page-top origin", () => {
assert.doesNotMatch(headerLift ?? "", /bg-mega-menu-bg/);
});
test("theme flips repaint the bar in the same frame as the page", () => {
const headerDeclaration = layoutStyles.match(
/^header \{\s*@apply ([^;]+);/m,
)?.[1];
assert.ok(headerDeclaration, "Missing header root declaration");
assertIncludesUtilities(headerDeclaration, ["bg-bg", "sticky"]);
// A colour transition on the bar itself would ease its bg/text over
// --mega-menu-duration on every data-darkmode flip, lagging the bar behind
// the untransitioned page in all three nav modes. The mega-menu wipe colour
// lives on .desktop-menu-sheet, which carries its own transition.
assert.doesNotMatch(headerDeclaration, /\btransition/);
});
test("mega-menu category masks use Tailwind arbitrary utilities", () => {
const title = getBlock(layoutStyles, "& .desktop-nav-title");
const icon = getBlock(title, "&::before");

View File

@ -167,7 +167,8 @@ test("content dropdowns stay above the closed header and below the open mega-men
/\[data-nav-type="mega-menu"\]\s*&:has\([\s\S]*?\.desktop-menu-container[\s\S]*?\.active[\s\S]*?\)\s*\{\s*@apply\s+([^;]+);/,
)?.[1] ?? "";
const dropdownRule = dropdown.match(/&\.dropdown\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
const messageRule = message.match(/\.alert-message\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";
const messageRule =
message.match(/\.alert-message\s*\{[\s\S]*?@apply\s+([^;]+);/)?.[1] ?? "";
const overlayRule =
overlay.match(/& \.desktop-menu-overlay\s*\{\s*@apply\s+([^;]+);/)?.[1] ?? "";

View File

@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.4
PKG_RELEASE:=58
PKG_VERSION:=1.1.6
PKG_RELEASE:=59
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long