update 2026-10-23 00:28:40

This commit is contained in:
action committed 2026-10-23 00:28:40 +08:00
1 parent 3f38c69f75
commit d64c67f0ed
319 files changed
+109661 -2193

No files matched your search

-46
View File
@@ -1,46 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2009-2013 OpenWrt.org
#
include $(TOPDIR)/rules.mk
PKG_NAME:=BaiduPCS-Go
PKG_VERSION:=3.9.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qjfoidnh/BaiduPCS-Go/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=de71666e961a3eeb90f8aca7b11934c3f0296d1870e036a7c086ba1decfca8ab
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
GO_PKG:=github.com/qjfoidnh/BaiduPCS-Go
GO_PKG_LDFLAGS_X:=main.Version=v$(PKG_VERSION)
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/BaiduPCS-Go
SECTION:=net
CATEGORY:=Network
SUBMENU:=Download Manager
TITLE:=Terminal utility for Baidu Network Disk
URL:=https://github.com/qjfoidnh/BaiduPCS-Go
DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle
endef
define Package/BaiduPCS-Go/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/bin/
$(CP) $(PKG_INSTALL_DIR)/usr/bin/BaiduPCS-Go $(1)/usr/bin/
endef
$(eval $(call GoBinPackage,BaiduPCS-Go))
$(eval $(call BuildPackage,BaiduPCS-Go))
-85
View File
@@ -1,85 +0,0 @@
#
# Copyright (C) 2019 OpenWrt.org
#
# KFERMercer <KFER.Mercer@gmail.com>
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=baidupcs-web
PKG_VERSION:=3.7.1
PKG_RELEASE:=2
include $(INCLUDE_DIR)/package.mk
define Package/baidupcs-web
SECTION:=net
CATEGORY:=Network
TITLE:=BaiduPCS-Web is a web controller for BaiduPCS-Go
URL:=https://github.com/Erope/baidupcs-web
endef
define Package/$(PKG_NAME)/description
BaiduPCS-Web is a web controller for BaiduPCS-Go
endef
# 禁止OpenWrt对二进制文件进行strip
STRIP:=true
ifeq ($(ARCH),i386)
PKG_ARCH_BAIDUPCS-WEB:=386
endif
ifeq ($(ARCH),x86_64)
PKG_ARCH_BAIDUPCS-WEB:=amd64
endif
ifeq ($(ARCH),mipsel)
PKG_ARCH_BAIDUPCS-WEB:=mipsle
endif
ifeq ($(ARCH),mips)
PKG_ARCH_BAIDUPCS-WEB:=mipsle
endif
ifeq ($(ARCH),arm)
ifeq ($(BOARD),bcm53xx)
PKG_ARCH_BAIDUPCS-WEB:=armv5
else ifeq ($(BOARD),kirkwood)
PKG_ARCH_BAIDUPCS-WEB:=armv5
else
PKG_ARCH_BAIDUPCS-WEB:=armv7
endif
endif
ifeq ($(ARCH),aarch64)
PKG_ARCH_BAIDUPCS-WEB:=arm64
endif
PKG_SOURCE:=BaiduPCS-Go-v$(PKG_VERSION)-linux-$(PKG_ARCH_BAIDUPCS-WEB).zip
PKG_SOURCE_URL:=https://github.com/coolsnowwolf/baidupcs-web/raw/master/
UNZIP_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)/$(PKG_NAME)-unzip
PKG_HASH:=skip
define Build/Prepare
mkdir -vp $(UNZIP_DIR)
unzip -od $(UNZIP_DIR) $(DL_DIR)/$(PKG_SOURCE)
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/baidupcs-web/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(UNZIP_DIR)/*/BaiduPCS-Go $(1)/usr/bin/baidupcs-web
chmod 755 $(1)/usr/bin/baidupcs-web
endef
$(eval $(call BuildPackage,baidupcs-web))
-70
View File
@@ -1,70 +0,0 @@
#
# Copyright (C) 2009-2012 OpenWrt.org
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=libcryptopp
PKG_VERSION:=5.6.4
PKG_RELEASE:=2
PKG_SOURCE:=cryptopp$(subst .,,$(PKG_VERSION)).zip
PKG_SOURCE_URL:=http://www.cryptopp.com/
PKG_MD5SUM:=4ee7e5cdd4a45a14756c169eaf2a77fc
PKG_USE_NINJA:=0
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)/$(PKG_NAME)-$(PKG_VERSION)
CMAKE_INSTALL:=1
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
define Package/libcryptopp
SECTION:=libs
CATEGORY:=Libraries
DEPENDS:=+libpthread +libstdcpp
TITLE:=Free C++ class library of cryptographic schemes
URL:=http://packages.debian.org/sid/libcrypto++-dev
endef
define Package/libcryptopp/description
Crypto++ is library for creating C++ programs which use cryptographic algorithms.
The library uses a Pipes & Filters architecture with heavy use of templates and
abstract base classes.
endef
#CONFIGURE_ARGS += \
# --enable-static \
# --enable-shared
#TARGET_LDFLAGS += \
# -lpthread
CMAKE_OPTIONS += -DCMAKE_CXX_FLAGS="$(FPIC)"
CMAKE_OPTIONS += -DBUILD_STATIC=ON
CMAKE_OPTIONS += -DBUILD_SHARED=ON
UNPACK_CMD=cd $(PKG_BUILD_DIR) && unzip "$(DL_DIR)/$(PKG_SOURCE)"
define Build/Prepare
$(Build/Prepare/Default)
cd $(PKG_BUILD_DIR) && rm -f GNUmakefile GNUmakefile-cross
endef
define Build/InstallDev
$(INSTALL_DIR) $(1)/usr/include/cryptopp
$(CP) $(PKG_INSTALL_DIR)/usr/include/cryptopp/* $(1)/usr/include/cryptopp/
$(INSTALL_DIR) $(1)/usr/lib
$(CP) $(PKG_INSTALL_DIR)/usr/lib/libcryptopp.{a,so*} $(1)/usr/lib/
endef
define Package/libcryptopp/install
$(INSTALL_DIR) $(1)/usr/lib
$(CP) $(PKG_INSTALL_DIR)/usr/lib/libcryptopp.so* $(1)/usr/lib/
endef
$(eval $(call BuildPackage,libcryptopp))
@@ -1,22 +0,0 @@
#
# <https://github.com/KFERMercer/luci-app-baidupcs-web>
#
# Copyright (C) 2020 KFERMercer <KFER.Mercer@gmail.com>
# Copyright (C) 2020 [CTCGFW] Project OpenWRT
#
# THIS IS FREE SOFTWARE, LICENSED UNDER GPLv3.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-baidupcs-web
LUCI_TITLE:=LuCI support for BaiduPCS-Go
LUCI_DEPENDS:=+baidupcs-web +wget +openssl-util
LUCI_PKGARCH:=all
PKG_VERSION:=1.0
PKG_RELEASE:=6
PKG_MAINTAINER:=<https://github.com/KFERMercer/luci-app-baidupcs-web>
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -1,18 +0,0 @@
module("luci.controller.baidupcs-web", package.seeall)
function index()
if not nixio.fs.access("/etc/config/baidupcs-web") then
return
end
entry({"admin", "nas", "baidupcs-web"}, cbi("baidupcs-web"), _("BaiduPCS Web"), 300).dependent = true
entry({"admin", "nas", "baidupcs-web", "status"}, call("act_status")).leaf = true
end
function act_status()
local e = {}
e.running=luci.sys.call("pgrep baidupcs-web >/dev/null") == 0
-- e.port = luci.sys.exec("uci get baidupcs-web.config.port")
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end
@@ -1,48 +0,0 @@
--[[
Copyright (C) 2020 KFERMercer <KFER.Mercer@gmail.com>
Copyright (C) 2020 [CTCGFW] Project OpenWRT
THIS IS FREE SOFTWARE, LICENSED UNDER GPLv3
]]--
m = Map("baidupcs-web")
m.title = translate("BaiduPCS-Web")
m.description = translate("基于BaiduPCS-Go, 可以让你高效的使用百度云")
m:section(SimpleSection).template = "baidupcs-web/baidupcs-web_status"
s = m:section(TypedSection, "baidupcs-web")
s.addremove = false
s.anonymous = true
enable = s:option(Flag, "enabled", translate("启用"))
enable.rmempty = false
o = s:option(Value, "port", translate("网页端口"))
o.datatype = "port"
o.placeholder = "5299"
o.default = "5299"
o.rmempty = false
o = s:option(Value, "download_dir", translate("下载目录"))
o.placeholder = "/opt/baidupcsweb-download"
o.default = "/opt/baidupcsweb-download"
o.rmempty = false
o = s:option(Value, "max_download_rate", translate("最大下载速度"))
o.placeholder = "0"
o = s:option(Value, "max_upload_rate", translate("最大上传速度"))
o.placeholder = "0"
o.description = translate("0代表不限制, 单位为每秒的传输速率, 后缀'/s' 可省略, 如 2MB/s, 2MB, 2m, 2mb 均为一个意思")
o = s:option(Value, "max_download_load", translate("同时进行下载文件的最大数量"))
o.placeholder = "1"
o.description = translate("不要太贪心, 当心被封号")
o = s:option(Value, "max_parallel", translate("最大并发连接数"))
o.placeholder = "8"
return m
@@ -1,38 +0,0 @@
<!--
Copyright (C) 2020 KFERMercer <KFER.Mercer@gmail.com>
THIS IS FREE SOFTWARE, LICENSED UNDER GPLv3.
-->
<script type="text/javascript">//<![CDATA[
XHR.poll(3, '<%=url([[admin]], [[nas]], [[baidupcs-web]], [[status]])%>', null,
function(x, data) {
var tb = document.getElementById('baidupcs-web_status');
if (data && tb) {
if (data.running) {
var links = '<em><b><font color=green>BaiduPCS-Web <%:RUNNING%></font></b></em><input class="cbi-button mar-10" type="button" value="<%:打开管理页面%>" onclick="openClient();" />';
tb.innerHTML = links;
} else {
tb.innerHTML = '<em><b><font color=red>BaiduPCS-Web <%:NOT RUNNING%></font></b></em>';
}
}
}
);
function openClient() {
var curWwwPath = window.document.location.href;
var pathName = window.document.location.pathname;
var pos = curWwwPath.indexOf(pathName);
var localhostPath = curWwwPath.substring(0, pos);
var clientPort = window.document.getElementById("cbid.baidupcs-web.config.port").value
var url = localhostPath + ":" + clientPort;
window.open(url)
};
//]]>
</script>
<style>.mar-10 {margin-left: 50px; margin-right: 10px;}</style>
<fieldset class="cbi-section">
<p id="baidupcs-web_status">
<em><%:Collecting data...%></em>
</p>
</fieldset>
@@ -1,2 +0,0 @@
config baidupcs-web 'config'
@@ -1,34 +0,0 @@
#!/bin/sh /etc/rc.common
START=90
STOP=10
BaiduPCSWeb="/usr/bin/baidupcs-web"
enabled="$(uci -q get baidupcs-web.config.enabled)"
port="$(uci -q get baidupcs-web.config.port)"
download_dir="$(uci -q get baidupcs-web.config.download_dir)"
max_download_rate="$(uci -q get baidupcs-web.config.max_download_rate || echo '0')"
max_upload_rate="$(uci -q get baidupcs-web.config.max_upload_rate || echo '0')"
max_download_load="$(uci -q get baidupcs-web.config.max_download_load || echo '1')"
max_parallel="$(uci -q get baidupcs-web.config.max_parallel || echo '8')"
start() {
stop
[ "$enabled" == "1" ] || exit 0
mkdir -p "${download_dir}"
"${BaiduPCSWeb}" config set \
--savedir "${download_dir}" \
--max_download_rate "${max_download_rate}" \
--max_upload_rate "${max_upload_rate}" \
--max_download_load "${max_download_load}" \
--max_parallel "${max_parallel}" \
--max_upload_parallel "${max_parallel}" \
>/dev/null 2>&1
"${BaiduPCSWeb}" web --port "${port}" --access >/dev/null 2>&1 &
}
stop() {
killall "${BaiduPCSWeb}" >/dev/null 2>&1
}
@@ -1,11 +0,0 @@
{
"luci-app-baidupcs-web": {
"description": "Grant UCI access for luci-app-baidupcs-web",
"read": {
"uci": [ "baidupcs-web" ]
},
"write": {
"uci": [ "baidupcs-web" ]
}
}
}
@@ -1,4 +0,0 @@
{
"config": "baidupcs-web",
"init": "baidupcs-web"
}
-17
View File
@@ -1,17 +0,0 @@
#
# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Nps
LUCI_DEPENDS:=+wget +npc
LUCI_PKGARCH:=all
PKG_VERSION:=1.1
PKG_RELEASE:=6
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -1,17 +0,0 @@
module("luci.controller.nps",package.seeall)
function index()
if not nixio.fs.access("/etc/config/nps") then
return
end
entry({"admin", "services", "nps"}, cbi("nps"), _("Nps Penetrate"), 100).dependent = true
entry({"admin", "services", "nps", "status"}, call("act_status")).leaf = true
end
function act_status()
local e = {}
e.running = luci.sys.call("pgrep npc > /dev/null") == 0
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end
@@ -1,54 +0,0 @@
m = Map("nps")
m.title = translate("Nps Penetrate")
m.description = translate("Nps is a fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet.")
m:section(SimpleSection).template = "nps/nps_status"
s = m:section(TypedSection, "nps")
s.addremove = false
s.anonymous = true
enable = s:option(Flag, "enabled", translate("Enable"))
enable.rmempty = false
server = s:option(Value, "server_addr", translate("Server"))
server.description = translate("Must an IPv4 address")
server.datatype = "ipaddr"
server.optional = false
server.rmempty = false
port = s:option(Value, "server_port", translate("Port"))
port.datatype = "port"
port.default = "8024"
port.optional = false
port.rmempty = false
protocol = s:option(ListValue, "protocol", translate("Protocol Type"))
protocol:value("tcp", translate("TCP Protocol"))
protocol:value("kcp", translate("KCP Protocol"))
protocol.default = "tcp"
vkey = s:option(Value, "vkey", translate("vkey"))
vkey.optional = false
vkey.password = true
vkey.rmempty = false
compress = s:option(Flag, "compress", translate("Enable Compression"))
compress.description = translate("The contents will be compressed to speed up the traffic forwarding speed, but this will consume some additional cpu resources.")
compress.default = "1"
compress.rmempty = false
crypt = s:option(Flag, "crypt", translate("Enable Encryption"))
crypt.description = translate("Encrypted the communication between Npc and Nps, will effectively prevent the traffic intercepted.")
crypt.default = "1"
crypt.rmempty = false
log_level = s:option(ListValue, "log_level", translate("Log Level"))
log_level:value("0", "Emergency")
log_level:value("2", "Critical")
log_level:value("3", "Error")
log_level:value("4", "Warning")
log_level:value("7", "Debug")
log_level.default = "3"
return m
@@ -1,22 +0,0 @@
<script type="text/javascript">//<![CDATA[
XHR.poll(3, '<%=url([[admin]], [[services]], [[nps]], [[status]])%>', null,
function(x, data) {
var tb = document.getElementById('nps_status');
if (data && tb) {
if (data.running) {
var links = "<em><b><font color=green>Nps <%:RUNNING%></font></b></em>";
tb.innerHTML = links;
} else {
tb.innerHTML = "<em><b><font color=red>Nps <%:NOT RUNNING%></font></b></em>";
}
}
}
);
//]]>
</script>
<style>.mar-10 {margin-left: 50px; margin-right: 10px;}</style>
<fieldset class="cbi-section">
<p id="nps_status">
<em><%:Collecting data...%></em>
</p>
</fieldset>
@@ -1,47 +0,0 @@
msgid "Nps Penetrate"
msgstr "Nps 内网穿透"
msgid "Nps is a fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet."
msgstr "Nps 是一种快速反向代理,可帮助您将 NAT 或防火墙后面的本地服务器公开到 Internet。"
msgid "Must an IPv4 address"
msgstr "必须是 IPv4 地址"
msgid "vkey"
msgstr "密钥(vkey)"
msgid "Enable Compression"
msgstr "使用压缩传输"
msgid "Enable Encryption"
msgstr "使用加密传输"
msgid "The contents will be compressed to speed up the traffic forwarding speed, but this will consume some additional cpu resources."
msgstr "压缩传输内容,加快流量转发速度,会额外消耗 CPU 资源。"
msgid "Encrypted the communication between Npc and Nps, will effectively prevent the traffic intercepted."
msgstr "加密传输 npc 与 nps 之间的通信内容,会有效防止流量被拦截。"
msgid "RUNNING"
msgstr "运行中"
msgid "NOT RUNNING"
msgstr "未运行"
msgid "Basic Setting"
msgstr "基本设置"
msgid "Protocol Type"
msgstr "协议类型"
msgid "Server"
msgstr "服务端地址"
msgid "TCP Protocol"
msgstr "TCP"
msgid "KCP Protocol"
msgstr "KCP"
msgid "Log Level"
msgstr "日志级别"
-1
View File
@@ -1 +0,0 @@
zh-cn
@@ -1,11 +0,0 @@
config nps
option enabled '0'
option server_addr '1.1.1.1'
option server_port '8024'
option protocol 'tcp'
option compress '1'
option crypt '1'
option vkey 'aaa'
option log_level '4'
@@ -1,68 +0,0 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2006-2011 OpenWrt.org
START=50
USE_PROCD=1
LOGFILE="/tmp/nps.log"
tmpconf="/tmp/etc/nps.conf"
nps_header() {
local enabled server_addr server_port protocol vkey compress crypt log_level
config_get enabled $1 enabled
config_get server_addr $1 server_addr
config_get server_port $1 server_port
config_get protocol $1 protocol
config_get vkey $1 vkey
config_get compress $1 compress
config_get crypt $1 crypt
config_get log_level $1 log_level
[ -f $tmpconf ] && rm $tmpconf
echo "[common]" >$tmpconf
echo "server_addr=${server_addr}:${server_port}" >>$tmpconf
echo "conn_type=${protocol}" >>$tmpconf
echo "vkey=${vkey}" >>$tmpconf
echo "auto_reconnection=true" >>$tmpconf
nps_write_bool compress $1
nps_write_bool crypt $1
Log_level=$log_level
Enabled=$enabled
}
nps_write_bool() {
local opt="$1"
local config="$2"
local val
config_get_bool val $config "$opt"
if [ "$val" -eq 0 ]; then
echo "${opt}=false" >> $tmpconf
else
echo "${opt}=true" >> $tmpconf
fi
}
start_service() {
config_load nps
config_foreach nps_header nps
if [ "$Enabled" = "1" ];then
logger -t FRPC 'Starting Nps service'
procd_open_instance
procd_set_param command /usr/bin/npc -config=$tmpconf -log_level=$Log_level -log_path=$LOGFILE
#procd_set_param file $tmpconf
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
fi
}
service_triggers() {
procd_add_reload_trigger "nps"
}
@@ -1,11 +0,0 @@
{
"luci-app-nps": {
"description": "Grant UCI access for luci-app-nps",
"read": {
"uci": [ "nps" ]
},
"write": {
"uci": [ "nps" ]
}
}
}
@@ -1,4 +0,0 @@
{
"config": "nps",
"init": "nps"
}
+2 -19
View File
@@ -7,28 +7,11 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-qbittorrent PKG_NAME:=luci-app-qbittorrent
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=4 PKG_RELEASE:=5
LUCI_TITLE:=LuCI support for qBittorrent LUCI_TITLE:=LuCI support for qBittorrent
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
LUCI_DEPENDS:= \ LUCI_DEPENDS:=+qBittorrent-static
+PACKAGE_luci-app-qbittorrent_static:qBittorrent-static \
+PACKAGE_luci-app-qbittorrent_dynamic:qbittorrent
define Package/$(PKG_NAME)/config
choice
prompt "Build Version Selection"
default PACKAGE_luci-app-qbittorrent_dynamic
config PACKAGE_luci-app-qbittorrent_static
bool "Static Build"
depends on arm||aarch64||x86_64
config PACKAGE_luci-app-qbittorrent_dynamic
bool "Dynamic Build"
endchoice
endef
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,19 +0,0 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-xlnetacc
PKG_VERSION:=1.0.5
PKG_RELEASE:=3
PKG_MAINTAINER:=Sense <sensec@gmail.com>
LUCI_TITLE:=LuCI Support for XLNetAcc
LUCI_PKGARCH:=all
LUCI_DEPENDS:=+jshn +curl +openssl-util
define Package/$(PKG_NAME)/conffiles
/etc/config/xlnetacc
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -1,36 +0,0 @@
module("luci.controller.xlnetacc",package.seeall)
function index()
if not nixio.fs.access("/etc/config/xlnetacc") then
return
end
entry({"admin", "services", "xlnetacc"}, alias("admin", "services", "xlnetacc", "general"), _("XLNetAcc"), 90).dependent = true
entry({"admin", "services", "xlnetacc", "general"}, cbi("xlnetacc"), _("Settings"), 1).leaf = true
entry({"admin", "services", "xlnetacc", "log"}, template("xlnetacc/logview"), _("Log"), 2).leaf = true
entry({"admin", "services", "xlnetacc", "status"}, call("action_status")).leaf = true
entry({"admin", "services", "xlnetacc", "logdata"}, call("action_log")).leaf = true
end
function action_status()
luci.http.prepare_content("application/json")
luci.http.write_json({
run_state = luci.sys.call("ps -w | grep xlnetacc.sh | grep -v grep >/dev/null")==0,
down_state = nixio.fs.readfile("/var/state/xlnetacc_down_state") or "",
up_state = nixio.fs.readfile("/var/state/xlnetacc_up_state") or ""
})
end
function action_log()
local uci = require "luci.model.uci".cursor()
local util = require "luci.util"
local log_data = {}
log_data.syslog = util.trim(util.exec("logread | grep xlnetacc"))
if uci:get("xlnetacc","general","logging")~="0" then
log_data.client = nixio.fs.readfile("/var/log/xlnetacc.log") or ""
end
uci:unload("xlnetacc")
luci.http.prepare_content("application/json")
luci.http.write_json(log_data)
end
@@ -1,57 +0,0 @@
local m,s,o
local uci = luci.model.uci.cursor()
m = Map("xlnetacc")
m.title = translate("XLNetAcc - Settings")
m.description = translate("XLNetAcc is a Thunder joint broadband operators launched a commitment to help users solve the low broadband, slow Internet access, poor Internet experience of professional-grade broadband upgrade software.")
m:section(SimpleSection).template = "xlnetacc/xlnetacc_status"
s = m:section(NamedSection, "general", "general")
s.title = translate("General Settings")
s.anonymous = true
s.addremove = false
o = s:option(Flag, "enabled", translate("Enabled"))
o.rmempty = false
o = s:option(Flag, "down_acc", translate("Enable DownLink Upgrade"))
o = s:option(Flag, "up_acc", translate("Enable UpLink Upgrade"))
o = s:option(Flag, "logging", translate("Enable Logging"))
o.default = 1
o = s:option(Flag, "verbose", translate("Enable verbose logging"))
o:depends("logging",1)
o = s:option(ListValue, "network", translate("Upgrade interface"))
uci:foreach("network","interface",function(section)
if section[".name"]~="loopback" then
o:value(section[".name"])
end
end)
o = s:option(Value, "keepalive", translate("Keepalive interval"))
o.description = translate("5 ~ 60 minutes")
for _,v in ipairs({5,10,20,30,60}) do
o:value(v,v.." "..translate("minutes"))
end
o.datatype = "range(5,60)"
o.default = 10
o = s:option(Value, "relogin", translate("Account relogin"))
o.description = translate("1 ~ 48 hours")
o:value(0, translate("Not enabled"))
for _,v in ipairs({3,12,18,24,30}) do
o:value(v,v.." "..translate("hours"))
end
o.datatype = "max(48)"
o.default = 0
o = s:option(Value, "account", translate("XLNetAcc account"))
o = s:option(Value, "password", translate("XLNetAcc password"))
o.password = true
return m
@@ -1,49 +0,0 @@
<%css=[[
#log_text{
padding:10px;
text-align:left;
height:500px;
overflow:auto;
}
#log_text pre{
word-break:break-all;
margin:0;
}
.description{
color:#ffffff;
background-color:#0099ff;
}
]]
%>
<%+header%>
<div class="cbi-map">
<h2 name="content"><%:XLNetAcc - Log%></h2>
<fieldset class="cbi-section">
<fieldset class="cbi-section-node">
<div id="log_text"><img src="<%=resource%>/icons/loading.gif" alt="<%:Loading...%>" style="vertical-align:middle"/><%:Collecting data...%></div>
<div style="text-align:right"><small><%:Refresh every 5 seconds.%></small></div>
</fieldset>
</fieldset>
</div>
<script type="text/javascript" src="<%=resource%>/cbi.js?v=1.0.5"></script>
<script type="text/javascript">//<![CDATA[
var log_id=document.getElementById('log_text');
XHR.poll(5,'<%=luci.dispatcher.build_url("admin","services","xlnetacc","logdata")%>',null,function(x,data){
if (log_id&&data){
log_id.innerHTML=String.format(
'<pre>%s%s%s%s</pre>',
'<span class="description"><strong><%:syslog:%></strong></span><br/>',
data.syslog || '<%:No log data.%>',
'<br/><br/><span class="description"><strong><%:log file:%></strong></span><br/>',
data.client || '<%:No log data.%>'
);
}else if (log_id){
log_id.innerHTML='<strong><%:Error get log data.%></strong>';
}
});
//]]></script>
<%+footer%>
@@ -1,21 +0,0 @@
<fieldset class="cbi-section">
<legend><%:Running Status%></legend>
<table width="100%" cellspacing="10" id="_xlnetacc_status_table">
<tr><td width="33%"><%:XLNetAcc Running Status%></td><td id="_run_state"><em><%:Collecting data...%></em></td></tr>
<tr><td width="33%"><%:DownLink Upgrade Status%></td><td id="_down_state"><em><%:Collecting data...%></em></td></tr>
<tr><td width="33%"><%:UpLink Upgrade Status%></td><td id="_up_state"><em><%:Collecting data...%></em></td></tr>
</table>
</fieldset>
<script type="text/javascript">//<![CDATA[
var run_state=document.getElementById('_run_state');
var down_state=document.getElementById('_down_state');
var up_state=document.getElementById('_up_state');
XHR.poll(5,'<%=luci.dispatcher.build_url("admin","services","xlnetacc","status")%>',null,function(x,status){
if ( x&&x.status==200 ){
run_state.innerHTML=status.run_state?'<em><b><font color=green><%:RUNNING%></font></b></em>':'<em><b><font color=red><%:NOT RUNNING%></font></b></em>';
down_state.innerHTML=status.down_state?status.down_state:'<%:No upgrade information%>';
up_state.innerHTML=status.up_state?status.up_state:'<%:No upgrade information%>';
}
});
//]]></script>
@@ -1,116 +0,0 @@
msgid "XLNetAcc"
msgstr "迅雷快鸟"
msgid "XLNetAcc - Settings"
msgstr "迅雷快鸟 - 设置"
msgid "XLNetAcc - Log"
msgstr "迅雷快鸟 - 日志"
msgid "XLNetAcc is a Thunder joint broadband operators launched a commitment to help users solve the low broadband, slow Internet access, poor Internet experience of professional-grade broadband upgrade software."
msgstr "迅雷快鸟是迅雷联合宽带运营商推出的一款致力于帮助用户解决宽带低、网速慢、上网体验差的专业级宽带加速软件。"
msgid "Settings"
msgstr "设置"
msgid "Log"
msgstr "日志"
msgid "Running Status"
msgstr "运行状态"
msgid "XLNetAcc Running Status"
msgstr "快鸟运行状态"
msgid "DownLink Upgrade Status"
msgstr "下行提速状态"
msgid "UpLink Upgrade Status"
msgstr "上行提速状态"
msgid "RUNNING"
msgstr "运行中"
msgid "NOT RUNNING"
msgstr "未运行"
msgid "No upgrade information"
msgstr "暂无提速信息"
msgid "General Settings"
msgstr "基本设置"
msgid "Enabled"
msgstr "启用"
msgid "Enable DownLink Upgrade"
msgstr "开启下行提速"
msgid "Enable UpLink Upgrade"
msgstr "开启上行提速"
msgid "Enable Logging"
msgstr "启用日志记录"
msgid "Enable verbose logging"
msgstr "启用详细日志"
msgid "Upgrade interface"
msgstr "指定提速接口"
msgid "Keepalive interval"
msgstr "保持连接周期"
msgid "Account relogin"
msgstr "帐号重新登录"
msgid "Not enabled"
msgstr "未启用"
msgid "5 ~ 60 minutes"
msgstr "5 ~ 60 分钟"
msgid "minutes"
msgstr "分钟"
msgid "1 ~ 48 hours"
msgstr "1 ~ 48 小时"
msgid "hours"
msgstr "小时"
msgid "XLNetAcc account"
msgstr "迅雷快鸟帐号"
msgid "XLNetAcc password"
msgstr "迅雷快鸟密码"
msgid "Does not store the plaintext password, automatically emptied after start."
msgstr "不存储明文密码,启动后自动清空。"
msgid "Encrypted password"
msgstr "加密后的密码"
msgid "Auto-generate in accordance with the plaintext password, do not modify it!"
msgstr "根据明文密码自动生成,请勿修改!"
msgid "Log Data"
msgstr "日志数据"
msgid "Loading..."
msgstr "正在加载..."
msgid "Refresh every 5 seconds."
msgstr "每 5 秒刷新。"
msgid "syslog:"
msgstr "系统日志:"
msgid "log file:"
msgstr "日志文件:"
msgid "No log data."
msgstr "无日志数据。"
msgid "Error get log data."
msgstr "获取日志数据失败。"
@@ -1 +0,0 @@
zh-cn
@@ -1,5 +0,0 @@
config general 'general'
option enabled '0'
option network 'wan'
@@ -1,13 +0,0 @@
#!/bin/sh
/etc/init.d/xlnetacc enabled || exit 0
[ "$INTERFACE" != "$(uci get xlnetacc.general.network)" ] && exit 0
case "$ACTION" in
ifup)
/etc/init.d/xlnetacc start
;;
ifdown)
/etc/init.d/xlnetacc stop
;;
esac
@@ -1,49 +0,0 @@
#!/bin/sh /etc/rc.common
START=95
STOP=10
SERVICE_DAEMONIZE=1
NAME=xlnetacc
start() {
local retry=1
while ps -w | grep ${NAME}.sh | grep -v grep >/dev/null 2>&1; do
[ $retry -ge 10 ] && return 1 || let retry++
sleep 1
done
rm -f /var/lock/xlnetacc.lock
config_load "$NAME"
config_get_bool enabled "general" "enabled" 0
config_get_bool down_acc "general" "down_acc" 0
config_get_bool up_acc "general" "up_acc" 0
config_get network "general" "network"
config_get username "general" "account"
config_get password "general" "password"
( [ $enabled -eq 0 ] || [ $down_acc -eq 0 -a $up_acc -eq 0 ] || [ -z "$username" -o -z "$password" -o -z "$network" ] ) && return 2
logger -p "daemon.notice" -t "$NAME" "XLNetAcc is starting ..."
service_start /usr/bin/${NAME}.sh --start
}
stop() {
ps -w | grep ${NAME}.sh | grep -v grep >/dev/null 2>&1 || return 1
local pid spid
for pid in $(ps -w | grep ${NAME}.sh | grep -v grep | awk '{print $1}'); do
echo "Stop XLNetAcc process PID: $pid"
kill $pid >/dev/null 2>&1
for spid in $(pgrep -P $pid "sleep"); do
echo "Stop XLNetAcc process SPID: $spid"
kill $spid >/dev/null 2>&1
done
done
rm -f /var/lock/xlnetacc.lock
logger -p "daemon.notice" -t "$NAME" "XLNetAcc has stoped."
return 0
}
restart() {
stop && sleep 1
start
}
@@ -1,609 +0,0 @@
#!/bin/sh
# 声明常量
readonly packageName='com.xunlei.vip.swjsq'
readonly protocolVersion=300
readonly businessType=68
readonly sdkVersion='3.1.2.185150'
readonly clientVersion='2.7.2.0'
readonly agent_xl="android-ok-http-client/xl-acc-sdk/version-$sdkVersion"
readonly agent_down='okhttp/3.9.1'
readonly agent_up='android-async-http/xl-acc-sdk/version-1.0.0.1'
readonly client_type_down='android-swjsq'
readonly client_type_up='android-uplink'
# 声明全局变量
_bind_ip=
_http_cmd=
_peerid=
_devicesign=
_userid=
_loginkey=
_sessionid=
_portal_down=
_portal_up=
_dial_account=
access_url=
http_args=
user_agent=
link_cn=
lasterr=
sequence_xl=1000000
sequence_down=$(( $(date +%s) / 6 ))
sequence_up=$sequence_down
# 包含用于解析 JSON 格式返回值的函数
. /usr/share/libubox/jshn.sh
# 读取 UCI 设置相关函数
uci_get_by_name(){
local ret=$(uci -q get $NAME.$1.$2)
echo -n ${ret:=$3}
}
uci_get_by_bool(){
case $(uci_get_by_name "$1" "$2" "$3") in
1|on|true|yes|enabled)echo -n 1;;
*)echo -n 0;;
esac
}
# 日志和状态栏输出。1 日志文件, 2 系统日志, 4 详细模式, 8 下行状态栏, 16 上行状态栏, 32 失败状态
_log(){
local msg=$1 flag=$2 timestamp=$(date +'%Y/%m/%d %H:%M:%S')
[ -z "$msg" ] && return
[ -z "$flag" ] && flag=1
[ $logging = 0 -a $(( $flag & 1 )) -ne 0 ] && flag=$(( $flag ^ 1 ))
if [ $verbose = 0 -a $(( $flag & 4 )) -ne 0 ];then
[ $(( $flag & 1 )) -ne 0 ] && flag=$(( $flag ^ 1 ))
[ $(( $flag & 2 )) -ne 0 ] && flag=$(( $flag ^ 2 ))
fi
if [ $down_acc = 0 -a $(( $flag & 8 )) -ne 0 ];then
flag=$(( $flag ^ 8 ))
[ $up_acc -ne 0 ] && flag=$(( $flag | 16 ))
fi
if [ $up_acc = 0 -a $(( $flag & 16 )) -ne 0 ];then
flag=$(( $flag ^ 16 ))
[ $down_acc -ne 0 ] && flag=$(( $flag | 8 ))
fi
[ $(( $flag & 1 )) -ne 0 ] && echo "$timestamp $msg" >> $LOGFILE 2> /dev/null
[ $(( $flag & 2 )) -ne 0 ] && logger -p "daemon.info" -t "$NAME" "$msg"
[ $(( $flag & 32 )) = 0 ] && local color="green" || local color="red"
[ $(( $flag & 8 )) -ne 0 ] && echo -n "<font color=$color>$timestamp $msg</font>" > $down_state_file 2> /dev/null
[ $(( $flag & 16 )) -ne 0 ] && echo -n "<font color=$color>$timestamp $msg</font>" > $up_state_file 2> /dev/null
}
# 清理日志
clean_log(){
[ $logging = 1 -a -f "$LOGFILE" ] || return
[ $(wc -l "$LOGFILE" | awk '{print $1}') -le 800 ] && return
_log "清理日志文件"
local logdata=$(tail -n 500 "$LOGFILE")
echo "$logdata" > $LOGFILE 2> /dev/null
unset logdata
}
# 获取接口IP地址
get_bind_ip(){
network=$(uci get "xlnetacc.general.network" 2> /dev/null)
json_cleanup;json_load "$(ubus call network.interface.$network status 2> /dev/null)" >/dev/null 2>&1
json_select "ipv4-address" >/dev/null 2>&1;json_select 1 >/dev/null 2>&1
json_get_var _bind_ip "address"
if [ -z "$_bind_ip" -o "$_bind_ip" = "0.0.0.0" ];then
_log "获取网络 $network IP地址失败"
return 1
else
_log "绑定IP地址: $_bind_ip"
return 0
fi
}
# 生成设备标识
gen_device_sign(){
local ifname macaddr
while :;do
ifname=$(uci get "network.$network.ifname" 2> /dev/null)
[ "${ifname:0:1}" = @ ] && network="${ifname:1}" || break
done
[ -z "$ifname" ] && { _log "获取网络 $network 信息出错";return;}
json_cleanup;json_load "$(ubus call network.device status {\"name\":\"$ifname\"} 2> /dev/null)" >/dev/null 2>&1
json_get_var macaddr "macaddr"
[ -z "$macaddr" ] && { _log "获取网络 $network MAC地址出错";return;}
macaddr=$(echo -n "$macaddr" | awk '{print toupper($0)}')
# 计算peerID
local fake_peerid=$(awk -F- '{print toupper($5)}' '/proc/sys/kernel/random/uuid')
readonly _peerid="${fake_peerid}004V"
_log "_peerid is $_peerid" $(( 1 | 4 ))
# 计算devicesign
# sign = div.10?.device_id + md5(sha1(packageName + businessType + md5(a protocolVersion specific GUID)))
local fake_device_id=$(echo -n "${macaddr//:/}" | openssl dgst -md5 | awk '{print $2}')
local fake_device_sign=$(echo -n "${fake_device_id}${packageName}${businessType}c7f21687eed3cdb400ca11fc2263c998" \
| openssl dgst -sha1 | awk '{print $2}')
readonly _devicesign="div101.${fake_device_id}"$(echo -n "$fake_device_sign" | openssl dgst -md5 | awk '{print $2}')
_log "_devicesign is $_devicesign" $(( 1 | 4 ))
}
# 快鸟帐号通用参数
swjsq_json(){
let sequence_xl++
# 生成POST数据
json_init
json_add_string protocolVersion "$protocolVersion"
json_add_string sequenceNo "$sequence_xl"
json_add_string platformVersion '10'
json_add_string isCompressed '0'
json_add_string appid "$businessType"
json_add_string clientVersion "$clientVersion"
json_add_string peerID "$_peerid"
json_add_string appName "ANDROID-$packageName"
json_add_string sdkVersion "${sdkVersion##*.}"
json_add_string devicesign "$_devicesign"
json_add_string netWorkType 'WIFI'
json_add_string providerName 'OTHER'
json_add_string deviceModel 'MI'
json_add_string deviceName 'Xiaomi Mi'
json_add_string OSVersion "7.1.1"
}
# 帐号登录
swjsq_login(){
swjsq_json
if [ -z "$_userid" -o -z "$_loginkey" ];then
access_url='https://mobile-login.xunlei.com/login'
json_add_string userName "$username"
json_add_string passWord "$password"
json_add_string verifyKey
json_add_string verifyCode
json_add_string isMd5Pwd '0'
else
access_url='https://mobile-login.xunlei.com/loginkey'
json_add_string userName "$_userid"
json_add_string loginKey "$_loginkey"
fi
json_close_object
local ret=$($_http_cmd -A "$agent_xl" -d "$(json_dump)" "$access_url")
case $? in
0)_log "login is $ret" $(( 1 | 4 ));json_cleanup;json_load "$ret" >/dev/null 2>&1;json_get_var lasterr "errorCode";;
2)lasterr=-2;;
28)lasterr=-3;;
*)lasterr=-1;;
esac
case ${lasterr:=-1} in
0)json_get_var _userid "userID";json_get_var _loginkey "loginKey";json_get_var _sessionid "sessionID";_log "_sessionid is $_sessionid" $(( 1 | 4 ));local outmsg="帐号登录成功";_log "$outmsg" $(( 1 | 8 ));;
15)_userid=;_loginkey=;;# 身份信息已失效
-1)local outmsg="帐号登录失败。迅雷服务器未响应,请稍候";_log "$outmsg";;
-2)local outmsg="cURL 参数解析错误,请更新 cURL";_log "$outmsg" $(( 1 | 8 | 32 ));;
-3)local outmsg="cURL 网络通信失败,请稍候";_log "$outmsg";;
*)local errorDesc;json_get_var errorDesc "errorDesc";local outmsg="帐号登录失败。错误代码: ${lasterr}";[ -n "$errorDesc" ] && outmsg="${outmsg},原因: $errorDesc";_log "$outmsg" $(( 1 | 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 帐号注销
swjsq_logout(){
swjsq_json
json_add_string userID "$_userid"
json_add_string sessionID "$_sessionid"
json_close_object
local ret=$($_http_cmd -A "$agent_xl" -d "$(json_dump)" 'https://mobile-login.xunlei.com/logout')
_log "logout is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errorCode"
case ${lasterr:=-1} in
0)_sessionid=;local outmsg="帐号注销成功";_log "$outmsg" $(( 1 | 8 ));;
-1)local outmsg="帐号注销失败。迅雷服务器未响应,请稍候";_log "$outmsg";;
*)local errorDesc;json_get_var errorDesc "errorDesc";local outmsg="帐号注销失败。错误代码: ${lasterr}";[ -n "$errorDesc" ] && outmsg="${outmsg},原因: $errorDesc";_log "$outmsg" $(( 1 | 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 获取用户信息
swjsq_getuserinfo(){
local _vasid vasid_down=14 vasid_up=33 outmsg
[ $down_acc -ne 0 ] && _vasid="${_vasid}${vasid_down},";[ $up_acc -ne 0 ] && _vasid="${_vasid}${vasid_up},"
swjsq_json
json_add_string userID "$_userid"
json_add_string sessionID "$_sessionid"
json_add_string vasid "$_vasid"
json_close_object
local ret=$($_http_cmd -A "$agent_xl" -d "$(json_dump)" 'https://mobile-login.xunlei.com/getuserinfo')
_log "getuserinfo is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errorCode"
case ${lasterr:=-1} in
0)local index=1 can_down=0 vasid isVip isYear expireDate;json_select "vipList" >/dev/null 2>&1
while :;do
json_select $index >/dev/null 2>&1
[ $? -ne 0 ] && break
json_get_var vasid "vasid"
json_get_var isVip "isVip"
json_get_var isYear "isYear"
json_get_var expireDate "expireDate"
json_select ".." >/dev/null 2>&1
let index++
case ${vasid:-0} in
2)[ $down_acc -ne 0 ] && outmsg="迅雷超级会员" || continue;;
$vasid_down)outmsg="迅雷快鸟会员";;
$vasid_up)outmsg="上行提速会员";;
*)continue;;
esac
if [ ${isVip:-0} = 1 -o ${isYear:-0} = 1 ];then
outmsg="${outmsg}有效。会员到期时间:${expireDate:0:4}-${expireDate:4:2}-${expireDate:6:2}"
[ $vasid = $vasid_up ] && _log "$outmsg" $(( 1 | 16 )) || _log "$outmsg" $(( 1 | 8 ))
[ $vasid -ne $vasid_up ] && can_down=$(( $can_down | 1 ))
else
if [ ${#expireDate} -ge 8 ];then
outmsg="${outmsg}已到期。会员到期时间:${expireDate:0:4}-${expireDate:4:2}-${expireDate:6:2}"
else
outmsg="${outmsg}无效"
fi
[ $vasid = $vasid_up ] && _log "$outmsg" $(( 1 | 16 | 32 )) || _log "$outmsg" $(( 1 | 8 | 32 ))
[ $vasid = $vasid_up ] && up_acc=0
fi
done
[ $can_down = 0 ] && down_acc=0;;
-1)outmsg="获取迅雷会员信息失败。迅雷服务器未响应,请稍候";_log "$outmsg";;
*)local errorDesc;json_get_var errorDesc "errorDesc";outmsg="获取迅雷会员信息失败。错误代码: ${lasterr}";[ -n "$errorDesc" ] && outmsg="${outmsg},原因: $errorDesc";_log "$outmsg" $(( 1 | 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 登录时间更新
swjsq_renewal(){
xlnetacc_var 1
local limitdate=$(date +%Y%m%d -d "1970.01.01-00:00:$(( $(date +%s) + 30 * 24 * 60 * 60 ))")
access_url='http://api.ext.swjsq.vip.xunlei.com'
local ret=$($_http_cmd -A "$user_agent" "$access_url/renewal?${http_args%&dial_account=*}&limitdate=$limitdate")
_log "renewal is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
0)local outmsg="更新登录时间成功。帐号登录展期:${limitdate:0:4}-${limitdate:4:2}-${limitdate:6:2}";_log "$outmsg";;
-1)local outmsg="更新登录时间失败。迅雷服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "richmessage";local outmsg="更新登录时间失败。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 获取提速入口
swjsq_portal(){
xlnetacc_var $1
[ $1 = 1 ] && access_url='http://api.portal.swjsq.vip.xunlei.com:81/v2/queryportal' || \
access_url='http://api.upportal.swjsq.vip.xunlei.com/v2/queryportal'
local ret=$($_http_cmd -A "$user_agent" "$access_url")
_log "portal $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
0)local interface_ip interface_port province sp;json_get_var interface_ip "interface_ip";json_get_var interface_port "interface_port";json_get_var province "province_name";json_get_var sp "sp_name"
if [ $1 = 1 ];then
_portal_down="http://$interface_ip:$interface_port/v2"
_log "_portal_down is $_portal_down" $(( 1 | 4 ))
else
_portal_up="http://$interface_ip:$interface_port/v2"
_log "_portal_up is $_portal_up" $(( 1 | 4 ))
fi
local outmsg="获取${link_cn}提速入口成功";[ -n "$province" -a -n "$sp" ] && outmsg="${outmsg}。运营商:${province}${sp}";_log "$outmsg" $(( 1 | $1 * 8 ));;
-1)local outmsg="获取${link_cn}提速入口失败。迅雷服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "message";local outmsg="获取${link_cn}提速入口失败。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 获取网络带宽信息
isp_bandwidth(){
xlnetacc_var $1
local ret=$($_http_cmd -A "$user_agent" "$access_url/bandwidth?${http_args%&dial_account=*}")
_log "bandwidth $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
# 获取带宽数据
0)local can_upgrade bind_dial_account dial_account stream cur_bandwidth max_bandwidth;[ $1 = 1 ] && stream="downstream" || stream="upstream";json_get_var can_upgrade "can_upgrade"
json_get_var bind_dial_account "bind_dial_account";json_get_var dial_account "dial_account";json_select;json_select "bandwidth" >/dev/null 2>&1;json_get_var cur_bandwidth "$stream"
json_select;json_select "max_bandwidth" >/dev/null 2>&1;json_get_var max_bandwidth "$stream";json_select;cur_bandwidth=$(( ${cur_bandwidth:-0} / 1024 ));max_bandwidth=$(( ${max_bandwidth:-0} / 1024 ))
if [ -n "$bind_dial_account" -a "$bind_dial_account" != "$dial_account" ];then
local outmsg="绑定宽带账号 $bind_dial_account 与当前宽带账号 $dial_account 不一致,请联系迅雷客服解绑(每月仅一次)";_log "$outmsg" $(( 1 | 8 | 32 ))
down_acc=0;up_acc=0
elif [ $can_upgrade = 0 ];then
local message;json_get_var message "richmessage";[ -z "$message" ] && json_get_var message "message"
local outmsg="${link_cn}无法提速";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ))
[ $1 = 1 ] && down_acc=0 || up_acc=0
elif [ $cur_bandwidth -ge $max_bandwidth ];then
local outmsg="${link_cn}无需提速。当前带宽 ${cur_bandwidth}M,超过最大可提升带宽 ${max_bandwidth}M";_log "$outmsg" $(( 1 | $1 * 8 ))
[ $1 = 1 ] && down_acc=0 || up_acc=0
else
if [ -z "$_dial_account" -a -n "$dial_account" ];then
_dial_account=$dial_account
_log "_dial_account is $_dial_account" $(( 1 | 4 ))
fi
local outmsg="${link_cn}可以提速。当前带宽 ${cur_bandwidth}M,可提升至 ${max_bandwidth}M";_log "$outmsg" $(( 1 | $1 * 8 ))
fi;;
# 724 账号存在异常
724)lasterr=-2;local outmsg="获取${link_cn}网络带宽信息失败。原因: 您的账号存在异常,请联系迅雷客服反馈";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
# 3103 线路暂不支持
3103)lasterr=0;local province sp;json_get_var province "province_name";json_get_var sp "sp_name";local outmsg="${link_cn}无法提速。原因: ${province}${sp}线路暂不支持";_log "$outmsg" $(( 1 | $1 * 8 | 32 ))
[ $1 = 1 ] && down_acc=0 || up_acc=0;;
-1)local outmsg="获取${link_cn}网络带宽信息失败。运营商服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "richmessage";[ -z "$message" ] && json_get_var message "message"
local outmsg="获取${link_cn}网络带宽信息失败。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 发送带宽提速信号
isp_upgrade(){
xlnetacc_var $1
local ret=$($_http_cmd -A "$user_agent" "$access_url/upgrade?$http_args")
_log "upgrade $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
0)local bandwidth;json_select "bandwidth" >/dev/null 2>&1;json_get_var bandwidth "downstream";bandwidth=$(( ${bandwidth:-0} / 1024 ))
local outmsg="${link_cn}提速成功,带宽已提升到 ${bandwidth}M";_log "$outmsg" $(( 1 | $1 * 8 ));[ $1 = 1 ] && down_acc=2 || up_acc=2;;
# 812 已处于提速状态
812)lasterr=0;local outmsg="${link_cn}提速成功,当前宽带已处于提速状态";_log "$outmsg" $(( 1 | $1 * 8 ));[ $1 = 1 ] && down_acc=2 || up_acc=2;;
# 724 账号存在异常
724)lasterr=-2;local outmsg="${link_cn}提速失败。原因: 您的账号存在异常,请联系迅雷客服反馈";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
-1)local outmsg="${link_cn}提速失败。运营商服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "richmessage";[ -z "$message" ] && json_get_var message "message"
local outmsg="${link_cn}提速失败。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 发送提速心跳信号
isp_keepalive(){
xlnetacc_var $1
local ret=$($_http_cmd -A "$user_agent" "$access_url/keepalive?$http_args")
_log "keepalive $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
0)local outmsg="${link_cn}心跳信号返回正常";_log "$outmsg";;
# 513 提速通道不存在
513)lasterr=-2;local outmsg="${link_cn}提速超时,提速通道不存在";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
-1)local outmsg="${link_cn}心跳信号发送失败。运营商服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "richmessage";[ -z "$message" ] && json_get_var message "message"
local outmsg="${link_cn}提速失效。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 发送带宽恢复信号
isp_recover(){
xlnetacc_var $1
local ret=$($_http_cmd -A "$user_agent" "$access_url/recover?$http_args")
_log "recover $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
case ${lasterr:=-1} in
0)local outmsg="${link_cn}带宽已恢复";_log "$outmsg" $(( 1 | $1 * 8 ));[ $1 = 1 ] && down_acc=1 || up_acc=1;;
-1)local outmsg="${link_cn}带宽恢复失败。运营商服务器未响应,请稍候";_log "$outmsg";;
*)local message;json_get_var message "richmessage";[ -z "$message" ] && json_get_var message "message"
local outmsg="${link_cn}带宽恢复失败。错误代码: ${lasterr}";[ -n "$message" ] && outmsg="${outmsg},原因: $message";_log "$outmsg" $(( 1 | $1 * 8 | 32 ));;
esac
[ $lasterr = 0 ] && return 0 || return 1
}
# 查询提速信息,未使用
isp_query(){
xlnetacc_var $1
local ret=$($_http_cmd -A "$user_agent" "$access_url/query_try_info?$http_args")
_log "query_try_info $1 is $ret" $(( 1 | 4 ))
json_cleanup;json_load "$ret" >/dev/null 2>&1
json_get_var lasterr "errno"
[ $lasterr = 0 ] && return 0 || return 1
}
# 设置参数变量
xlnetacc_var(){
if [ $1 = 1 ];then
let sequence_down++
access_url=$_portal_down
http_args="sequence=${sequence_down}&client_type=${client_type_down}-${clientVersion}&client_version=${client_type_down//-/}-${clientVersion}&chanel=umeng-10900011&time_and=$(date +%s)000"
user_agent=$agent_down
link_cn="下行"
else
let sequence_up++
access_url=$_portal_up
http_args="sequence=${sequence_up}&client_type=${client_type_up}-${clientVersion}&client_version=${client_type_up//-/}-${clientVersion}"
user_agent=$agent_down
link_cn="上行"
fi
http_args="${http_args}&peerid=${_peerid}&userid=${_userid}&sessionid=${_sessionid}&user_type=1&os=android-7.1.1"
[ -n "$_dial_account" ] && http_args="${http_args}&dial_account=${_dial_account}"
}
# 重试循环
xlnetacc_retry(){
if [ $# -ge 3 ];then
if [ $3 -ne 0 ];then
[ $2 = 1 -a $down_acc -ne $3 ] && return 0
[ $2 = 2 -a $up_acc -ne $3 ] && return 0
fi
fi
local retry=1
while :;do
lasterr=
eval $1 $2 && break # 成功
[ $# -ge 4 -a $retry -ge $4 ] && break || let retry++ # 重试超时
case $lasterr in
-1)sleep 5s;;# 服务器未响应
-2)break;;# 严重错误
*)sleep 3s;;# 其它错误
esac
done
[ ${lasterr:-0} = 0 ] && return 0 || return 1
}
# 注销已登录帐号
xlnetacc_logout(){
[ -z "$_sessionid" ] && return 2
[ $# -ge 1 ] && local retry=$1 || local retry=1
xlnetacc_retry 'isp_recover' 1 2 $retry
xlnetacc_retry 'isp_recover' 2 2 $retry
xlnetacc_retry 'swjsq_logout' 0 0 $retry
[ $down_acc -ne 0 ] && down_acc=1;[ $up_acc -ne 0 ] && up_acc=1
_sessionid=;_dial_account=
[ $lasterr = 0 ] && return 0 || return 1
}
# 中止信号处理
sigterm(){
_log "trap sigterm, exit" $(( 1 | 4 ))
xlnetacc_logout
rm -f "$down_state_file" "$up_state_file"
exit 0
}
# 初始化
xlnetacc_init(){
[ "$1" != "--start" ] && return 1
# 防止重复启动
[ -f /var/lock/xlnetacc.lock ] && return 1
touch /var/lock/xlnetacc.lock
# 读取设置
readonly NAME=xlnetacc
readonly LOGFILE=/var/log/${NAME}.log
readonly down_state_file=/var/state/${NAME}_down_state
readonly up_state_file=/var/state/${NAME}_up_state
down_acc=$(uci_get_by_bool "general" "down_acc" 0)
up_acc=$(uci_get_by_bool "general" "up_acc" 0)
readonly logging=$(uci_get_by_bool "general" "logging" 1)
readonly verbose=$(uci_get_by_bool "general" "verbose" 0)
network=$(uci_get_by_name "general" "network" "wan")
keepalive=$(uci_get_by_name "general" "keepalive" 10)
relogin=$(uci_get_by_name "general" "relogin" 0)
readonly username=$(uci_get_by_name "general" "account")
readonly password=$(uci_get_by_name "general" "password")
local enabled=$(uci_get_by_bool "general" "enabled" 0)
([ $enabled = 0 ] || [ $down_acc = 0 -a $up_acc = 0 ] || [ -z "$username" -o -z "$password" -o -z "$network" ]) && return 2
([ -z "$keepalive" -o -n "${keepalive//[0-9]/}" ] || [ $keepalive -lt 5 -o $keepalive -gt 60 ]) && keepalive=10
readonly keepalive=$(( $keepalive ))
([ -z "$relogin" -o -n "${relogin//[0-9]/}" ] || [ $relogin -gt 48 ]) && relogin=0
readonly relogin=$(( $relogin * 60 * 60 ))
[ $logging = 1 ] && [ ! -d /var/log ] && mkdir -p /var/log
[ -f "$LOGFILE" ] && _log "------------------------------"
_log "迅雷快鸟正在启动..."
# 检查外部调用工具
command -v curl >/dev/null || { _log "cURL 未安装";return 3;}
local opensslchk=$(echo -n 'openssl' | openssl dgst -sha1 | awk '{print $2}')
[ "$opensslchk" != 'c898fa1e7226427010e329971e82c669f8d8abb4' ] && { _log "openssl-util 未安装或计算错误";return 3;}
# 捕获中止信号
trap 'sigterm' INT # Ctrl-C
trap 'sigterm' QUIT # Ctrl-\
trap 'sigterm' TERM # kill
# 生成设备标识
gen_device_sign
[ ${#_peerid} -ne 16 -o ${#_devicesign} -ne 71 ] && return 4
clean_log
[ -d /var/state ] || mkdir -p /var/state
rm -f "$down_state_file" "$up_state_file"
return 0
}
# 程序主体
xlnetacc_main(){
while :;do
# 获取外网IP地址
xlnetacc_retry 'get_bind_ip'
_http_cmd="curl -Lfs -m 5 --interface $_bind_ip"
# 注销快鸟帐号
xlnetacc_logout 3 && sleep 3s
# 登录快鸟帐号
while :;do
lasterr=
swjsq_login
case $lasterr in
0)break;;# 登录成功
-1)sleep 5s;;# 服务器未响应
-2)return 7;;# cURL 参数解析错误
-3)sleep 3s;;# cURL 网络通信失败
6)sleep 130m;;# 需要输入验证码
8)sleep 3m;;# 服务器系统维护
15)sleep 1s;;# 身份信息已失效
*)return 5;;# 登录失败
esac
done
# 获取用户信息
xlnetacc_retry 'swjsq_getuserinfo'
[ $down_acc = 0 -a $up_acc = 0 ] && break
# 登录时间更新
xlnetacc_retry 'swjsq_renewal'
# 获取提速入口
xlnetacc_retry 'swjsq_portal' 1 1
xlnetacc_retry 'swjsq_portal' 2 1
# 获取带宽信息
xlnetacc_retry 'isp_bandwidth' 1 1 10 || { sleep 3m;continue;}
xlnetacc_retry 'isp_bandwidth' 2 1 10 || { sleep 3m;continue;}
[ $down_acc = 0 -a $up_acc = 0 ] && break
# 带宽提速
xlnetacc_retry 'isp_upgrade' 1 1 10 || { sleep 3m;continue;}
xlnetacc_retry 'isp_upgrade' 2 1 10 || { sleep 3m;continue;}
# 心跳保持
local timer=$(date +%s)
while :;do
clean_log # 清理日志
sleep ${keepalive}m
[ $relogin -ne 0 -a $(( $(date +%s) - $timer )) -ge $relogin ] && break # 登录超时
xlnetacc_retry 'isp_keepalive' 1 2 5 || break
xlnetacc_retry 'isp_keepalive' 2 2 5 || break
done
done
xlnetacc_logout
_log "无法提速,迅雷快鸟已停止。"
return 6
}
# 程序入口
xlnetacc_init "$@" && xlnetacc_main
exit $?
@@ -1,11 +0,0 @@
{
"luci-app-xlnetacc": {
"description": "Grant UCI access for luci-app-xlnetacc",
"read": {
"uci": [ "xlnetacc" ]
},
"write": {
"uci": [ "xlnetacc" ]
}
}
}
@@ -1,4 +0,0 @@
{
"config": "xlnetacc",
"init": "xlnetacc"
}
-65
View File
@@ -1,65 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
#
# Copyright (C) 2021 ImmortalWrt.org
include $(TOPDIR)/rules.mk
PKG_NAME:=nps
PKG_VERSION:=0.26.18
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/yisier/nps/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=29da044262071a1fa53ce7169c6427ee4f12fc0ada60ef7fb52fabfd165afe91
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILE:=LICENSE
PKG_MAINTAINTER:=Tianling Shen <cnsztl@immortalwrt.org>
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
GO_PKG:=ehang.io/nps
GO_PKG_BUILD_PKG:=ehang.io/nps/cmd/...
GO_PKG_LDFLAGS:=-s -w
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/nps/template
SECTION:=net
CATEGORY:=Network
TITLE:=A intranet penetration proxy server ($(1))
DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle
URL:=https://ehang-io.github.io/nps
endef
Package/npc = $(call Package/nps/template,client)
Package/nps = $(call Package/nps/template,server)
define Package/nps/description/template
NPS is a lightweight, high-performance, powerful intranet penetration proxy server,
with a powerful web management terminal.
endef
Package/npc/description = $(Package/nps/description/template)
Package/nps/description = $(Package/nps/description/template)
define Build/Compile
$(call GoPackage/Build/Compile)
endef
define Package/nps/install/template
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/$(2) $(1)/usr/bin/$(2)
endef
Package/npc/install = $(call Package/nps/install/template,$(1),npc)
Package/nps/install = $(call Package/nps/install/template,$(1),nps)
$(eval $(call GoBinPackage,npc))
$(eval $(call GoBinPackage,nps))
$(eval $(call BuildPackage,npc))
$(eval $(call BuildPackage,nps))
@@ -1,4 +0,0 @@
diff --git a/cmd/npc/sdk.go b/cmd/npc/sdk.go.bak
similarity index 100%
rename from cmd/npc/sdk.go
rename to cmd/npc/sdk.go.bak
-95
View File
@@ -1,95 +0,0 @@
#
# Copyright (C) 2017-2020
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=qbittorrent
PKG_VERSION:=4.4.5
PKG_RELEASE=1
PKG_SOURCE:=qBittorrent-release-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qbittorrent/qBittorrent/tar.gz/release-$(PKG_VERSION)?
PKG_HASH:=70c2128b44fe4df4dfc8afc765a304d70cf1b042b5214bcc855d8b3bbc9ccf36
PKG_BUILD_DIR:=$(BUILD_DIR)/qBittorrent-release-$(PKG_VERSION)
PKG_LICENSE:=GPL-2.0+
PKG_LICENSE_FILES:=COPYING
PKG_CPE_ID:=cpe:/a:qbittorrent:qbittorrent
PKG_BUILD_DEPENDS:=qttools
PKG_BUILD_PARALLEL:=1
PKG_INSTALL:=1
PKG_USE_MIPS16:=0
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
define Package/qbittorrent
SECTION:=net
CATEGORY:=Network
SUBMENU:=BitTorrent
DEPENDS:=+libgcc +libstdcpp \
+rblibtorrent \
+libopenssl \
+qt5-core \
+qt5-network \
+qt5-sql \
+qt5-xml \
+zlib
TITLE:=bittorrent client programmed in C++ / Qt
URL:=https://www.qbittorrent.org/
PROVIDES:=qBittorrent
endef
define Package/qbittorrent/description
qBittorrent is a bittorrent client programmed in C++ / Qt that uses
libtorrent (sometimes called libtorrent-rasterbar) by Arvid Norberg.
It aims to be a good alternative to all other bittorrent clients out
there. qBittorrent is fast, stable and provides unicode support as
well as many features.
endef
CMAKE_OPTIONS += \
-DCMAKE_BUILD_TYPE=Release \
-DQT6=OFF \
-DSTACKTRACE=OFF \
-DWEBUI=ON \
-DGUI=OFF \
-DVERBOSE_CONFIGURE=ON
# The pcre2 is compiled with support for mips16
ifdef CONFIG_USE_MIPS16
TARGET_CFLAGS += -minterlink-mips16
endif
# Support the glibc
ifdef CONFIG_USE_GLIBC
TARGET_LDFLAGS += -ldl -lrt -lpthread
endif
TARGET_CFLAGS += -std=c++17 -ffunction-sections -fdata-sections -flto
TARGET_LDFLAGS += -Wl,--gc-sections,--as-needed -flto
# The pentium-mmx with lto will build failed on qt6
ifeq ($(ARCH),i386)
ifneq ($(findstring pentium-mmx,$(CONFIG_CPU_TYPE)),)
TARGET_CFLAGS := $(filter-out -flto,$(TARGET_CFLAGS))
TARGET_LDFLAGS := $(filter-out -flto,$(TARGET_LDFLAGS))
endif
endif
define Package/qbittorrent/conffiles
/etc/config/qbittorrent
endef
define Package/qbittorrent/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/qbittorrent-nox $(1)/usr/bin
endef
$(eval $(call BuildPackage,qbittorrent))
-200
View File
@@ -1,200 +0,0 @@
#
# Copyright (C) 2020 Openwrt.org
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
PKG_NAME:=qtbase
PKG_BASE:=5.15
PKG_BUGFIX:=8
PKG_VERSION:=$(PKG_BASE).$(PKG_BUGFIX)
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-everywhere-opensource-src-$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:= \
http://master.qt.io/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules \
http://mirrors.tuna.tsinghua.edu.cn/qt/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules \
http://qt.mirror.constant.com/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules
PKG_HASH:=bfb11126c7f3abc3fdf86425ce912988b864a7e79a606d77325cffdbacb4be9c
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-everywhere-src-$(PKG_VERSION)
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
PKG_CPE_ID:=cpe:/a:qt:qt
include $(INCLUDE_DIR)/package.mk
define Package/qtbase/Default
SECTION:=libs
CATEGORY:=Libraries
SUBMENU:=Qt5
TITLE:=qtbase
URL:=http://qt-project.org
DEPENDS:=+libgcc +libstdcpp @!LINUX_2_6_36
endef
define Package/qtbase/description
This package provides the Qt5 libraries.
Qt is a cross-platform application development framework for desktop, embedded and mobile.
Supported Platforms include Linux, OS X, Windows, VxWorks, QNX, Android, iOS, BlackBerry,
Sailfish OS and others.
Qt is not a programming language on its own. It is a framework written in C++.
This package provides the following run-time libraries:
- core
- network
- xml
endef
define Package/qtbase
$(call Package/qtbase/Default)
TITLE+=all libs
DEPENDS+=$(QTBASE_DEPENDS)
endef
define Package/qtbase/install
true
endef
define Package/qtbase/config
config QT5_INCLUDE_ATOMIC
bool "Depends on libatomic"
default y if !(arm_v7 || ARCH_64BIT || TARGET_x86)
default n
help
Check this options to add the libatomic to the dependences.
- You'd better to confirm whether your compiler need the external libatomic.
Otherwise, it is not recommended to modify this option mannually.
menu "Select Qtbase Libraries"
comment "Qtbase Libraries"
$(foreach lib,$(QTBASE_LIBS),
config PACKAGE_qt5-$(lib)
prompt "Qtbase $(lib) library."
default m if ALL
)
endmenu
endef
PKG_CONFIG_DEPENDS = CONFIG_QT5_INCLUDE_ATOMIC
EXTRA_CFLAGS += $(FPIC) -ffunction-sections -fdata-sections -flto
EXTRA_LDFLAGS += -Wl,--gc-sections,--as-needed
CONFIGURE_ARGS = \
-sysroot $(STAGING_DIR) \
-hostprefix $(STAGING_DIR_HOSTPKG) \
-extprefix $(STAGING_DIR)/usr \
-prefix /usr \
-archdatadir /usr/share/Qt \
-datadir /usr/share/Qt \
-device linux-generic-g++ \
-device-option CROSS_COMPILE="$(TARGET_CROSS)" \
-device-option COMPILER_FLAGS="$(TARGET_CFLAGS) $(EXTRA_CFLAGS) $(TARGET_CPPFLAGS) $(EXTRA_CPPFLAGS)" \
-device-option LINKER_FLAGS="$(TARGET_LDFLAGS) $(EXTRA_LDFLAGS)" \
-confirm-license \
-opensource \
-release \
-shared \
-strip \
-no-rpath \
-no-use-gold-linker \
-ltcg \
-mimetype-database \
-openssl-linked \
-qt-doubleconversion \
-system-pcre \
-system-zlib \
$(if $(findstring i386,$(ARCH)),-no-sse2 -no-sse4.1) \
-no-angle \
-no-cups \
-no-dbus \
-no-directfb \
-no-dtls\
-no-egl \
-no-eglfs \
-no-freetype \
-no-gbm \
-no-glib \
-no-gtk \
-no-gui \
-no-harfbuzz \
-no-iconv \
-no-icu \
-no-kms \
-no-libjpeg \
-no-libmd4c \
-no-libpng \
-no-libudev \
-no-mtdev \
-no-opengl \
-no-opengles3 \
-no-openvg \
-no-pch \
-no-slog2 \
-sql-sqlite \
-no-trace \
-no-tslib \
-no-vulkan \
-no-widgets \
-no-xcb \
-no-xkbcommon \
-no-zstd \
-no-compile-examples \
-no-feature-concurrent \
-no-feature-gssapi \
-no-feature-testlib \
-make libs \
-nomake examples \
-nomake tests \
-nomake tools \
-v
define Build/Compile
+$(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)
endef
define Build/InstallDev
$(MAKE) -C $(PKG_BUILD_DIR) install
endef
define Package/qtbase/Default/install
$(INSTALL_DIR) $(1)/usr/lib
$(CP) $(PKG_BUILD_DIR)/lib/libQt5$(shell echo $(2) | sed -e 's/\b[[:alpha:]]/\u&/g').so* $(1)/usr/lib
endef
define DefineQtbaseLibrary
QTBASE_DEPENDS+=+qt5-$(1)
QTBASE_LIBS+=$(1)
define Package/qt5-$(1)
$(call Package/qtbase/Default)
TITLE+=$(1)
DEPENDS+=$(foreach lib,$(2),+qt5-$(lib)) $(3)
HIDDEN:=1
endef
define Package/qt5-$(1)/description
This package contains the qt5 $(1) library.
endef
define Package/qt5-$(1)/install
$(call Package/qtbase/Default/install,$$(1),$(1))
endef
endef
$(eval $(call DefineQtbaseLibrary,core,,+QT5_INCLUDE_ATOMIC:libatomic +libpcre2-16 +zlib))
$(eval $(call DefineQtbaseLibrary,network,core,+libopenssl +zlib))
$(eval $(call DefineQtbaseLibrary,xml,core,))
$(eval $(call DefineQtbaseLibrary,sql,core))
$(foreach lib,$(QTBASE_LIBS),$(eval $(call BuildPackage,qt5-$(lib))))
$(eval $(call BuildPackage,qtbase))
@@ -1,12 +0,0 @@
diff --color -uNr a/src/corelib/text/qbytearraymatcher.h b/src/corelib/text/qbytearraymatcher.h
--- a/src/corelib/text/qbytearraymatcher.h 2020-10-27 16:02:11.000000000 +0800
+++ b/src/corelib/text/qbytearraymatcher.h 2021-07-19 06:22:30.139372013 +0800
@@ -41,7 +41,7 @@
#define QBYTEARRAYMATCHER_H
#include <QtCore/qbytearray.h>
-
+#include <limits>
QT_BEGIN_NAMESPACE
-50
View File
@@ -1,50 +0,0 @@
#
# Copyright (C) 2019 Openwrt.org
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
PKG_NAME:=qttools
PKG_BASE:=5.15
PKG_BUGFIX:=8
PKG_VERSION:=$(PKG_BASE).$(PKG_BUGFIX)
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-everywhere-opensource-src-$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:= \
http://master.qt.io/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules \
http://mirrors.tuna.tsinghua.edu.cn/qt/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules \
http://qt.mirror.constant.com/archive/qt/$(PKG_BASE)/$(PKG_VERSION)/submodules
PKG_HASH:=a3bef8de13032dae17450f5df35e8abbb4f41f71e3b628871d3da5633577e9c4
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-everywhere-src-$(PKG_VERSION)
PKG_BUILD_PARALLEL:=1
PKG_BUILD_DEPENDS:=qtbase
include $(INCLUDE_DIR)/package.mk
define Package/qttools
SECTION:=libs
CATEGORY:=Libraries
SUBMENU:=Qt5
TITLE:=qttools
URL:=http://qt-project.org
BUILDONLY:=1
endef
define Build/Configure
cd $(PKG_BUILD_DIR) && qmake -o Makefile qttools.pro
endef
define Build/Compile
+$(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)
endef
define Build/InstallDev
$(MAKE) -C $(PKG_BUILD_DIR) install
endef
$(eval $(call BuildPackage,qttools))
-62
View File
@@ -1,62 +0,0 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rblibtorrent
PKG_VERSION:=1.2.18
PKG_RELEASE=1
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/arvidn/libtorrent.git
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_SOURCE_VERSION:=855c44be6370dc90ec1f3dff4223770dd47a5208
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_MIRROR_HASH:=da0626afc80287c8ee6825bfed2d52fe24e8fd9abff9952034292bdc00a2dac1
PKG_LICENSE:=BSD
PKG_LICENSE_FILES:=COPYING
PKG_USE_MIPS16:=0
PKG_BUILD_PARALLEL:=1
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
define Package/rblibtorrent
SECTION:=libs
CATEGORY:=Libraries
TITLE:=Rasterbar BitTorrent library
URL:=https://www.libtorrent.org/
DEPENDS:=+libgcc +libstdcpp +libopenssl +boost +boost-system +boost-chrono +boost-random +libatomic
MAINTAINER:=Arvid Norberg <arvid@libtorrent.org>
endef
define Package/rblibtorrent/description
Rasterbar libtorrent is a C++ library that aims to be a good alternative to
all the other bittorrent implementations around. It is a library and not a
full featured client, although it comes with a working example client.
endef
TARGET_CFLAGS += $(if $(CONFIG_SOFT_FLOAT),-DBOOST_NO_FENV_H) -fPIC -ffunction-sections -fdata-sections -flto
EXTRA_CXXFLAGS += $(if $(CONFIG_GCC_VERSION_4_8),-std=gnu++11,-std=gnu++17)
TARGET_LDFLAGS += -lstdc++ -Wl,--gc-sections,--as-needed -flto
CMAKE_OPTIONS += \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_STANDARD=17 \
-DBUILD_SHARED_LIBS=ON \
-Ddeprecated-functions=ON \
-Dencryption=ON \
-Diconv=ON \
-Dstatic_runtime=ON \
-Dlogging=OFF
define Build/InstallDev
$(INSTALL_DIR) $(1)
$(CP) $(PKG_INSTALL_DIR)/* $(1)
endef
define Package/rblibtorrent/install
$(INSTALL_DIR) $(1)/usr/lib
$(CP) $(PKG_INSTALL_DIR)/usr/lib/*.so* $(1)/usr/lib
endef
$(eval $(call BuildPackage,rblibtorrent))
-50
View File
@@ -1,50 +0,0 @@
#
# Copyright (C) 2019 OpenWrt.org
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=parted
PKG_VERSION:=3.3
PKG_RELEASE:=1
PKG_BUILD_DIR:=$(BUILD_DIR)/parted-$(PKG_VERSION)
PKG_SOURCE:=parted-$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=@GNU/parted
PKG_MD5SUM:=090655d05f3c471aa8e15a27536889ec
include $(INCLUDE_DIR)/package.mk
define Package/parted
SECTION:=utils
CATEGORY:=Utilities
SUBMENU:=Disc
TITLE:=parted Partition editor
URL:=http://www.gnu.org/software/parted/index.shtml
DEPENDS:= +libuuid +libreadline +libncurses +libblkid
endef
define Package/parted/description
parted Partition editor
http://www.gnu.org/software/parted/index.shtml
endef
define Build/Configure
$(call Build/Configure/Default, \
--without-readline \
--disable-device-mapper \
--disable-nls \
)
endef
define Package/parted/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_DIR) $(1)/usr/lib
$(INSTALL_BIN) $(PKG_BUILD_DIR)/libparted/.libs/*.so* $(1)/usr/lib/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/libparted/fs/.libs/*.so* $(1)/usr/lib/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/parted/.libs/parted $(1)/usr/sbin/
$(INSTALL_BIN) $(PKG_BUILD_DIR)/partprobe/.libs/partprobe $(1)/usr/sbin/
endef
$(eval $(call BuildPackage,parted))
+1 -1
View File
@@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic
# DD = day (01 = 1st) # DD = day (01 = 1st)
# NN = daily release sequence (01 = first release of the day, 02 = second, ...) # NN = daily release sequence (01 = first release of the day, 02 = second, ...)
# Example below: 26.10.0101 = October 1, 2026, 1st release of the day # Example below: 26.10.0101 = October 1, 2026, 1st release of the day
PKG_VERSION:=26.10.2002 PKG_VERSION:=26.10.2202
# PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump) # PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump)
# 1 = Lua version (lua branch) → tag: 26.10.0101 # 1 = Lua version (lua branch) → tag: 26.10.0101
# 2 = JS version (main branch) → tag: 26.10.0101-js # 2 = JS version (main branch) → tag: 26.10.0101-js
@@ -39,6 +39,18 @@ case "$PLATFORM" in
*) MENU_ARMCPU=yes ;; *) MENU_ARMCPU=yes ;;
esac esac
# Raise uhttpd max_requests (default 3 is too low for this plugin's concurrent
# polling and can crash some uhttpd builds), but never override a higher value.
cur_max="$(uci -q get uhttpd.main.max_requests 2>/dev/null)"
if [ -z "$cur_max" ] || [ "$cur_max" -lt 50 ] 2>/dev/null; then
uci -q batch <<-EOF >/dev/null
set uhttpd.main.max_requests='50'
commit uhttpd
EOF
# Apply now (skip during image build).
[ -z "${IPKG_INSTROOT}" ] && /etc/init.d/uhttpd reload >/dev/null 2>&1 || true
fi
uci -q batch <<-EOF >/dev/null uci -q batch <<-EOF >/dev/null
delete ucitrack.@amlogic[-1] delete ucitrack.@amlogic[-1]
add ucitrack amlogic add ucitrack amlogic
+3
View File
@@ -0,0 +1,3 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# instead of letting the install die somewhere deep inside a dependency.
engine-strict=true
+19
View File
@@ -0,0 +1,19 @@
{
"name": "luci-app-aurora-config-dev",
"private": true,
"type": "module",
"description": "Build and test harness. htdocs/luci-static/resources is generated from src/resource by scripts/build-js.mjs -- never edit it by hand. Also pins the @eamonxg/luci-theme-tokens version that scripts/sync-tokens.mjs vendors (and lets Renovate/Dependabot propose bumps).",
"engines": { "node": ">=20" },
"scripts": {
"build": "node scripts/build-js.mjs",
"check": "node scripts/build-js.mjs --check",
"test": "node scripts/build-js.mjs --check && node --test tests/*.test.mjs",
"sync-tokens": "node scripts/sync-tokens.mjs",
"gen-presets": "node scripts/gen-presets.mjs",
"gen-font-presets": "node scripts/gen-font-presets.mjs"
},
"devDependencies": {
"@eamonxg/luci-theme-tokens": "2.0.0",
"terser": "^5.43.1"
}
}
+110
View File
@@ -0,0 +1,110 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@eamonxg/luci-theme-tokens':
specifier: 2.0.0
version: 2.0.0
terser:
specifier: ^5.43.1
version: 5.49.1
packages:
'@eamonxg/luci-theme-tokens@2.0.0':
resolution: {integrity: sha512-LSPR6fW5iDN0r4eEfLLVirIgjJxUN8L1uJ3hZDHZRPbHtRE8v21pJSMhZoQKerCXUJx3spZc3W3r9f+baw3Jqw==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
colorjs.io@0.6.1:
resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
terser@5.49.1:
resolution: {integrity: sha512-7A2xlQ5EnGT8KPA92dUh6RbRYTVw8hEaEN9L1K68l4UOXFuV511NnAqObGoRqGOQofQcMypisu1s3xawCEHrvA==}
engines: {node: '>=10'}
hasBin: true
snapshots:
'@eamonxg/luci-theme-tokens@2.0.0':
dependencies:
colorjs.io: 0.6.1
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
acorn@8.18.0: {}
buffer-from@1.1.2: {}
colorjs.io@0.6.1: {}
commander@2.20.3: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
terser@5.49.1:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.18.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -0,0 +1,346 @@
{
"name": "@eamonxg/luci-theme-tokens",
"version": "2.0.0",
"presets": {
"default": {
"light": {
"bg": "#f7fafc",
"surface": "#fff",
"text": "#121a22",
"brand": "#0086bf",
"on_brand": "#fff",
"link": "#0086bf",
"info": "#0e5794",
"warning": "#653e00",
"success": "#00523c",
"danger": "#8d1925",
"text_muted": "#5f666d",
"text_subtle": "#7f858b",
"surface_sunken": "#f4f7fa",
"surface_overlay": "#fcffff",
"hairline": "#121a2221",
"hover_faint": "#ebeff2",
"brand_hover": "#0074ac",
"brand_subtle": "#dfecf5",
"brand_subtle_hover": "#d1dfe8",
"focus_ring": "#0186bf99",
"progress_start": "#70aed5",
"progress_end": "#0086bf",
"info_surface": "#d8f0ff",
"warning_surface": "#ffecd2",
"success_surface": "#d5f8e9",
"danger_surface": "#ffe6e4",
"danger_surface_hover": "#f9d9d7",
"control_bg": "#fcffff",
"scrim": "#0009",
"mega_menu_bg": "#f1f7fb",
"mega_menu_scrim": "#0d1c2a61"
},
"dark": {
"bg": "#05070e",
"surface": "#141822",
"text": "#f9fafb",
"brand": "#00978f",
"on_brand": "#fff",
"link": "#00978f",
"info": "#8dc1ff",
"warning": "#f0ba59",
"success": "#51bd85",
"danger": "#f17070",
"text_muted": "#909297",
"text_subtle": "#5d6066",
"surface_sunken": "#0a0e17",
"surface_overlay": "#191d27",
"hairline": "#f9fafb1a",
"hover_faint": "#f9fafb0d",
"brand_hover": "#008880",
"brand_subtle": "#0a1a20",
"brand_subtle_hover": "#142329",
"focus_ring": "#00978f99",
"progress_start": "#166262",
"progress_end": "#00978f",
"info_surface": "#20344c",
"warning_surface": "#45310b",
"success_surface": "#153524",
"danger_surface": "#541f20",
"danger_surface_hover": "#602a2a",
"control_bg": "#0a0e17",
"scrim": "#0009",
"mega_menu_bg": "#0a0e17",
"mega_menu_scrim": "#00000080"
}
},
"sage-green": {
"light": {
"bg": "#f8f7f4",
"surface": "#fff",
"text": "#1a1f2e",
"brand": "#7c9082",
"on_brand": "#fff",
"link": "#7c9082",
"info": "#0e5794",
"warning": "#653e00",
"success": "#00523c",
"danger": "#8d1925",
"text_muted": "#656973",
"text_subtle": "#84878f",
"surface_sunken": "#f8f7f3",
"surface_overlay": "#fdfcf9",
"hairline": "#1a1f2e21",
"hover_faint": "#f0eeea",
"brand_hover": "#6a7e70",
"brand_subtle": "#e8eae6",
"brand_subtle_hover": "#dbddd9",
"focus_ring": "#7c908299",
"progress_start": "#a6b3a8",
"progress_end": "#7c9082",
"info_surface": "#d8f0ff",
"warning_surface": "#ffecd2",
"success_surface": "#d5f8e9",
"danger_surface": "#ffe6e4",
"danger_surface_hover": "#f9d9d7",
"control_bg": "#fdfcf9",
"scrim": "#0009",
"mega_menu_bg": "#f8f6f0",
"mega_menu_scrim": "#0d1c2a61"
},
"dark": {
"bg": "#0a0a0a",
"surface": "#121212",
"text": "#f5f5f5",
"brand": "#7c9082",
"on_brand": "#000",
"link": "#7c9082",
"info": "#8dc1ff",
"warning": "#f0ba59",
"success": "#51bd85",
"danger": "#f17070",
"text_muted": "#919191",
"text_subtle": "#616161",
"surface_sunken": "#090909",
"surface_overlay": "#161616",
"hairline": "#f5f5f51a",
"hover_faint": "#f5f5f50d",
"brand_hover": "#6d8173",
"brand_subtle": "#1a1c1a",
"brand_subtle_hover": "#232524",
"focus_ring": "#7c908299",
"progress_start": "#4f5b53",
"progress_end": "#7c9082",
"info_surface": "#20344c",
"warning_surface": "#45310b",
"success_surface": "#153524",
"danger_surface": "#541f20",
"danger_surface_hover": "#602a2a",
"control_bg": "#090909",
"scrim": "#0009",
"mega_menu_bg": "#090909",
"mega_menu_scrim": "#00000080"
}
},
"amber-sand": {
"light": {
"bg": "#faf9f5",
"surface": "#faf9f5",
"text": "#3d3929",
"brand": "#c96442",
"on_brand": "#fff",
"link": "#c96442",
"info": "#0e5794",
"warning": "#653e00",
"success": "#00523c",
"danger": "#8d1925",
"text_muted": "#7f7c70",
"text_subtle": "#9a978c",
"surface_sunken": "#f8f7f3",
"surface_overlay": "#fffefa",
"hairline": "#3d392921",
"hover_faint": "#f0eeea",
"brand_hover": "#b55230",
"brand_subtle": "#f6e7df",
"brand_subtle_hover": "#e9dad2",
"focus_ring": "#c9644299",
"progress_start": "#dd9880",
"progress_end": "#c96442",
"info_surface": "#d8f0ff",
"warning_surface": "#ffecd2",
"success_surface": "#d5f8e9",
"danger_surface": "#ffe6e4",
"danger_surface_hover": "#f9d9d7",
"control_bg": "#fffefa",
"scrim": "#0009",
"mega_menu_bg": "#f7f6f0",
"mega_menu_scrim": "#0d1c2a61"
},
"dark": {
"bg": "#262624",
"surface": "#30302e",
"text": "#c3c0b6",
"brand": "#d97757",
"on_brand": "#000",
"link": "#d97757",
"info": "#8dc1ff",
"warning": "#f0ba59",
"success": "#51bd85",
"danger": "#f17070",
"text_muted": "#83817a",
"text_subtle": "#63625d",
"surface_sunken": "#252523",
"surface_overlay": "#353533",
"hairline": "#c3c0b61a",
"hover_faint": "#c3c0b60d",
"brand_hover": "#c86848",
"brand_subtle": "#40332c",
"brand_subtle_hover": "#4b3d37",
"focus_ring": "#d9775799",
"progress_start": "#965a45",
"progress_end": "#d97757",
"info_surface": "#20344c",
"warning_surface": "#45310b",
"success_surface": "#153524",
"danger_surface": "#541f20",
"danger_surface_hover": "#602a2a",
"control_bg": "#252523",
"scrim": "#0009",
"mega_menu_bg": "#252523",
"mega_menu_scrim": "#00000080"
}
},
"monochrome": {
"light": {
"bg": "#fcfcfc",
"surface": "#fff",
"text": "#000",
"brand": "#000",
"on_brand": "#fff",
"link": "#000",
"info": "#0e5794",
"warning": "#653e00",
"success": "#00523c",
"danger": "#8d1925",
"text_muted": "#414141",
"text_subtle": "#676767",
"surface_sunken": "#f7f7f7",
"surface_overlay": "#fff",
"hairline": "#00000021",
"hover_faint": "#eee",
"brand_hover": "#000",
"brand_subtle": "#d5d5d5",
"brand_subtle_hover": "#c8c8c8",
"focus_ring": "#0009",
"progress_start": "#383838",
"progress_end": "#000",
"info_surface": "#d8f0ff",
"warning_surface": "#ffecd2",
"success_surface": "#d5f8e9",
"danger_surface": "#ffe6e4",
"danger_surface_hover": "#f9d9d7",
"control_bg": "#fff",
"scrim": "#0009",
"mega_menu_bg": "#f6f6f6",
"mega_menu_scrim": "#0d1c2a61"
},
"dark": {
"bg": "#000",
"surface": "#090909",
"text": "#fff",
"brand": "#fff",
"on_brand": "#000",
"link": "#fff",
"info": "#8dc1ff",
"warning": "#f0ba59",
"success": "#51bd85",
"danger": "#f17070",
"text_muted": "#868686",
"text_subtle": "#4d4d4d",
"surface_sunken": "#030303",
"surface_overlay": "#0d0d0d",
"hairline": "#ffffff1a",
"hover_faint": "#ffffff0d",
"brand_hover": "#eee",
"brand_subtle": "#0d0d0d",
"brand_subtle_hover": "#161616",
"focus_ring": "#fff9",
"progress_start": "#999",
"progress_end": "#fff",
"info_surface": "#20344c",
"warning_surface": "#45310b",
"success_surface": "#153524",
"danger_surface": "#541f20",
"danger_surface_hover": "#602a2a",
"control_bg": "#030303",
"scrim": "#0009",
"mega_menu_bg": "#030303",
"mega_menu_scrim": "#00000080"
}
},
"sky-blue": {
"light": {
"bg": "#fff",
"surface": "#f7f8f8",
"text": "#0f1419",
"brand": "#1e9df1",
"on_brand": "#fff",
"link": "#1e9df1",
"info": "#0e5794",
"warning": "#653e00",
"success": "#00523c",
"danger": "#8d1925",
"text_muted": "#5f6367",
"text_subtle": "#808487",
"surface_sunken": "#f7f7f7",
"surface_overlay": "#fff",
"hairline": "#0f141921",
"hover_faint": "#eee",
"brand_hover": "#008add",
"brand_subtle": "#e8f4ff",
"brand_subtle_hover": "#dbe7f1",
"focus_ring": "#1e9df199",
"progress_start": "#7cbef5",
"progress_end": "#1e9df1",
"info_surface": "#d8f0ff",
"warning_surface": "#ffecd2",
"success_surface": "#d5f8e9",
"danger_surface": "#ffe6e4",
"danger_surface_hover": "#f9d9d7",
"control_bg": "#fff",
"scrim": "#0009",
"mega_menu_bg": "#f6f6f6",
"mega_menu_scrim": "#0d1c2a61"
},
"dark": {
"bg": "#000",
"surface": "#17181c",
"text": "#e7e9ea",
"brand": "#1c9cf0",
"on_brand": "#fff",
"link": "#1c9cf0",
"info": "#8dc1ff",
"warning": "#f0ba59",
"success": "#51bd85",
"danger": "#f17070",
"text_muted": "#797a7a",
"text_subtle": "#454646",
"surface_sunken": "#0d0e12",
"surface_overlay": "#1c1d21",
"hairline": "#e7e9ea1a",
"hover_faint": "#e7e9ea0d",
"brand_hover": "#008cdf",
"brand_subtle": "#00040c",
"brand_subtle_hover": "#030c15",
"focus_ring": "#1c9cf099",
"progress_start": "#21669a",
"progress_end": "#1c9cf0",
"info_surface": "#20344c",
"warning_surface": "#45310b",
"success_surface": "#153524",
"danger_surface": "#541f20",
"danger_surface_hover": "#602a2a",
"control_bg": "#0d0e12",
"scrim": "#0009",
"mega_menu_bg": "#0d0e12",
"mega_menu_scrim": "#00000080"
}
}
}
}
@@ -0,0 +1,174 @@
#!/usr/bin/env node
// The ONLY writer of htdocs/luci-static/resources. Sources live in
// .dev/src/resource and are never shipped; this turns them into the bytes the
// router serves. uhttpd has no gzip -- there is no Content-Encoding anywhere in
// its source -- so these bytes are the router's flash-read and socket-write cost
// one for one. Rationale and measurements:
// docs/specs/2026-08-05-asset-pipeline-and-color-shim.md
import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { minify } from "terser";
const here = dirname(fileURLToPath(import.meta.url));
const flag = (name, fallback) => {
const i = process.argv.indexOf(name);
return i !== -1 && process.argv[i + 1] ? resolve(process.argv[i + 1]) : fallback;
};
const SRC = flag("--src", resolve(here, "../src/resource"));
const OUT = flag("--out", resolve(here, "../../htdocs/luci-static/resources"));
const check = process.argv.includes("--check");
// From luci-theme-aurora .dev/vite.config.ts. Every flag here is load-bearing:
//
// bare_returns a LuCI view's last statement is a top-level `return`
// directives:false its dependency declarations are string directives, which
// a directive-dropping compressor deletes
//
// And two options must stay at their DEFAULTS, which is why they are absent:
// mangle.toplevel and compress.toplevel. color.global.js and tokens.global.js
// publish themselves through a top-level `var Color` / `var AuroraTokens`;
// turning either on deletes the whole declaration -- measured, the output
// becomes an empty string -- and both pages white-screen with nothing logged.
//
// comments:false strips everything, so third-party licences are re-attached
// from .license sidecars below rather than preserved in place.
const TERSER = {
parse: { bare_returns: true },
compress: { directives: false, passes: 2 },
mangle: true,
format: { comments: false, beautify: false },
};
async function walk(dir, base = dir) {
const found = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return found;
}
for (const entry of entries) {
// Hidden files are never sources and must never be shipped: .DS_Store rode
// along the first time this ran and would have been installed onto every
// router that upgraded.
if (entry.name.startsWith(".")) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) found.push(...(await walk(full, base)));
else found.push(relative(base, full).split("\\").join("/"));
}
return found.sort();
}
// MIT and friends require the copyright and permission notice to travel with
// every copy. Vendored libraries carry theirs as plain // comments, which
// comments:false strips, so each such file gets a <name>.license sidecar whose
// contents are re-attached verbatim as a banner. The sidecar is not itself an
// artifact -- it only ever appears inside the file it belongs to.
async function banner(rel) {
try {
const text = await readFile(join(SRC, `${rel}.license`), "utf8");
return `/*!\n${text.trimEnd()}\n*/\n`;
} catch {
return "";
}
}
// `__ASSET_HASH(<path>)__` becomes the first 8 hex of that source file's
// sha256, so a resource we fetch by a URL we build ourselves can carry a
// version that changes if and only if its contents do.
//
// It has to happen here rather than being stamped into the source, because
// only this script reads every source file. The artifact stays a pure function
// of the sources, so --check is unaffected.
//
// Why it matters: uhttpd dates our installed files Last-Modified: epoch and
// sends no Cache-Control, so the heuristic freshness a browser computes runs to
// years -- it will not even revalidate. Resources LuCI `require`s get LuCI's
// own ?v=; the ones we fetch get this.
const ASSET_HASH = /__ASSET_HASH\(([^)]+)\)__/g;
async function resolveAssetHashes(code, rel) {
const matches = [...code.matchAll(ASSET_HASH)];
let out = code;
for (const [token, target] of matches) {
let contents;
try {
contents = await readFile(join(SRC, target), "utf8");
} catch {
throw new Error(`__ASSET_HASH names ${target}, which is not a source`);
}
const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8);
out = out.split(token).join(hash);
}
return out;
}
async function build(rel) {
const source = await resolveAssetHashes(
await readFile(join(SRC, rel), "utf8"),
rel,
);
if (rel.endsWith(".js")) {
// No sourceMap option at all: terser would otherwise be free to append a
// sourceMappingURL comment, and the map is not installed on the device.
const result = await minify(source, TERSER);
if (typeof result.code !== "string" || result.code.length === 0)
throw new Error(`${rel}: terser produced no output`);
return (await banner(rel)) + result.code;
}
if (rel.endsWith(".json")) return JSON.stringify(JSON.parse(source));
return source;
}
const sources = (await walk(SRC)).filter((rel) => !rel.endsWith(".license"));
if (sources.length === 0) {
console.error(`build-js: no sources under ${SRC}`);
process.exit(1);
}
const built = new Map();
for (const rel of sources) {
try {
built.set(rel, await build(rel));
} catch (error) {
console.error(`build-js: ${rel}: ${error.message}`);
process.exit(1);
}
}
if (check) {
const stale = [];
for (const [rel, content] of built) {
let current = null;
try {
current = await readFile(join(OUT, rel), "utf8");
} catch {}
if (current !== content) stale.push(rel);
}
for (const rel of await walk(OUT))
if (!built.has(rel)) stale.push(`${rel} (orphan: no source)`);
if (stale.length) {
console.error("build-js: artifacts are stale -- run `pnpm build` and commit:");
for (const rel of stale) console.error(` ${rel}`);
process.exit(1);
}
console.log(`build-js: ${built.size} artifacts up to date`);
} else {
// Wipe first so a deleted source cannot leave a shipped artifact behind.
await rm(OUT, { recursive: true, force: true });
let before = 0;
let after = 0;
for (const [rel, content] of built) {
const target = join(OUT, rel);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content, "utf8");
before += (await readFile(join(SRC, rel), "utf8")).length;
after += content.length;
}
console.log(
`build-js: ${built.size} files, ${before} -> ${after} chars ` +
`(${Math.round((after / before) * 100)}%)`,
);
}
@@ -0,0 +1,59 @@
// Regenerates root/usr/share/aurora/font-presets.conf from the curated
// manifest below. Downloads every woff2 from jsDelivr to compute its sha256,
// so the router can verify integrity offline. Run: npm run gen-font-presets
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const OUT = fileURLToPath(
new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url),
);
const SANS_TAIL = '"Lato", ui-sans-serif, system-ui, sans-serif';
const MONO_TAIL = 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace';
// slot, name, label, source, family, stack, [pkg id, version, weights]
const MANIFEST = [
["sans", "default", "Lato", "Built-in", "Lato", `"Lato", ui-sans-serif, system-ui, sans-serif`],
["sans", "system", "System UI", "System", "", `system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`],
["sans", "geist-sans", "Geist Sans", "Fontsource", "Geist Sans", `"Geist Sans", ${SANS_TAIL}`, "geist-sans", "5.2.5", [400, 500, 600, 700]],
["sans", "nunito", "Nunito", "Fontsource", "Nunito", `"Nunito", ${SANS_TAIL}`, "nunito", "5.2.7", [400, 500, 600, 700]],
["sans", "space-grotesk", "Space Grotesk", "Fontsource", "Space Grotesk", `"Space Grotesk", ${SANS_TAIL}`, "space-grotesk", "5.2.10", [400, 500, 600, 700]],
["mono", "default", "System Mono", "Built-in", "", MONO_TAIL],
["mono", "jetbrains-mono", "JetBrains Mono", "Fontsource", "JetBrains Mono", `"JetBrains Mono", ${MONO_TAIL}`, "jetbrains-mono", "5.2.8", [400, 700]],
["mono", "maple-mono", "Maple Mono", "Fontsource", "Maple Mono", `"Maple Mono", ${MONO_TAIL}`, "maple-mono", "5.2.6", [400, 700]],
["mono", "fira-code", "Fira Code", "Fontsource", "Fira Code", `"Fira Code", ${MONO_TAIL}`, "fira-code", "5.2.7", [400, 700]],
["mono", "cascadia-code", "Cascadia Code", "Fontsource", "Cascadia Code", `"Cascadia Code", ${MONO_TAIL}`, "cascadia-code", "5.2.3", [400, 700]],
];
const jsdelivr = (id, ver, w) =>
`https://cdn.jsdelivr.net/npm/@fontsource/${id}@${ver}/files/${id}-latin-${w}-normal.woff2`;
const npmmirror = (id, ver, w) =>
`https://registry.npmmirror.com/@fontsource/${id}/${ver}/files/files/${id}-latin-${w}-normal.woff2`;
const lines = ["v2|generated-by-gen-font-presets|do-not-edit"];
let total = 0;
for (const [slot, name, label, source, family, stack, id, ver, weights] of MANIFEST) {
lines.push(`font|${slot}|${name}|${label}|${source}|${family}|${stack}`);
if (!id) continue;
if (weights.length > 4)
throw new Error(`${slot}/${name} exceeds 4-file budget: ${weights.length} weights`);
let presetBytes = 0;
for (const w of weights) {
const url = jsdelivr(id, ver, w);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
const buf = Buffer.from(await res.arrayBuffer());
presetBytes += buf.length;
const sha256 = createHash("sha256").update(buf).digest("hex");
lines.push(`file|${slot}|${name}|${w}|${sha256}|${url}|${npmmirror(id, ver, w)}`);
total += 1;
}
if (presetBytes > 200 * 1024)
throw new Error(`${slot}/${name} exceeds 200KB budget: ${presetBytes} bytes`);
console.log(`${slot}/${name}: ${weights.length} files, ${(presetBytes / 1024).toFixed(1)}KB`);
}
writeFileSync(OUT, lines.join("\n") + "\n");
console.log(`Wrote ${OUT} (${total} file entries)`);
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// Regenerate po/templates/aurora-config.pot from the translatable strings in
// this package.
//
// LuCI's own i18n-scan.pl lives in the OpenWrt build tree, which is not a
// dependency of this repo -- so the template used to be refreshed by hand and
// drifted from the source. This reproduces the same output: entries sorted by
// msgid, each preceded by "#:" references relative to the LuCI feed root.
//
// Two things the naive version of this script got wrong, both caught by
// diffing against the previous template:
// * _() calls routinely wrap across lines, so the scan runs over whole file
// text rather than line by line;
// * menu.d and acl.d titles/descriptions are translatable too, and LuCI's
// scanner picks them up from the JSON.
import { readFile, writeFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Paths are held as repo-relative strings because they end up verbatim in the
// "#:" reference lines, but they are read through REPO so the script works from
// any working directory (it lives in .dev/scripts, two levels down).
const REPO = join(dirname(fileURLToPath(import.meta.url)), "../..");
const at = (relPath) => join(REPO, relPath);
const PREFIX = "applications/luci-app-aurora-config";
const OUT = "po/templates/aurora-config.pot";
const JS_SCAN = ".dev/src/resource";
// The "#:" references keep naming the installed path: that is where a reader
// of the shipped package finds the string, and holding it steady keeps this
// move out of the .pot and all 15 .po files.
const JS_REF = "htdocs/luci-static/resources";
const JSON_ROOTS = ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"];
async function walk(dir, ext) {
const found = [];
for (const entry of await readdir(at(dir), { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) found.push(...(await walk(path, ext)));
else if (entry.name.endsWith(ext)) found.push(path);
}
return found.sort();
}
// _("...") / _('...'), tolerating newlines and indentation after the paren.
// Concatenated expressions are deliberately not stitched together: LuCI's
// scanner does not either, and a string it cannot see whole is one a
// translator could not use whole.
const CALL = /\b_\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
const entries = new Map();
const record = (raw, file, line) => {
// LuCI trims surrounding whitespace when it looks a string up, which is why
// no msgid in the existing catalogue carries a trailing space even though
// the source writes _("Theme: "). Emitting the untrimmed form would produce
// keys that never resolve, silently dropping those translations.
const msgid = raw.trim();
if (!msgid) return;
if (!entries.has(msgid)) entries.set(msgid, new Set());
entries.get(msgid).add(`${PREFIX}/${file}:${line}`);
};
for (const file of await walk(JS_SCAN, ".js")) {
const text = await readFile(at(file), "utf8");
const ref = JS_REF + file.slice(JS_SCAN.length);
for (const match of text.matchAll(CALL)) {
const line = text.slice(0, match.index).split("\n").length;
record(match[2], ref, line);
}
}
for (const root of JSON_ROOTS) {
for (const file of await walk(root, ".json")) {
const text = await readFile(at(file), "utf8");
text.split("\n").forEach((line, index) => {
const match = /"(?:title|description)"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(line);
if (match) record(match[1], file, index + 1);
});
}
}
const sorted = [...entries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n';
for (const msgid of sorted) {
out += "\n";
for (const ref of [...entries.get(msgid)].sort()) out += `#: ${ref}\n`;
out += `msgid "${msgid}"\nmsgstr ""\n`;
}
await writeFile(at(OUT), out);
console.log(`${OUT}: ${sorted.length} strings`);
@@ -0,0 +1,230 @@
// Regenerate the preset templates under root/usr/share/aurora/ from
// scripts/aurora-presets.json -- resolved preset values precomputed at
// @eamonxg/luci-theme-tokens build time (inputs rounded to hex first, then
// derived, so stored == what the config UI recomputes; see the package's
// build.mjs). This script only formats and injects UCI option lines, and
// mirrors the preset data into the JS sources so the Marketplace can render
// the built-in preset cards offline (.dev/src/resource/aurora/presets.json).
//
// Only the colours come from the vendored json. A preset's layout and
// typography -- navigation shape, spacing, corner radius, content width, both
// font stacks, the toolbar switch -- live in the templates themselves and are
// read back out here: sync-tokens.mjs overwrites aurora-presets.json from the
// npm package on every sync, so anything this repo authors has to live
// somewhere that sync never touches.
//
// Zero dependencies / no build step. Run: node scripts/gen-presets.mjs
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
// here = .dev/scripts, so the repo root is two levels up.
const root = resolve(here, "../..");
const PRESET_DIR = resolve(root, "root/usr/share/aurora");
const SRC_PRESETS = resolve(
root,
".dev/src/resource/aurora/presets.json",
);
// The two groups a hub payload sorts these keys into (see build_share_payload
// in root/usr/libexec/rpcd/luci.aurora). The browser copy is emitted in that
// same shape, so the Marketplace reads a built-in preset with exactly the
// accessors it already uses for a shared configuration.
const LAYOUT_KEYS = [
"nav_type",
"struct_spacing",
"struct_radius_base",
"struct_content_width_centered",
"toolbar_enabled",
];
const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"];
// A hub payload's typography carries the roster id alongside the stack
// (build_share_payload emits both). The id is what says whether the family has
// to be downloaded before the preset looks the way it is drawn -- "default"
// and "system" resolve to faces already on the router, every other id is a
// Fontsource webfont. Recovered from the roster by stack, the same reverse
// lookup resolve_font_preset_id does on the device.
const FONT_PRESETS = resolve(PRESET_DIR, "font-presets.conf");
const fontIdByStack = () => {
const table = { sans: new Map(), mono: new Map() };
for (const line of readFileSync(FONT_PRESETS, "utf8").split("\n")) {
const parts = line.split("|");
if (parts[0] !== "font") continue;
const [, slot, id, , , , stack] = parts;
if (table[slot] && !table[slot].has(stack)) table[slot].set(stack, id);
}
return table;
};
const { presets } = JSON.parse(
readFileSync(resolve(here, "aurora-presets.json"), "utf8"),
);
const colorLines = (preset) => {
const lines = [];
for (const mode of ["light", "dark"])
for (const [key, hex] of Object.entries(presets[preset][mode]))
lines.push(`\toption ${mode}_${key} '${hex}'`);
return lines;
};
const isColorOptionLine = (line) => /^\toption (light|dark)_/.test(line);
// `option <key> '<value>'` -> value. Only the first occurrence counts, the
// same way load_preset_struct_snapshot's awk pass takes it.
const readOption = (source, key) => {
const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source);
return match ? match[1] : null;
};
const fontIds = fontIdByStack();
const structureOf = (preset, source) => {
const pick = (keys) =>
keys.reduce((acc, key) => {
const value = readOption(source, key);
if (value === null)
throw new Error(
`${preset}: template is missing option ${key} -- every built-in ` +
`preset is a whole look, not just a palette`,
);
acc[key] = value;
return acc;
}, {});
const typography = pick(TYPOGRAPHY_KEYS);
for (const slot of ["sans", "mono"]) {
const stack = typography[`struct_font_${slot}`];
const id = fontIds[slot].get(stack);
// A stack the roster does not know would leave the device unable to
// reverse-map it either, so the preset's typeface would silently degrade
// to the next family in the stack. Copy the roster line, do not retype it.
if (!id)
throw new Error(
`${preset}: struct_font_${slot} matches no ${slot} entry in ` +
`font-presets.conf -- copy the stack from its "font|${slot}|..." row`,
);
typography[`font_${slot}`] = id;
}
return { layout: pick(LAYOUT_KEYS), typography };
};
const toolbarOf = (preset, source) => {
const items = [];
let current = null;
for (const raw of source.split("\n")) {
const line = raw.trim();
if (/^config\s+toolbar_item\s*$/.test(line)) {
if (current) items.push(current);
current = { title: "", url: "", icon: "", enabled: "" };
continue;
}
if (/^config\s/.test(line)) {
if (current) items.push(current);
current = null;
continue;
}
if (!current) continue;
const m = line.match(/^option\s+(title|url|icon|enabled)\s+(.*)$/);
if (!m) continue;
current[m[1]] = m[2].replace(/^'(.*)'$/, "$1").replace(/^"(.*)"$/, "$1");
}
if (current) items.push(current);
for (const item of items) {
if (!item.title || !item.url || !/^[01]$/.test(item.enabled))
throw new Error(
`${preset}: a toolbar_item is missing title/url/enabled -- the device ` +
`rejects the whole preset on this (see load_preset_toolbar)`,
);
}
return items.map(({ title, url, icon, enabled }) =>
icon ? { title, url, icon, enabled } : { title, url, enabled },
);
};
// The four colours the Marketplace draws a card from -- marketplace.js SWATCH_KEYS.
// Kept in sync by builtin-presets.test.mjs, which parses that declaration and
// fails if this list drifts from it.
const SWATCH_KEYS = ["bg", "surface", "text", "brand"];
// Flat light_*/dark_* keys, the shape a hub payload's `colors` object uses --
// but only the swatch keys. The templates keep all 62; this copy is downloaded
// by every visitor to the store and the store reads no others. Applying a
// built-in preset does not go through here at all: the browser sends a name and
// apply_theme_preset reads the full template on the router.
const flatColors = (preset) => {
const out = {};
for (const mode of ["light", "dark"])
for (const key of SWATCH_KEYS) {
const hex = presets[preset][mode][key];
if (hex === undefined)
throw new Error(`${preset}: ${mode} is missing the swatch key ${key}`);
out[`${mode}_${key}`] = hex;
}
return out;
};
const browserPresets = {};
for (const preset of Object.keys(presets)) {
const templateFile = preset === "default" ? "default.template" : `${preset}.template`;
const path = resolve(PRESET_DIR, templateFile);
const lines = readFileSync(path, "utf8").split("\n");
const out = [];
let injected = false;
for (const line of lines) {
if (isColorOptionLine(line)) {
if (!injected) {
out.push(...colorLines(preset));
injected = true;
}
continue;
}
out.push(line);
}
if (!injected) throw new Error(`${preset}: no colour block found to replace`);
const source = out.join("\n");
writeFileSync(path, source, "utf8");
console.log(`gen-presets: wrote ${templateFile}`);
const { layout, typography } = structureOf(preset, source);
browserPresets[preset] = {
colors: flatColors(preset),
layout,
typography,
toolbar: toolbarOf(preset, source),
};
}
// Browser copy for the Marketplace's built-in preset cards. Same data as the
// templates, projected into the hub's payload shape; regenerated together so
// the two can never drift.
mkdirSync(dirname(SRC_PRESETS), { recursive: true });
const presetsJson = JSON.stringify({ presets: browserPresets }, null, 2) + "\n";
writeFileSync(SRC_PRESETS, presetsJson, "utf8");
console.log("gen-presets: wrote .dev/src/resource/aurora/presets.json");
// Stamp a content hash into marketplace.js, which appends it as ?v= when fetching
// the file above. uhttpd dates every static resource at the epoch and sends no
// Cache-Control, so a browser's heuristic freshness for it runs to years and it
// never revalidates -- without this stamp, a change here reaches nobody who has
// already opened the store. Same mechanism sync-tokens.mjs uses for the token
// engine; tests/builtin-presets.test.mjs checks the two agree.
const hash = createHash("sha256").update(presetsJson).digest("hex").slice(0, 8);
const stampLine = `const PRESETS_VERSION = "${hash}";`;
const galleryPath = resolve(root, ".dev/src/resource/view/aurora/marketplace.js");
const gallery = readFileSync(galleryPath, "utf8").replace(
/const PRESETS_VERSION = "[^"]*";/,
stampLine,
);
if (!gallery.includes(stampLine))
throw new Error("PRESETS_VERSION marker not found in marketplace.js");
writeFileSync(galleryPath, gallery, "utf8");
console.log(`gen-presets: stamped PRESETS_VERSION=${hash} into marketplace.js`);
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Sync po/<lang>/aurora-config.po against the regenerated template: drop
// entries the template no longer lists, append the ones it gained, and leave
// every existing translation untouched.
//
// Translations for the new strings come from translations.json next to this
// script. A language missing an entry leaves msgstr empty rather than shipping
// an English string dressed as a translation.
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// This script lives in .dev/scripts; the catalogues it edits are at the repo
// root, so nothing here may depend on the working directory.
const here = dirname(fileURLToPath(import.meta.url));
const at = (relPath) => join(here, "../..", relPath);
const POT = at("po/templates/aurora-config.pot");
const TABLE = join(here, "translations.json");
const parsePo = (text) => {
const entries = [];
let refs = [];
let msgid = null;
for (const line of text.split("\n")) {
if (line.startsWith("#:")) refs.push(line);
else if (line.startsWith("msgid ")) msgid = line.slice(7, -1);
else if (line.startsWith("msgstr ") && msgid !== null) {
entries.push({ refs, msgid, msgstr: line.slice(8, -1) });
refs = [];
msgid = null;
}
}
return entries;
};
const potText = await readFile(POT, "utf8");
const potEntries = parsePo(potText).filter((e) => e.msgid !== "");
const table = JSON.parse(await readFile(TABLE, "utf8"));
const langs = [
"de", "es", "fr", "id", "it", "ja", "ko",
"nl", "pl", "ru", "tr", "uk", "zh_Hans", "zh_Hant",
];
for (const lang of langs) {
const path = at(`po/${lang}/aurora-config.po`);
const existing = new Map(
parsePo(await readFile(path, "utf8"))
.filter((e) => e.msgid !== "")
.map((e) => [e.msgid, e.msgstr]),
);
let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n';
let added = 0;
let untranslated = 0;
for (const entry of potEntries) {
// An entry that exists but was never filled in counts as missing: a blank
// msgstr ships the English string, which is exactly what the table is for.
let msgstr = existing.get(entry.msgid);
if (!msgstr) {
if (msgstr === undefined) added += 1;
msgstr = table[entry.msgid]?.[lang] ?? "";
if (!msgstr) untranslated += 1;
}
out += "\n";
for (const ref of entry.refs) out += `${ref}\n`;
out += `msgid "${entry.msgid}"\nmsgstr "${msgstr}"\n`;
}
await writeFile(path, out);
const dropped = [...existing.keys()].filter(
(id) => !potEntries.some((e) => e.msgid === id),
).length;
console.log(
`${lang}: ${potEntries.length} entries (+${added} new, -${dropped} obsolete` +
(untranslated ? `, ${untranslated} UNTRANSLATED` : "") +
")",
);
}
@@ -0,0 +1,138 @@
// Vendor the color-token artifacts from the @eamonxg/luci-theme-tokens npm
// package (renamed from @eamonxg/aurora-tokens; published from the
// luci-theme-tokens repo). Everything here is a plain copy -- all derivation
// happens upstream at package build time:
// - .dev/src/resource/utils/tokens.global.js
// - root/usr/share/aurora/color-tokens.conf
// - .dev/scripts/aurora-presets.json (resolved preset hex values)
// then stamps TOKENS_ENGINE_VERSION into view/aurora/studio.js and reruns
// scripts/gen-presets.mjs. All vendored artifacts are committed; nothing
// downstream (CI, SDK build, device) needs npm or the package repo.
//
// Source resolution:
// 1. --local <path> a local luci-theme-tokens checkout (run `npm install`
// + `node build.mjs` there first)
// 2. npm registry the version pinned in package.json devDependencies
// 3. sibling checkout ../luci-theme-tokens, when the registry is unreachable
//
// Usage: node scripts/sync-tokens.mjs [--local <path>]
// node scripts/sync-tokens.mjs --check exit 1 if outputs are stale
//
// Zero dependencies, same as gen-presets.mjs.
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, dirname, relative, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const here = dirname(fileURLToPath(import.meta.url));
// here = .dev/scripts, so the repo root is two levels up.
const root = resolve(here, "../..");
const PACKAGE = "@eamonxg/luci-theme-tokens";
const check = process.argv.includes("--check");
const argValue = (flag) => {
const index = process.argv.indexOf(flag);
return index !== -1 ? process.argv[index + 1] : null;
};
const pinnedVersion = () =>
JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"))
.devDependencies[PACKAGE];
const buildCheckout = (checkout) => {
const built = spawnSync(process.execPath, [resolve(checkout, "build.mjs")], {
stdio: "inherit",
});
if (built.status !== 0)
throw new Error(`building ${checkout} failed -- run \`npm install\` there first`);
return checkout;
};
const fetchFromRegistry = async (version) => {
const tarballUrl = `https://registry.npmjs.org/${PACKAGE}/-/${PACKAGE.split("/")[1]}-${version}.tgz`;
const response = await fetch(tarballUrl);
if (!response.ok) throw new Error(`fetch ${tarballUrl}: HTTP ${response.status}`);
const dir = mkdtempSync(join(tmpdir(), "luci-theme-tokens-"));
writeFileSync(join(dir, "package.tgz"), Buffer.from(await response.arrayBuffer()));
const extracted = spawnSync("tar", ["-xzf", join(dir, "package.tgz"), "-C", dir], {
stdio: "inherit",
});
if (extracted.status !== 0) throw new Error("tar extraction failed");
return join(dir, "package");
};
async function loadPackage() {
const explicit = argValue("--local");
if (explicit) {
console.log(`sync-tokens: source = local checkout ${explicit}`);
return buildCheckout(explicit);
}
const version = pinnedVersion();
try {
const dir = await fetchFromRegistry(version);
console.log(`sync-tokens: source = npm ${PACKAGE}@${version}`);
return dir;
} catch (error) {
const sibling = resolve(root, "../luci-theme-tokens");
if (existsSync(join(sibling, "build.mjs"))) {
console.warn(
`sync-tokens: WARNING registry unavailable (${error.message}); ` +
`falling back to sibling checkout -- output may not match the pinned ${version}`,
);
return buildCheckout(sibling);
}
throw error;
}
}
const pkgDir = await loadPackage();
const dist = (f) => readFileSync(join(pkgDir, "dist/aurora", f), "utf8");
const version = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")).version;
// Stamp the engine version into theme.js: it appends ?v= when loading
// tokens.global.js so a browser can never pair new theme.js with a cached engine.
const THEME_VIEW = resolve(root, ".dev/src/resource/view/aurora/studio.js");
const versionLine = `const TOKENS_ENGINE_VERSION = "${version}";`;
const themeSource = readFileSync(THEME_VIEW, "utf8").replace(
/const TOKENS_ENGINE_VERSION = "[^"]*";/,
versionLine,
);
if (!themeSource.includes(versionLine))
throw new Error("TOKENS_ENGINE_VERSION marker not found in theme.js");
const outputs = [
[resolve(root, ".dev/src/resource/utils/tokens.global.js"), dist("tokens.global.js")],
[resolve(root, "root/usr/share/aurora/color-tokens.conf"), dist("color-tokens.conf")],
[resolve(here, "aurora-presets.json"), dist("presets.json")],
[THEME_VIEW, themeSource],
];
let stale = false;
for (const [path, content] of outputs) {
const name = relative(root, path);
let current = null;
try {
current = readFileSync(path, "utf8");
} catch {}
if (current === content) {
console.log(`sync-tokens: ${name} up to date`);
continue;
}
if (check) {
console.error(`sync-tokens: ${name} is STALE -- run: node scripts/sync-tokens.mjs`);
stale = true;
continue;
}
writeFileSync(path, content, "utf8");
console.log(`sync-tokens: wrote ${name}`);
}
if (check) process.exit(stale ? 1 : 0);
// Preset templates embed resolved preset values; refresh them from the JSON.
const presets = spawnSync(process.execPath, [resolve(here, "gen-presets.mjs")], {
stdio: "inherit",
});
process.exit(presets.status ?? 1);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,259 @@
{
"presets": {
"default": {
"colors": {
"light_bg": "#f7fafc",
"light_surface": "#fff",
"light_text": "#121a22",
"light_brand": "#0086bf",
"dark_bg": "#05070e",
"dark_surface": "#141822",
"dark_text": "#f9fafb",
"dark_brand": "#00978f"
},
"layout": {
"nav_type": "mega-menu",
"struct_spacing": "0.25rem",
"struct_radius_base": "0.5rem",
"struct_content_width_centered": "80rem",
"toolbar_enabled": "1"
},
"typography": {
"struct_font_sans": "\"Lato\", ui-sans-serif, system-ui, sans-serif",
"struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
"font_sans": "default",
"font_mono": "default"
},
"toolbar": [
{
"title": "Overview",
"url": "/cgi-bin/luci/admin/status/overview",
"icon": "overview.svg",
"enabled": "1"
},
{
"title": "System",
"url": "/cgi-bin/luci/admin/system/system",
"icon": "system.svg",
"enabled": "1"
},
{
"title": "Software",
"url": "/cgi-bin/luci/admin/system/package-manager",
"icon": "software.svg",
"enabled": "0"
},
{
"title": "Network",
"url": "/cgi-bin/luci/admin/network/network",
"icon": "network.svg",
"enabled": "1"
}
]
},
"sage-green": {
"colors": {
"light_bg": "#f8f7f4",
"light_surface": "#fff",
"light_text": "#1a1f2e",
"light_brand": "#7c9082",
"dark_bg": "#0a0a0a",
"dark_surface": "#121212",
"dark_text": "#f5f5f5",
"dark_brand": "#7c9082"
},
"layout": {
"nav_type": "mega-menu",
"struct_spacing": "0.3rem",
"struct_radius_base": "0.875rem",
"struct_content_width_centered": "92rem",
"toolbar_enabled": "1"
},
"typography": {
"struct_font_sans": "\"Nunito\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
"struct_font_mono": "\"Maple Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
"font_sans": "nunito",
"font_mono": "maple-mono"
},
"toolbar": [
{
"title": "Overview",
"url": "/cgi-bin/luci/admin/status/overview",
"icon": "overview.svg",
"enabled": "1"
},
{
"title": "System",
"url": "/cgi-bin/luci/admin/system/system",
"icon": "system.svg",
"enabled": "1"
},
{
"title": "Software",
"url": "/cgi-bin/luci/admin/system/package-manager",
"icon": "software.svg",
"enabled": "0"
},
{
"title": "Network",
"url": "/cgi-bin/luci/admin/network/network",
"icon": "network.svg",
"enabled": "1"
}
]
},
"amber-sand": {
"colors": {
"light_bg": "#faf9f5",
"light_surface": "#faf9f5",
"light_text": "#3d3929",
"light_brand": "#c96442",
"dark_bg": "#262624",
"dark_surface": "#30302e",
"dark_text": "#c3c0b6",
"dark_brand": "#d97757"
},
"layout": {
"nav_type": "sidebar",
"struct_spacing": "0.25rem",
"struct_radius_base": "0.375rem",
"struct_content_width_centered": "96rem",
"toolbar_enabled": "0"
},
"typography": {
"struct_font_sans": "\"Space Grotesk\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
"struct_font_mono": "\"Fira Code\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
"font_sans": "space-grotesk",
"font_mono": "fira-code"
},
"toolbar": [
{
"title": "Overview",
"url": "/cgi-bin/luci/admin/status/overview",
"icon": "overview.svg",
"enabled": "1"
},
{
"title": "System",
"url": "/cgi-bin/luci/admin/system/system",
"icon": "system.svg",
"enabled": "1"
},
{
"title": "Software",
"url": "/cgi-bin/luci/admin/system/package-manager",
"icon": "software.svg",
"enabled": "0"
},
{
"title": "Network",
"url": "/cgi-bin/luci/admin/network/network",
"icon": "network.svg",
"enabled": "1"
}
]
},
"monochrome": {
"colors": {
"light_bg": "#fcfcfc",
"light_surface": "#fff",
"light_text": "#000",
"light_brand": "#000",
"dark_bg": "#000",
"dark_surface": "#090909",
"dark_text": "#fff",
"dark_brand": "#fff"
},
"layout": {
"nav_type": "dropdown",
"struct_spacing": "0.2rem",
"struct_radius_base": "0rem",
"struct_content_width_centered": "88rem",
"toolbar_enabled": "0"
},
"typography": {
"struct_font_sans": "\"Geist Sans\", \"Lato\", ui-sans-serif, system-ui, sans-serif",
"struct_font_mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
"font_sans": "geist-sans",
"font_mono": "jetbrains-mono"
},
"toolbar": [
{
"title": "Overview",
"url": "/cgi-bin/luci/admin/status/overview",
"icon": "overview.svg",
"enabled": "1"
},
{
"title": "System",
"url": "/cgi-bin/luci/admin/system/system",
"icon": "system.svg",
"enabled": "1"
},
{
"title": "Software",
"url": "/cgi-bin/luci/admin/system/package-manager",
"icon": "software.svg",
"enabled": "0"
},
{
"title": "Network",
"url": "/cgi-bin/luci/admin/network/network",
"icon": "network.svg",
"enabled": "1"
}
]
},
"sky-blue": {
"colors": {
"light_bg": "#fff",
"light_surface": "#f7f8f8",
"light_text": "#0f1419",
"light_brand": "#1e9df1",
"dark_bg": "#000",
"dark_surface": "#17181c",
"dark_text": "#e7e9ea",
"dark_brand": "#1c9cf0"
},
"layout": {
"nav_type": "sidebar",
"struct_spacing": "0.25rem",
"struct_radius_base": "0.75rem",
"struct_content_width_centered": "80rem",
"toolbar_enabled": "1"
},
"typography": {
"struct_font_sans": "system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif",
"struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace",
"font_sans": "system",
"font_mono": "default"
},
"toolbar": [
{
"title": "Overview",
"url": "/cgi-bin/luci/admin/status/overview",
"icon": "overview.svg",
"enabled": "1"
},
{
"title": "System",
"url": "/cgi-bin/luci/admin/system/system",
"icon": "system.svg",
"enabled": "1"
},
{
"title": "Software",
"url": "/cgi-bin/luci/admin/system/package-manager",
"icon": "software.svg",
"enabled": "0"
},
{
"title": "Network",
"url": "/cgi-bin/luci/admin/network/network",
"icon": "network.svg",
"enabled": "1"
}
]
}
}
}
@@ -0,0 +1,85 @@
"use strict";
"require baseclass";
const INDICATOR_ID = "aurora-inbox";
const STYLE_ID = "aurora-inbox-indicator";
const INBOX_PATH = "admin/system/aurora/marketplace";
const INBOX_HASH = "#inbox";
const ICON_CLASS = "aurora-inbox-icon";
const INDICATOR = '#indicators span[data-indicator="' + INDICATOR_ID + '"].' + ICON_CLASS;
// Only for themes that hide indicator text and paint ::before by indicator
// name (Aurora, shadcn): there an unknown name is a solid square. Themes that
// show the text keep LuCI's plain "Inbox 3".
const INDICATOR_CSS =
INDICATOR +
"{position:relative;order:1;--aurora-inbox-icon:url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M22 12h-6l-2 3h-4l-2-3H2'/%3e%3cpath d='M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z'/%3e%3c/svg%3e\")}" +
INDICATOR +
"::before{-webkit-mask:var(--aurora-inbox-icon) center/cover no-repeat;mask:var(--aurora-inbox-icon) center/cover no-repeat}" +
INDICATOR +
"[data-count]::after{content:attr(data-count);position:absolute;top:-2px;right:-2px;z-index:10;" +
"display:flex;align-items:center;justify-content:center;min-width:12px;min-height:12px;" +
"padding:0 2px;border-radius:99px;background:var(--danger);color:var(--on-brand);" +
"font-size:8px;font-weight:700;line-height:1;box-shadow:0 1px 2px rgba(0,0,0,.1)}";
const openInbox = () => {
window.location.href = L.url(INBOX_PATH) + INBOX_HASH;
};
const update = () =>
Promise.all([L.require("utils.notices"), L.require("utils.hub-api"), L.require("ui")]).then(
([notices, hubApi, ui]) => {
const count = notices.badgeCount(
hubApi.noticesCache.getStale(),
hubApi.meCache.getStale(),
hubApi.inboxState(),
);
if (!count) return ui.hideIndicator(INDICATOR_ID);
if (!document.getElementById(STYLE_ID)) {
const style = E("style", { id: STYLE_ID });
style.textContent = INDICATOR_CSS;
document.head.appendChild(style);
}
ui.showIndicator(INDICATOR_ID, _("Inbox") + " " + count, openInbox, "active");
const node = document.querySelector('span[data-indicator="' + INDICATOR_ID + '"]');
if (node) {
node.setAttribute("data-count", count);
node.title = node.textContent;
if (window.getComputedStyle(node).fontSize === "0px") node.classList.add(ICON_CLASS);
// Last in the DOM and the highest order: after the existing indicators in
// either row direction, and still there when a later one appears.
node.parentNode.appendChild(node);
}
},
);
const run = () =>
Promise.all([L.require("utils.notices"), L.require("utils.hub-api")])
.then(([notices, hubApi]) =>
notices.isDue(hubApi.noticesCheckedAt(), Date.now())
? hubApi.refreshNotices().catch(() => null)
: null,
)
.then(update);
return baseclass.extend({
INBOX_HASH: INBOX_HASH,
__init__() {
if (!L.env.sessionid) return;
document.addEventListener(
"luci-loaded",
() =>
(window.requestIdleCallback || window.setTimeout)(() =>
Promise.resolve().then(run).catch(() => {}),
),
{ once: true },
);
},
update() {
return Promise.resolve().then(update).catch(() => {});
},
});
@@ -0,0 +1,399 @@
"use strict";
"require baseclass";
"require rpc";
"require ui";
// Shared upload plumbing for the config UI. Two surfaces consume it (custom
// fonts, brand asset library); anything font- or image-specific stays with
// the caller -- this module only knows about files, bytes, and the
// cgi-upload channel.
const MAX_UPLOAD = 8 * 1024 * 1024;
const formatSize = (bytes) =>
bytes >= 1048576
? (bytes / 1048576).toFixed(1) + " MB"
: Math.max(1, Math.round(bytes / 1024)) + " KB";
const extOf = (name) => (name.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
return baseclass.extend({
MAX_UPLOAD,
formatSize,
extOf,
// Pure pre-check for instant feedback; the rpcd receive_upload gate stays
// authoritative. exts is a lowercase list without dots, e.g. ["woff2"].
checkFile(file, opts) {
const exts = opts?.exts || [];
const ext = extOf(file.name);
if (exts.length && !exts.includes(ext))
return {
ok: false,
err: _("Unsupported file type. Allowed: %s").format(exts.join(", ")),
};
if (file.size > MAX_UPLOAD)
return {
ok: false,
err: _("File is %s, exceeding the 8MB limit.").format(
formatSize(file.size),
),
};
return { ok: true, err: "" };
},
// XHR to /cgi-bin/cgi-upload. Resolves on HTTP 200; the caller still has
// to run its own RPC confirm (upload_font / upload_icon) afterwards.
uploadToRouter(opts) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (opts.onProgress)
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable)
opts.onProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.addEventListener("load", () =>
xhr.status === 200
? resolve()
: reject(new Error(_("Upload failed (HTTP %s)").format(xhr.status))),
);
xhr.addEventListener("error", () =>
reject(new Error(_("Upload failed"))),
);
const formData = new FormData();
formData.append("sessionid", rpc.getSessionID());
formData.append("filename", opts.tmpPath);
formData.append("filemode", "0600");
formData.append("filedata", opts.file, opts.file.name);
xhr.open("POST", "/cgi-bin/cgi-upload");
xhr.withCredentials = true;
xhr.send(formData);
});
},
// Composite "asset manager": a native LuCI `.table` (preview | name |
// badge | size | delete) sitting on a dock that shows either the slim
// upload bar, the in-place confirm form, or a progress row -- never more
// than one at a time. Both custom-fonts and the brand asset library
// render through this; the only per-kind knowledge lives in the caller's
// row/bar/form/checkFile/upload callbacks. Table markup deliberately
// mirrors the theme's other cbi tables (`table` / `tr table-titles` /
// `th` / `tr` / `td`) so it inherits the Aurora theme's colors, dark
// mode, and row hover instead of fighting it with bespoke inline chrome.
//
// cfg = {
// badgeHeader, // _("Slot") or _("Type")
// emptyText,
// rows: [{ preview, name, badge, size, onDelete() }], // badge: plain text or falsy
// bar: { hint, sub, accept },
// checkFile(file) -> { ok, err },
// form: {
// fields(file) -> {
// rows: [{label, control}], // rendered as cbi-value label/field rows
// value(), valid(), setDisabled(bool), // bool reaches every control
// },
// },
// upload(file, meta, onProgress) -> Promise, // caller composes
// // uploadToRouter + RPC
// }
createAssetManager(cfg) {
const rows = cfg.rows || [];
const shell = E("div", {});
const dock = E("div", {});
shell.appendChild(dock);
if (rows.length) {
shell.appendChild(
E("table", { class: "table" }, [
E("tr", { class: "tr table-titles" }, [
E("th", { class: "th", style: "width:56px;" }, ""),
E("th", { class: "th" }, _("Name")),
E("th", { class: "th" }, cfg.badgeHeader),
E("th", { class: "th" }, _("Size")),
E("th", { class: "th center" }, ""),
]),
...rows.map((row) =>
E("tr", { class: "tr" }, [
E("td", { class: "td" }, row.preview),
E(
"td",
{ class: "td", style: "word-break:break-all;" },
row.name,
),
E(
"td",
{
class: "td",
style: "color:var(--text-muted);font-size:0.9em;",
},
row.badge || "",
),
E(
"td",
{
class: "td",
style:
"color:var(--text-muted);font-variant-numeric:tabular-nums;" +
"white-space:nowrap;",
},
formatSize(row.size || 0),
),
E(
"td",
{ class: "td center" },
E(
"button",
{
type: "button",
class: "cbi-button cbi-button-remove",
click: row.onDelete,
},
_("Delete"),
),
),
]),
),
]),
);
} else {
shell.appendChild(
E("div", { style: "padding:0.5em 0;" }, [E("em", {}, cfg.emptyText)]),
);
}
const clearDock = () => {
while (dock.firstChild) dock.firstChild.remove();
};
const renderBar = () => {
clearDock();
const input = E("input", {
type: "file",
style: "display:none;",
accept: (cfg.bar && cfg.bar.accept) || "",
});
input.addEventListener("change", () => {
const file = input.files && input.files[0];
input.value = "";
if (file) onFile(file);
});
const bar = E(
"button",
{
type: "button",
style:
"display:block;width:100%;" +
"border:2px dashed var(--hairline);border-radius:0.5em;" +
"padding:1.25em 1em;text-align:center;cursor:pointer;" +
"margin-bottom:0.75em;" +
"transition:border-color 0.15s,background 0.15s;",
click: () => input.click(),
dragover: (e) => {
e.preventDefault();
bar.style.borderColor = "var(--brand)";
bar.style.background = "var(--brand-subtle)";
},
dragleave: () => {
bar.style.borderColor = "";
bar.style.background = "";
},
drop: (e) => {
e.preventDefault();
bar.style.borderColor = "";
bar.style.background = "";
const file = e.dataTransfer && e.dataTransfer.files[0];
if (file) onFile(file);
},
},
[
E(
"div",
{ style: "font-size:1.5em;margin-bottom:0.25em;pointer-events:none;" },
"⬆",
),
E("strong", { style: "pointer-events:none;" }, cfg.bar.hint),
E(
"div",
{
style:
"font-size:0.8em;opacity:0.6;margin-top:0.25em;pointer-events:none;",
},
cfg.bar.sub || "",
),
],
);
dock.appendChild(bar);
dock.appendChild(input);
};
const buildForm = (file, check) => {
const fields = cfg.form.fields(file);
const progressBar = E("div", {
style:
"height:100%;width:0%;transition:width 0.12s linear;" +
"background:var(--brand);border-radius:999px;",
});
const progressWrap = E(
"div",
{
style:
"display:none;height:5px;border-radius:999px;" +
"background:var(--hairline);overflow:hidden;margin-top:0.6em;",
},
[progressBar],
);
const setProgress = (p) => {
progressBar.style.width = p + "%";
};
const showProgress = () => {
progressWrap.style.display = "block";
};
const hideProgress = () => {
progressWrap.style.display = "none";
setProgress(0);
};
let busy = false;
const cancelBtn = E(
"button",
{ type: "button", class: "cbi-button", click: renderBar },
_("Cancel"),
);
const fileSummary = E("div", { style: "margin-bottom:0.6em;" }, [
E("strong", { style: "word-break:break-all;" }, file.name),
" · " + formatSize(file.size),
]);
const errEl = check.ok
? null
: E(
"p",
{
style:
"color:var(--danger);font-weight:600;font-size:0.9em;" +
"margin:0 0 0.6em;",
},
check.err,
);
const rowsEl = E(
"div",
{ style: "margin-bottom:0.6em;" },
fields.rows.map((row) =>
E("div", { class: "cbi-value" }, [
E("label", { class: "cbi-value-title" }, row.label),
E("div", { class: "cbi-value-field" }, row.control),
]),
),
);
const goBtn = E(
"button",
{ type: "button", class: "cbi-button cbi-button-positive" },
_("Confirm Upload"),
);
const updateGoState = () => {
if (busy) return;
goBtn.disabled = !check.ok || !fields.valid();
};
updateGoState();
rowsEl.addEventListener("input", updateGoState);
rowsEl.addEventListener("change", updateGoState);
const actionsRow = E("div", { class: "right" }, [
cancelBtn,
" ",
goBtn,
]);
goBtn.addEventListener("click", () => {
busy = true;
goBtn.disabled = true;
cancelBtn.disabled = true;
fields.setDisabled(true);
showProgress();
setProgress(0);
const meta = fields.value();
cfg
.upload(file, meta, setProgress)
.catch((err) => {
busy = false;
cancelBtn.disabled = false;
fields.setDisabled(false);
hideProgress();
updateGoState();
ui.addNotification(
null,
E("p", _("Upload failed: %s").format(err.message)),
"error",
);
});
});
return E(
"div",
{
style:
"margin-bottom:0.5em;padding-bottom:0.6em;" +
"border-bottom:1px solid var(--hairline);",
},
[fileSummary, errEl, rowsEl, actionsRow, progressWrap].filter(Boolean),
);
};
const onFile = (file) => {
const check = cfg.checkFile(file);
clearDock();
dock.appendChild(buildForm(file, check));
};
renderBar();
return shell;
},
confirmDelete(opts) {
return new Promise((resolve) => {
ui.showModal(opts.title, [
E("p", {}, opts.message),
E("div", { class: "right" }, [
E(
"button",
{
class: "btn",
click: () => {
ui.hideModal();
resolve(false);
},
},
_("Cancel"),
),
" ",
E(
"button",
{
class: "btn cbi-button-negative",
click: () => {
ui.hideModal();
resolve(true);
},
},
_("Delete"),
),
]),
]);
});
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
colorjs.io -- https://colorjs.io -- MIT License
Copyright (c) 2021 Lea Verou, Chris Lilley
This build embeds the following, each under the MIT License:
The MIT License (MIT)
Copyright (c) 2024 Matt DesLauriers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------
Copyright (c) 2021 Björn Ottosson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,52 @@
"use strict";
"require baseclass";
// 更新源相关的纯判断:版本比对与软件包页定位。
// 刻意不碰 DOM、不发请求 —— 这样它们能被真正单元测试,而不是只做正则断言。
// 软件包页的地址在各 OpenWrt 版本间不一致(主题支持 23.05 及以后):
// snapshot 上实测是 package-manager,更早的版本用 opkg。不硬编码猜测,而是
// 从 ui.menu.load() 的真实菜单树里找,顺带覆盖两种本来要单独处理的情况:
// 用户无权访问该页(节点带 satisfied:false),以及精简固件根本没装这个页面。
const PACKAGE_PAGE_CANDIDATES = ["package-manager", "opkg", "packages"];
return baseclass.extend({
PACKAGE_PAGE_CANDIDATES: PACKAGE_PAGE_CANDIDATES,
// "1.1.3-r20260803" → 20260803;其余形态一律 null。
parseRevision: function (version) {
const match = /-r(\d{8})\b/.exec(String(version == null ? "" : version));
return match ? Number(match[1]) : null;
},
// install.sh 写明 opkg 与 apk 的版本方案不可比较、脚本从不排序它们。
// 这里沿用同一条纪律:只有两边都带 rYYYYMMDD 戳时才敢说"有新版"。
// 判不出来就沉默 —— 误报"有新版"比不报更糟。
isNewer: function (installed, available) {
const a = this.parseRevision(installed);
const b = this.parseRevision(available);
if (a === null || b === null) return false;
return b > a;
},
// 从 manifest.json 里取某渠道某包的版本。结构由 feed 的 gen-manifest.sh
// 生成:channels[渠道][格式] 是一个 {pkg, version} 数组。
findManifestVersion: function (manifest, channel, format, pkg) {
const entries = manifest?.channels?.[channel]?.[format];
if (!Array.isArray(entries)) return null;
const hit = entries.find((entry) => entry?.pkg === pkg);
return hit?.version || null;
},
pickPackageManagerPath: function (menuTree) {
const system = menuTree?.children?.admin?.children?.system?.children;
if (!system) return null;
const name = PACKAGE_PAGE_CANDIDATES.find((candidate) => {
const node = system[candidate];
// ui.menu 保留无权限的节点但标记 satisfied:false(getChildren 就是按它
// 过滤的)。不检查这个的话,会给没权限的用户渲染出一条点了吃 403 的链接。
return node && node.satisfied !== false;
});
return name ? `admin/system/${name}` : null;
},
});
@@ -0,0 +1,385 @@
"use strict";
"require baseclass";
"require rpc";
"require utils.notices as notices";
const CACHE_KEY = "aurora.hub.list";
const ME_CACHE_KEY = "aurora.hub.me";
const NOTICES_CACHE_KEY = "aurora.hub.notices";
const INBOX_READ_KEY = "aurora.hub.inboxRead";
const INBOX_DONE_KEY = "aurora.hub.inboxDone";
const NOTICES_CHECKED_KEY = "aurora.hub.noticesChecked";
const HUB_BASE = "https://themes.eamonxg.fun";
const HUB_TIMEOUT_MS = 15000;
const HUB_NOT_FOUND = "invalid_id";
// 本客户端读得懂的最高 payload schema。升 schema 时只改这一处。
const CLIENT_SCHEMA = 1;
// 浏览器直连 hub 的读路径。返回包络刻意与 rpcd 保持一致
// ({result:0,data} / {result:1,error}),marketplace.js 无需感知传输方式的变化。
const hubFetch = (path) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), HUB_TIMEOUT_MS);
return fetch(HUB_BASE + path, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then((res) => {
if (res.status === 404) return { result: 1, error: HUB_NOT_FOUND };
if (!res.ok) return { result: 1, error: "hub_unreachable" };
return res.json().then((data) => ({ result: 0, data }));
})
.catch(() => ({ result: 1, error: "hub_unreachable" }))
.finally(() => clearTimeout(timer));
};
// 资产字节走浏览器,不走路由器。原因不是"更快"(虽然确实快),是 OpenWrt 的
// uclient-fetch 走 TLS 根本推不动:实测同一台机器,512KB 三次里挂一次、1MB
// 必断("Connection reset prematurely"),而同机 curl 传 1.6MB 只要 3.4 秒
// —— 是它的 TLS 写路径在缓冲填满后不再续写。浏览器 fetch/XHR 没这个毛病。
//
// 浏览器拿到的是一张票据,不是 device_token:票据把 (draft_id, kind, size,
// sha256) 全钉死,泄漏了也只能把同一份字节再传一遍。device_token 是创作者
// 身份本身 —— 谁拿到谁能以你的名义发布、删掉你所有作品 —— 它留在路由器
// 那个 0600 的文件里。
//
// 用 XHR 而不是 fetch,只为了 upload.onprogress:一张 1.2MB 的图在慢上行的
// 线路上要传十几秒,没有进度条的等待会被当成卡死。
const putAssetBytes = (url, ticket, blob, onProgress) =>
new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Authorization", "Bearer " + ticket);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.timeout = 120000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
};
xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300);
xhr.onerror = () => resolve(false);
xhr.ontimeout = () => resolve(false);
xhr.send(blob);
});
// hub 的资产 url 是相对路径("/assets/{id}/{kind}"),和详情接口一直以来的
// 返回一致 —— 拼接 base 是客户端的活,这样缓存下来的列表里不会烙进主机名。
// HUB_BASE 归本模块所有,所以拼接也放在这里。
//
// 路径来自 hub,属于不可信输入,因此按 hub 唯一可能产出的形状做匹配,而不是
// 直接拼。其它一律返回 "",调用方退回纯色块。
const ASSET_PATH_RE = /^\/assets\/[A-Za-z0-9]{1,32}\/[a-z0-9_]{1,32}$/;
// 两份 localStorage 缓存的共同实现。它们只作首帧的种子:render 先用缓存画出
// 画面,随后 fetchSort / refreshMyShares 的结果无条件覆盖 —— 所以刻意不设
// TTL。到 hub 的每次往返都是 205ms 起步、首连约 800ms(TLS 握手不可复用),
// 一份"旧但立刻可用"的数据,比一次准确的白屏有用得多。
//
// 信封保留 timestamp:它没有读者,但在真机上翻 localStorage 时,它是唯一能看
// 出这帧画面有多旧的东西,而且保持了与旧版本写入的条目同形,无需迁移。
const makeCache = (key, label) => ({
getStale() {
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
return JSON.parse(cached).value ?? null;
} catch (e) {
return null;
}
},
set(value) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), value }));
} catch (e) {
console.error("Failed to cache hub " + label + " data:", e);
}
},
clear() {
try {
localStorage.removeItem(key);
} catch (e) {}
},
});
const readStored = (key, fallback) => {
try {
const parsed = JSON.parse(localStorage.getItem(key));
return parsed == null ? fallback : parsed;
} catch (e) {
return fallback;
}
};
const writeStored = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {}
};
const patchSnapshot = (cache, patch) => {
const stale = cache.getStale();
const snapshot = Object.assign(
{
notices: notices.sanitize(stale && stale.notices),
local: (stale && stale.local) || null,
},
patch,
);
cache.set(snapshot);
return snapshot;
};
return baseclass.extend({
listCache: makeCache(CACHE_KEY, "list"),
meCache: makeCache(ME_CACHE_KEY, "me"),
// { notices, local }
noticesCache: makeCache(NOTICES_CACHE_KEY, "notices"),
CLIENT_SCHEMA: CLIENT_SCHEMA,
callHubList(sort, page) {
const safeSort = sort === "new" ? "new" : "hot";
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
return hubFetch(
"/api/v1/themes/aurora/configs?sort=" +
safeSort +
"&page=" +
safePage +
"&schema=" +
CLIENT_SCHEMA,
);
},
callHubNotices() {
return hubFetch("/api/v1/notices?theme=aurora&schema=" + CLIENT_SCHEMA).then((res) =>
res.error === HUB_NOT_FOUND ? { result: 0, data: { notices: [] } } : res,
);
},
noticesCheckedAt() {
const stamp = readStored(NOTICES_CHECKED_KEY, 0);
return Number.isFinite(stamp) ? stamp : 0;
},
markNoticesChecked(nowMs) {
writeStored(NOTICES_CHECKED_KEY, nowMs);
},
inboxState() {
const keys = (key) => {
const list = readStored(key, []);
return Array.isArray(list) ? list.filter((item) => typeof item === "string") : [];
};
return { read: keys(INBOX_READ_KEY), done: keys(INBOX_DONE_KEY) };
},
setInboxState(state) {
writeStored(INBOX_READ_KEY, state.read);
writeStored(INBOX_DONE_KEY, state.done);
},
// local 随 feed 一起落缓存:preload 在两次轮询之间只读缓存,
// 不为"缺不缺更新源"去问 ubus。
setNoticesLocal(local) {
return patchSnapshot(this.noticesCache, { local: local });
},
// options.me:调用方自己已经在跑 hub_me 时,把那份结果(或它的 promise)传
// 进来。不传则只在 feed 里有 creators 通知、或缓存显示本机发过作品时才调
// hub_me(路由器 → hub,约 1 s)。
refreshNotices(options) {
const opts = options || {};
const supplied =
opts.me === undefined ? null : Promise.resolve(opts.me).catch(() => null);
this.markNoticesChecked(Date.now());
const probed = L.resolveDefault(this.callGetInitData(), null).then((reply) =>
notices.localState(reply),
);
return Promise.all([this.callHubNotices(), probed]).then(([res, fresh]) => {
const stale = this.noticesCache.getStale();
const local = fresh || (stale && stale.local) || null;
const known = { feed: Boolean(res && res.result === 0), me: false, local: Boolean(fresh) };
const snapshot = {
notices: notices.sanitize(known.feed ? res.data && res.data.notices : stale && stale.notices),
local: local,
};
const list = snapshot.notices;
const cachedMe = this.meCache.getStale();
const ask = () =>
known.feed &&
(list.some((notice) => notice.audience === "creators") || notices.hasShares(cachedMe))
? L.resolveDefault(this.callHubMe(), null).then((reply) => {
const me = reply && reply.result === 0 && reply.data ? reply.data : null;
if (me) this.meCache.set(me);
return me;
})
: Promise.resolve(null);
return (supplied || ask()).then((me) => {
this.noticesCache.set(snapshot);
const active = notices.inbox(snapshot, me || cachedMe, null).map((item) => item.key);
const state = this.inboxState();
known.me = Boolean(me);
this.setInboxState({
read: notices.pruneKeys(state.read, active, known),
done: notices.pruneKeys(state.done, active, known),
});
return snapshot;
});
});
},
callHubGet(id) {
if (!/^[A-Za-z0-9]+$/.test(String(id || ""))) {
return Promise.resolve({ result: 1, error: HUB_NOT_FOUND });
}
return hubFetch("/api/v1/themes/aurora/configs/" + id);
},
hubAssetUrl(path) {
return typeof path === "string" && ASSET_PATH_RE.test(path)
? HUB_BASE + path
: "";
},
callHubApply: rpc.declare({
object: "luci.aurora",
method: "hub_apply",
params: ["id"],
}),
callGetHubStatus: rpc.declare({
object: "luci.aurora",
method: "get_hub_status",
params: ["job_id"],
}),
callHubRestore: rpc.declare({
object: "luci.aurora",
method: "hub_restore_backup",
}),
// 纯本地:回答「路由器现在穿的是哪一套、还是不是那一套、被盖掉的那套长什么
// 样」。不走 hub,所以不进上面那套缓存 —— 它便宜到可以每次开页面都问一遍。
callHubLocalState: rpc.declare({
object: "luci.aurora",
method: "hub_local_state",
}),
// 三段式发布的两端。中间那段(资产字节)由 publishCurrentConfig 用浏览器
// 直接 PUT 到 hub —— 见下。target_id 为空串是新发布、非空是更新那一条,
// 所以"更新分享"不需要自己的一套方法:它跟发布本来就是同一件事,只差目标。
callHubShareBegin: rpc.declare({
object: "luci.aurora",
method: "hub_share_begin",
params: ["name", "description", "target_id"],
}),
callHubShareCommit: rpc.declare({
object: "luci.aurora",
method: "hub_share_commit",
params: ["draft_id"],
}),
// 发布一整套配置:路由器建草稿并拿票据 -> 浏览器把字节直接送到 hub ->
// 路由器提交。返回的信封与其它 hub 调用一致({result:0,...})。
publishCurrentConfig(options) {
const opts = options || {};
const report = (info) => {
if (typeof opts.onProgress === "function") opts.onProgress(info);
};
report({ phase: "begin" });
return L.resolveDefault(
this.callHubShareBegin(opts.name, opts.description || "", opts.targetId || ""),
null,
).then((begun) => {
if (!begun || begun.result !== 0) {
return { result: 1, error: (begun && begun.error) || "hub_unreachable" };
}
// 内容与已有的一模一样:hub 直接给了那条的 id,一个字节都不必传。
if (begun.duplicate) return { result: 0, id: begun.id, duplicate: true };
const entries = begun.assets || [];
let index = 0;
// 顺序上传,不并发:一台路由器的上行本来就窄,几条流互相抢只会让每一条
// 都变慢,而进度条上也读不出"到底传到哪了"。
const next = () => {
if (index >= entries.length) {
report({ phase: "commit" });
// assets 是这一趟真正传上去的份数。调用方据此决定要不要提审核 ——
// 它已经在手里,不必为一句话再往 hub 跑一趟。
return L.resolveDefault(this.callHubShareCommit(begun.draft_id), null).then(
(done) =>
done && done.result === 0
? { result: 0, id: done.id, assets: entries.length }
: { result: 1, error: (done && done.error) || "hub_unreachable" },
);
}
const entry = entries[index];
const position = { kind: entry.kind, index: index + 1, count: entries.length };
index += 1;
// 字节从路由器自己的 web 服务器上读,同源,不需要任何凭证。
return fetch(entry.src)
.then((res) => (res.ok ? res.blob() : null))
.catch(() => null)
.then((blob) => {
if (!blob) return { result: 1, error: "asset_unreadable" };
report({ phase: "upload", loaded: 0, total: blob.size, ...position });
return putAssetBytes(HUB_BASE + entry.url, entry.ticket, blob, (loaded, total) =>
report({ phase: "upload", loaded, total, ...position }),
).then((ok) => (ok ? next() : { result: 1, error: "asset_upload_failed" }));
});
};
return next();
});
},
callHubSetNickname: rpc.declare({
object: "luci.aurora",
method: "hub_set_nickname",
params: ["nickname"],
}),
callGetInitData: rpc.declare({
object: "luci.aurora",
method: "get_init_data",
}),
callAddFeed: rpc.declare({
object: "luci.aurora",
method: "add_feed",
}),
callHubMe: rpc.declare({
object: "luci.aurora",
method: "hub_me",
}),
// Both sit behind the write ACL: the key is the creator account's password,
// so reading it out is as much a privileged act as replacing it.
callHubExportKey: rpc.declare({
object: "luci.aurora",
method: "hub_export_key",
}),
callHubImportKey: rpc.declare({
object: "luci.aurora",
method: "hub_import_key",
params: ["key"],
}),
callHubDelete: rpc.declare({
object: "luci.aurora",
method: "hub_delete",
params: ["id"],
}),
});
@@ -0,0 +1,249 @@
"use strict";
"require baseclass";
// 两条标记注释之间是纯函数,hub 管理台的预览由同步脚本逐字取用这一段:
// 里面不得引用 LuCI、window 或本文件的其它部分。
// <markdown-core>
const MD_MAX_DEPTH = 2;
const MD_HEADING_OFFSET = 3;
const MD_FENCE = "```";
const MD_HEADING_RE = /^(#{1,3}) +(.*)$/;
const MD_RULE_RE = /^-{3,} *$/;
const MD_QUOTE_RE = /^> ?/;
const MD_BULLET_RE = /^[-*] +/;
const MD_NUMBER_RE = /^[0-9]{1,9}[.] +/;
const MD_EXTERNAL_RE = /^https:/;
function mdClean(src) {
const text = typeof src === "string" ? src : "";
let out = "";
let from = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
if ((code < 32 && code !== 9 && code !== 10) || code === 127) {
out += text.slice(from, i);
from = i + 1;
}
}
return out + text.slice(from);
}
// 每个分隔符只向右找一次「最近的闭合」;找不到就记下来,同一种分隔符不再找。
// 方括号的扫描遇到下一个 "[" 就停。两条合起来保证整体是线性的。
function mdInline(text, depth) {
const tokens = [];
const dead = {};
let plain = "";
let i = 0;
const flush = () => {
if (plain) tokens.push({ type: "text", text: plain });
plain = "";
};
const closer = (mark, from) => {
if (dead[mark]) return -1;
const at = text.indexOf(mark, from);
if (at === -1) dead[mark] = true;
return at;
};
const nested = (inner) =>
depth < MD_MAX_DEPTH ? mdInline(inner, depth + 1) : [{ type: "text", text: inner }];
while (i < text.length) {
const ch = text[i];
if (ch === "`") {
const end = closer("`", i + 1);
if (end > i + 1) {
flush();
tokens.push({ type: "code", text: text.slice(i + 1, end) });
i = end + 1;
continue;
}
} else if (ch === "*") {
const mark = text[i + 1] === "*" ? "**" : "*";
const start = i + mark.length;
if (start < text.length && text[start] !== " ") {
const end = closer(mark, start);
if (end > start && text[end - 1] !== " ") {
flush();
tokens.push({
type: mark === "**" ? "strong" : "em",
children: nested(text.slice(start, end)),
});
i = end + mark.length;
continue;
}
}
if (mark === "**") {
plain += mark;
i += 2;
continue;
}
} else if (ch === "[") {
let j = i + 1;
while (j < text.length && text[j] !== "[" && text[j] !== "]") j += 1;
if (j > i + 1 && text[j] === "]" && text[j + 1] === "(") {
let k = j + 2;
while (k < text.length && text[k] !== ")" && text[k] !== "(" && text[k] !== " ") k += 1;
if (k > j + 2 && text[k] === ")") {
flush();
tokens.push({ type: "link", text: text.slice(i + 1, j), url: text.slice(j + 2, k) });
i = k + 1;
continue;
}
}
}
plain += ch;
i += 1;
}
flush();
return tokens;
}
function mdBlocks(src) {
const lines = mdClean(src).split("\n");
const blocks = [];
let open = null;
const close = () => {
open = null;
};
const extend = (type, text) => {
if (open && open.type === type) open.lines.push(text);
else {
open = { type: type, lines: [text] };
blocks.push(open);
}
};
const item = (type, text) => {
if (open && open.type === type) open.items.push(text);
else {
open = { type: type, items: [text] };
blocks.push(open);
}
};
for (let n = 0; n < lines.length; n += 1) {
const line = lines[n];
const trimmed = line.trim();
if (trimmed.startsWith(MD_FENCE)) {
const code = [];
n += 1;
while (n < lines.length && !lines[n].trim().startsWith(MD_FENCE)) {
code.push(lines[n]);
n += 1;
}
close();
blocks.push({ type: "code", text: code.join("\n") });
continue;
}
if (trimmed === "") {
close();
continue;
}
const heading = MD_HEADING_RE.exec(trimmed);
if (heading) {
close();
blocks.push({ type: "heading", level: heading[1].length, text: heading[2] });
} else if (MD_RULE_RE.test(trimmed)) {
close();
blocks.push({ type: "rule" });
} else if (MD_QUOTE_RE.test(trimmed)) extend("quote", trimmed.replace(MD_QUOTE_RE, ""));
else if (MD_BULLET_RE.test(trimmed)) item("bullets", trimmed.replace(MD_BULLET_RE, ""));
else if (MD_NUMBER_RE.test(trimmed)) item("numbers", trimmed.replace(MD_NUMBER_RE, ""));
else if (open && open.items) open.items[open.items.length - 1] += " " + trimmed;
else extend("paragraph", trimmed);
}
return blocks;
}
function mdPlain(tokens) {
return tokens
.map((token) => (token.children ? mdPlain(token.children) : token.text))
.join("");
}
function renderMarkdown(src, doc, safeUrl) {
const root = doc.createDocumentFragment();
const inline = (parent, tokens) => {
tokens.forEach((token) => {
if (token.type === "text") parent.appendChild(doc.createTextNode(token.text));
else if (token.type === "code") {
const code = doc.createElement("code");
code.appendChild(doc.createTextNode(token.text));
parent.appendChild(code);
} else if (token.type === "link") {
const href = typeof safeUrl === "function" ? safeUrl(token.url) : "";
if (typeof href !== "string" || href === "") {
parent.appendChild(doc.createTextNode(token.text));
return;
}
const link = doc.createElement("a");
link.setAttribute("href", href);
if (MD_EXTERNAL_RE.test(href)) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noreferrer");
}
link.appendChild(doc.createTextNode(token.text));
parent.appendChild(link);
} else {
const node = doc.createElement(token.type === "strong" ? "strong" : "em");
inline(node, token.children);
parent.appendChild(node);
}
});
return parent;
};
const element = (tag, text) => inline(doc.createElement(tag), mdInline(text, 0));
mdBlocks(src).forEach((block) => {
if (block.type === "code") {
const pre = doc.createElement("pre");
const code = doc.createElement("code");
code.appendChild(doc.createTextNode(block.text));
pre.appendChild(code);
root.appendChild(pre);
} else if (block.type === "rule") root.appendChild(doc.createElement("hr"));
else if (block.type === "heading")
root.appendChild(element("h" + (block.level + MD_HEADING_OFFSET), block.text));
else if (block.items) {
const list = doc.createElement(block.type === "numbers" ? "ol" : "ul");
block.items.forEach((text) => list.appendChild(element("li", text)));
root.appendChild(list);
} else
root.appendChild(
element(block.type === "quote" ? "blockquote" : "p", block.lines.join(" ")),
);
});
return root;
}
function markdownToText(src) {
const blocks = mdBlocks(src);
for (let n = 0; n < blocks.length; n += 1) {
const block = blocks[n];
let text = "";
if (block.type === "code")
text = block.text.split("\n").filter((line) => line.trim() !== "")[0] || "";
else if (block.type === "heading") text = mdPlain(mdInline(block.text, 0));
else if (block.items) text = mdPlain(mdInline(block.items[0], 0));
else if (block.lines) text = mdPlain(mdInline(block.lines[0], 0));
if (text.trim() !== "") return text.trim();
}
return "";
}
// </markdown-core>
return baseclass.extend({
render: renderMarkdown,
toText: markdownToText,
});
@@ -0,0 +1,411 @@
"use strict";
"require baseclass";
const POLL_INTERVAL_MS = 12 * 60 * 60 * 1000;
const MAX_NOTICES = 20;
const MAX_TITLE_CHARS = 120;
const MAX_BODY_CHARS = 4000;
const MAX_REASON_CHARS = 1000;
const MAX_VERSION_CHARS = 64;
const ID_RE = /^[A-Za-z0-9]{1,32}$/;
const LANG_RE = /^[a-z]{2}(-[a-z]{2,4})?$/;
const LEVELS = ["info", "warning", "critical"];
const BADGE_LEVELS = ["warning", "critical"];
const AUDIENCES = ["all", "creators"];
const COMPAT_STATES = ["unsupported", "deprecated"];
const HTTPS_PREFIX = "https://";
const LUCI_PATH_RE = /^admin\/[A-Za-z0-9_\-./?=&%#]*$/;
const URL_FORBIDDEN_RE = /[\u0000-\u0020\u007f\\]/;
const STAMP_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/;
const SUNSET_DATE_RE = /^\d{4}-\d{2}-\d{2}(?=$|[ T])/;
const GROUP_ACTION = "action";
const GROUP_UPDATES = "updates";
const SOURCE_ROUTER = "router";
const SOURCE_SHARES = "shares";
const SOURCE_STORE = "store";
const FEED_KEY = "feed:missing";
const LOCAL_KEY_PREFIX = "feed:";
const DERIVED_KEY_MARK = ":";
const AGE_STEPS = [
["week", 7 * 24 * 60 * 60 * 1000],
["day", 24 * 60 * 60 * 1000],
["hour", 60 * 60 * 1000],
["minute", 60 * 1000],
];
const isObject = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
// 按码点计数:hub 的上限数的是字符,而 .length 数的是 UTF-16 码元。
const charCount = (value) => Array.from(value).length;
const isTitle = (value) =>
typeof value === "string" &&
value.trim() !== "" &&
charCount(value) <= MAX_TITLE_CHARS;
const isBody = (value) =>
typeof value === "string" && charCount(value) <= MAX_BODY_CHARS;
const cleanI18n = (i18n) => {
const out = {};
if (!isObject(i18n)) return out;
Object.keys(i18n).forEach((lang) => {
const entry = i18n[lang];
if (!LANG_RE.test(lang) || !isObject(entry)) return;
const kept = {};
if (isTitle(entry.title)) kept.title = entry.title;
if (isBody(entry.body) && entry.body !== "") kept.body = entry.body;
if (Object.keys(kept).length) out[lang] = kept;
});
return out;
};
const stampOf = (value) => {
const match = typeof value === "string" ? STAMP_RE.exec(value) : null;
return match ? match[0] : "";
};
const cleanNotice = (raw) => {
if (!isObject(raw)) return null;
if (typeof raw.id !== "string" || !ID_RE.test(raw.id)) return null;
if (!LEVELS.includes(raw.level) || !AUDIENCES.includes(raw.audience)) return null;
if (!isTitle(raw.title) || !isBody(raw.body)) return null;
return {
id: raw.id,
level: raw.level,
audience: raw.audience,
title: raw.title,
body: raw.body,
url: typeof raw.url === "string" ? raw.url : "",
i18n: cleanI18n(raw.i18n),
at: stampOf(raw.starts_at) || stampOf(raw.at) || stampOf(raw.created_at),
};
};
const stringList = (value) =>
Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
const ownConfigs = (me) =>
isObject(me) && Array.isArray(me.configs)
? me.configs.filter(
(config) => isObject(config) && typeof config.id === "string" && ID_RE.test(config.id),
)
: [];
const isReason = (value) =>
typeof value === "string" && charCount(value) <= MAX_REASON_CHARS;
const versionOf = (pkg) => {
const version = isObject(pkg) ? pkg.installed_version : null;
return typeof version === "string" && charCount(version) <= MAX_VERSION_CHARS ? version : "";
};
const nameOf = (config) => (typeof config.name === "string" ? config.name : "");
// FNV-1a,32 位。只为让"受影响的那一组 id"一变键就变,不承担任何安全含义。
const shortHash = (text) => {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
};
return baseclass.extend({
POLL_INTERVAL_MS: POLL_INTERVAL_MS,
MAX_NOTICES: MAX_NOTICES,
GROUP_ACTION: GROUP_ACTION,
GROUP_UPDATES: GROUP_UPDATES,
SOURCE_ROUTER: SOURCE_ROUTER,
SOURCE_SHARES: SOURCE_SHARES,
SOURCE_STORE: SOURCE_STORE,
FEED_KEY: FEED_KEY,
localize(notice, lang) {
const source = isObject(notice) ? notice : {};
const i18n = isObject(source.i18n) ? source.i18n : {};
const code = String(lang == null ? "" : lang).toLowerCase().replace(/_/g, "-");
const chain = [i18n[code], i18n[code.split("-")[0]], source];
const pick = (field) => {
const hit = chain.find(
(entry) => isObject(entry) && typeof entry[field] === "string" && entry[field] !== "",
);
return hit ? hit[field] : "";
};
return { title: pick("title"), body: pick("body") };
},
safeUrl(url) {
if (typeof url !== "string" || URL_FORBIDDEN_RE.test(url)) return "";
if (LUCI_PATH_RE.test(url))
return url.includes("..") || url.includes("//") ? "" : url;
if (!url.startsWith(HTTPS_PREFIX)) return "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" || !parsed.hostname) return "";
if (parsed.username || parsed.password) return "";
return url;
} catch (e) {
return "";
}
},
isExternalUrl(url) {
return typeof url === "string" && url.startsWith(HTTPS_PREFIX);
},
present(notice, lang) {
const text = this.localize(notice, lang);
const url = this.safeUrl(notice?.url);
return {
title: text.title,
body: text.body,
url: url,
external: this.isExternalUrl(url),
};
},
sanitize(list) {
if (!Array.isArray(list)) return [];
const out = [];
for (const raw of list) {
if (out.length >= MAX_NOTICES) break;
const notice = cleanNotice(raw);
if (notice && !out.some((kept) => kept.id === notice.id)) out.push(notice);
}
return out;
},
isDue(lastCheckedMs, nowMs) {
if (!Number.isFinite(lastCheckedMs) || lastCheckedMs <= 0) return true;
if (!Number.isFinite(nowMs) || nowMs < lastCheckedMs) return true;
return nowMs - lastCheckedMs >= POLL_INTERVAL_MS;
},
creatorAffected(me) {
if (!isObject(me)) return false;
const summary = isObject(me.compat_summary) ? me.compat_summary : {};
const count = (field) => (Number.isFinite(summary[field]) ? summary[field] : 0);
if (count("deprecated") + count("unsupported") > 0) return true;
return (Array.isArray(me.configs) ? me.configs : []).some((config) => {
if (!isObject(config) || config.status === "removed") return false;
const state = isObject(config.compat) ? config.compat.state : null;
return typeof state === "string" && state !== "ok";
});
},
hasShares(me) {
return ownConfigs(me).length > 0;
},
compatBadge(config) {
const state = config?.compat?.state;
return COMPAT_STATES.includes(state) ? state : null;
},
sunsetDate(config) {
const stamp = config?.compat?.sunset_at;
const match = typeof stamp === "string" ? SUNSET_DATE_RE.exec(stamp) : null;
return match ? match[0] : "";
},
compatCounts(me) {
const counts = { deprecated: 0, unsupported: 0, sunset: "" };
ownConfigs(me)
.filter((config) => config.status !== "removed")
.forEach((config) => {
const state = this.compatBadge(config);
if (state) counts[state] += 1;
const sunset = state === "deprecated" ? this.sunsetDate(config) : "";
if (sunset && (!counts.sunset || sunset < counts.sunset)) counts.sunset = sunset;
});
if (isObject(me) && isObject(me.compat_summary))
["deprecated", "unsupported"].forEach((state) => {
const n = me.compat_summary[state];
counts[state] = Number.isInteger(n) && n > 0 ? n : 0;
});
return counts;
},
shortHash: shortHash,
derive(me) {
const configs = ownConfigs(me);
const live = configs.filter((config) => config.status !== "removed");
const items = [];
COMPAT_STATES.forEach((state) => {
const hit = live.filter((config) => this.compatBadge(config) === state);
if (!hit.length) return;
const sunsets = hit
.map((config) => this.sunsetDate(config))
.filter((day) => day)
.sort();
items.push({
key:
"compat:" +
state +
":" +
shortHash(
hit
.map((config) => config.id)
.sort()
.join(","),
),
group: GROUP_ACTION,
source: SOURCE_SHARES,
kind: "compat",
state: state,
level: "critical",
names: hit.map(nameOf),
sunset: state === "deprecated" && sunsets.length ? sunsets[0] : "",
});
});
live
.filter((config) => config.assets_status === "rejected")
.forEach((config) =>
items.push({
key: "rejected:" + config.id,
group: GROUP_ACTION,
source: SOURCE_SHARES,
kind: "rejected",
level: "warning",
name: nameOf(config),
reason: isReason(config.assets_reject_reason) ? config.assets_reject_reason : "",
}),
);
configs
.filter((config) => config.status === "removed")
.forEach((config) =>
items.push({
key: "removed:" + config.id,
group: GROUP_ACTION,
source: SOURCE_SHARES,
kind: "removed",
level: "warning",
name: nameOf(config),
}),
);
return items;
},
// get_init_data 的应答 -> 随 feed 一起缓存的本机结论。判断不出来(没有 feed
// 字段、包管理器未知)就是 null:不知道,不等于"缺"。
localState(initData) {
const feed = isObject(initData) && isObject(initData.feed) ? initData.feed : null;
if (!feed || typeof feed.pm !== "string" || feed.pm === "" || feed.pm === "unknown")
return null;
const versions = isObject(initData.versions) ? initData.versions : {};
return {
feedMissing: feed.configured !== true,
theme: versionOf(versions.theme),
app: versionOf(versions.config),
};
},
deriveLocal(local) {
if (!isObject(local) || local.feedMissing !== true) return [];
return [
{
key: FEED_KEY,
group: GROUP_ACTION,
source: SOURCE_ROUTER,
kind: "feed",
level: "warning",
theme: typeof local.theme === "string" ? local.theme : "",
app: typeof local.app === "string" ? local.app : "",
},
];
},
inbox(snapshot, me, state) {
const read = stringList(state && state.read);
const done = stringList(state && state.done);
const affected = this.creatorAffected(me);
const updates = this.sanitize(isObject(snapshot) ? snapshot.notices : null)
.filter((notice) => notice.audience !== "creators" || affected)
.map((notice) => ({
key: notice.id,
group: GROUP_UPDATES,
source: SOURCE_STORE,
kind: "notice",
level: notice.level,
at: notice.at,
notice: notice,
}));
return this.deriveLocal(isObject(snapshot) ? snapshot.local : null)
.concat(this.derive(me), updates)
.filter((item) => !done.includes(item.key))
.map((item) => Object.assign(item, { unread: !read.includes(item.key) }));
},
unreadCount(items) {
return (Array.isArray(items) ? items : []).filter((item) => item && item.unread).length;
},
badgeCount(snapshot, me, state) {
if (!isObject(snapshot)) return 0;
return this.inbox(snapshot, me, state).filter(
(item) =>
item.unread && (item.group === GROUP_ACTION || BADGE_LEVELS.includes(item.level)),
).length;
},
withKey(keys, key) {
const list = stringList(keys);
return typeof key !== "string" || list.includes(key) ? list : list.concat(key);
},
withoutKey(keys, key) {
return stringList(keys).filter((kept) => kept !== key);
},
withKeys(keys, items) {
return (Array.isArray(items) ? items : []).reduce(
(list, item) => this.withKey(list, item && item.key),
stringList(keys),
);
},
// 某一路来源这次没拿到(hub 不通、hub_me 失败)时,它名下的键原样保留:
// 否则一次断网就把 Done 过的条目全放回来。
pruneKeys(keys, activeKeys, known) {
const active = stringList(activeKeys);
const unknown = (key) => {
if (key.startsWith(LOCAL_KEY_PREFIX)) return !(known && known.local);
if (key.includes(DERIVED_KEY_MARK)) return !(known && known.me);
return !(known && known.feed);
};
return stringList(keys).filter(
(key, index, all) => all.indexOf(key) === index && (active.includes(key) || unknown(key)),
);
},
stampMs(stamp) {
const match = typeof stamp === "string" ? STAMP_RE.exec(stamp) : null;
if (!match) return null;
const [year, month, day, hour, minute, second] = match
.slice(1)
.map((part) => Number(part || 0));
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
return Number.isFinite(ms) ? ms : null;
},
ageOf(thenMs, nowMs) {
if (!Number.isFinite(thenMs) || !Number.isFinite(nowMs)) return null;
const elapsed = Math.max(0, nowMs - thenMs);
const step = AGE_STEPS.find((candidate) => elapsed >= candidate[1]);
return step
? { value: -Math.floor(elapsed / step[1]), unit: step[0] }
: { value: 0, unit: "minute" };
},
});
@@ -0,0 +1,205 @@
"use strict";
"require baseclass";
// Palette-painted mini previews for the Marketplace (marketplace.js is the only
// caller: cards, detail drawer and share panel): a fake LuCI page drawn from
// a 4-key palette ({bg, surface, text, brand}).
// Geometry is static cssText; only sanitised
// colors vary, so untrusted hub palettes can never inject styles.
//
// SECURITY: a config's logo is a user-uploaded SVG served by the hub. It may
// only ever reach the page through <img src>, where SVG scripting does not
// run. Nothing in this package may put hub SVG text into the DOM -- not
// innerHTML, not DOMParser, not insertAdjacentHTML -- however thoroughly the
// hub sanitises it on upload. Defence in depth: the sanitiser is not the
// only thing standing between a shared config and script execution.
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
const safeHex = (value, fallback) =>
HEX_RE.test(value || "") ? value : fallback;
const mixHex = (hex, pct) =>
"color-mix(in srgb, " + hex + " " + pct + "%, transparent)";
// An <img> that silently degrades to `fallback` when the image cannot be
// loaded, keeping the slot it occupies exactly the same size either way.
// Callers pass the css of the box the fallback already fills, so has-logo,
// no-logo and failed-load are three states with identical geometry.
//
// No spinner and no skeleton on purpose: the store paints from a stale
// cached list whenever the hub is unreachable, and in that state every image
// request fails. A flash followed by a jump is worse than a plain colour
// block that was right from the first frame.
const logoImage = (url, fallback, styleCss) => {
if (!url) return fallback;
const img = E("img", { src: url, alt: "", style: styleCss });
img.addEventListener("error", () => {
if (img.parentNode) img.parentNode.replaceChild(fallback, img);
});
return img;
};
// opts.nav: "sidebar" draws a left rail; "mega-menu" draws the top bar plus
// the expanded panel that sits under it; anything else (notably "dropdown")
// draws the plain top bar. These are the three values validate_and_apply_hub_
// payload accepts, so the preview covers the whole enum.
//
// opts.logo: an absolute url for the config's logo_svg. The brand square is
// exactly where a logo shows up in the real chrome, so drawing it there is
// the whole change -- one node swapped for another of the same size. That is
// why this shape survives no-asset, has-asset and failed-load without moving
// a pixel, which matters: most shared configs carry no assets at all.
const buildMini = (pal, opts) => {
const nav = (opts && opts.nav) || "top";
const bg = safeHex(pal && pal.bg, "#f7fafc");
const surface = safeHex(pal && pal.surface, "#ffffff");
const text = safeHex(pal && pal.text, "#121a22");
const brand = safeHex(pal && pal.brand, "#0086bf");
const bar = (width, pct, extra) => {
const el = E("span", {
style:
"height:22%;border-radius:99px;width:" + width + ";" + (extra || ""),
});
el.style.background = mixHex(text, pct);
return el;
};
const row = (width) => {
const el = E("div", {
style:
"height:5px;border-radius:99px;margin-bottom:4%;width:" + width + ";",
});
el.style.background = mixHex(text, 24);
return el;
};
const card = (children) => {
const el = E(
"div",
{ style: "border-radius:8px;padding:4%;margin-bottom:5%;" },
children,
);
el.style.background = surface;
el.style.border = "1px solid " + mixHex(text, 10);
return el;
};
const DOT_CSS = "width:7%;aspect-ratio:1;border-radius:25%;flex:none;";
const brandSquare = E("span", { style: DOT_CSS });
brandSquare.style.background = brand;
const dot = logoImage(
(opts && opts.logo) || "",
brandSquare,
DOT_CSS + "object-fit:contain;",
);
const title = E("div", {
style:
"height:8px;max-height:14%;border-radius:99px;width:45%;margin-bottom:6%;",
});
title.style.background = mixHex(text, 72);
const btn = E("span", {
style: "display:inline-block;width:26%;height:12px;border-radius:99px;",
});
btn.style.background = brand;
const content = [card([title, row("90%"), row("70%"), btn]), card([row("85%"), row("60%")])];
let chromeNodes;
let main;
if (nav === "sidebar") {
const rail = E(
"div",
{
style:
"position:absolute;left:0;top:0;bottom:0;width:22%;display:flex;" +
"flex-direction:column;gap:8%;padding:6% 4%;box-sizing:border-box;",
},
[dot, bar("80%", 28, "height:7%;"), bar("90%", 28, "height:7%;"), bar("70%", 28, "height:7%;")],
);
rail.children[2].style.background = brand;
rail.style.background = surface;
rail.style.borderRight = "1px solid " + mixHex(text, 12);
chromeNodes = [rail];
main = E(
"div",
{ style: "position:absolute;left:27%;top:7%;right:5%;bottom:0;" },
content,
);
} else {
const top = E(
"div",
{
style:
"position:absolute;left:0;right:0;top:0;height:16%;display:flex;" +
"align-items:center;gap:4%;padding:0 4%;box-sizing:border-box;",
},
[dot, bar("18%", 55), bar("12%", 34), bar("12%", 34)],
);
top.style.background = surface;
top.style.borderBottom = "1px solid " + mixHex(text, 12);
chromeNodes = [top];
// A mega menu is only distinguishable from a dropdown once its panel is
// open, so the thumbnail draws it open.
if (nav === "mega-menu") {
const panel = E(
"div",
{
style:
"position:absolute;left:0;right:0;top:16%;height:13%;display:flex;" +
"align-items:center;gap:3%;padding:0 4%;box-sizing:border-box;",
},
[
bar("10%", 30, "height:26%;"),
bar("14%", 30, "height:26%;"),
bar("9%", 30, "height:26%;"),
bar("12%", 30, "height:26%;"),
],
);
panel.style.background = surface;
panel.style.borderBottom = "1px solid " + mixHex(text, 12);
chromeNodes.push(panel);
}
main = E(
"div",
{
style:
"position:absolute;left:5%;top:" +
(nav === "mega-menu" ? "37%" : "24%") +
";right:5%;bottom:0;",
},
content,
);
}
const root = E(
"div",
{ style: "position:absolute;inset:0;overflow:hidden;" },
chromeNodes.concat([main]),
);
root.style.background = bg;
return root;
};
// Light/dark diagonal split: two minis, the dark one clipped to the right.
const buildDuo = (palette, opts) => {
const dark = buildMini((palette && palette.dark) || {}, opts);
dark.style.clipPath = "polygon(58% 0, 100% 0, 100% 100%, 42% 100%)";
return E("div", { style: "position:relative;width:100%;height:100%;" }, [
buildMini((palette && palette.light) || {}, opts),
dark,
]);
};
return baseclass.extend({
safeHex,
mixHex,
logoImage,
buildMini,
buildDuo,
});
@@ -0,0 +1,317 @@
/**
* @eamonxg/luci-theme-tokens v2.0.0 -- GENERATED, DO NOT EDIT.
* Aurora design-token engine (browser global). Built from aurora/spec.js +
* aurora/defaults.js + operators.js by build.mjs. Depends on the global
* `Color` (utils/color.global.js in luci-app-aurora-config); load that first.
*/
var AuroraTokens = (() => {
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// global.js
var global_exports = {};
__export(global_exports, {
DEFAULTS: () => DEFAULTS,
DERIVATIONS: () => DERIVATIONS,
DERIVED_KEYS: () => DERIVED_KEYS,
INPUTS: () => INPUTS,
resolve: () => resolve
});
// aurora/spec.js
var DERIVATIONS = {
light: {
text_muted: ["mix", "text", "bg", 0.62],
text_subtle: ["mix", "text", "bg", 0.48],
// Recessed fills need chroma ABOVE bg's (set, not shade): at these sizes a
// bg-inherited tint reads as dirty grey against the pure-white cards.
surface_sunken: ["set", "bg", 0.975, 5e-3],
surface_overlay: ["shade", "bg", 0.016],
hairline: ["alpha", "text", 0.13],
hover_faint: ["set", "bg", 0.95, 6e-3],
brand_hover: ["shade", "brand", -0.06],
brand_subtle: ["mix", "brand", "bg", 0.12],
brand_subtle_hover: ["shade", "brand_subtle", -0.04],
focus_ring: ["alpha", "brand", 0.6],
progress_start: ["mix", "brand", "surface_sunken", 0.65],
progress_end: ["const", "var:brand"],
info_surface: ["set", "info", 0.945, 0.042],
warning_surface: ["set", "warning", 0.955, 0.042],
success_surface: ["set", "success", 0.95, 0.042],
danger_surface: ["set", "danger", 0.95, 0.036],
danger_surface_hover: ["shade", "danger_surface", -0.04],
// Form-control fill (inputs/selects/textareas/checkboxes, login field).
// One semantic token so components never fork per mode: light rests a
// near-white overlay on the page; dark recesses below the card.
control_bg: ["const", "var:surface_overlay"],
scrim: ["const", "oklch(0 0 0 / 0.6)"],
// Fully opaque: a clean solid panel (Apple's #fafafc). Any translucency let
// the dimmed curtain bleed through, greying the panel off the header tone
// and leaking faint blurred page content into the empty columns. The header
// lifts to this same colour when the menu opens (see _layout.css) so bar and
// panel read as one continuous surface.
// Relight 2026-07: a cool snow step BELOW bg — anything brighter read as
// paper-white against the dimmed page; extra chroma keeps it icy.
mega_menu_bg: ["set", "bg", 0.972, 8e-3],
// Mega-menu curtain: a real dimming layer. A near-page-light grey (the
// earlier #e8e8ed attempt) only blurred without darkening, so the mask read
// as absent. Black at a modest alpha actually dims the page; the now-opaque
// panel + its shadow give a clean edge, so this no longer bands the way the
// old translucent panel over a heavy scrim did. Lighter than the 0.6 modal
// scrim — it's a menu backdrop, not a dialog.
// Relight 2026-07: pure black over the snow-white page greyed it dirty;
// a cool blue-black dim is the "blue hour" of the light theme.
mega_menu_scrim: ["const", "oklch(0.22 0.035 250 / 0.38)"]
},
dark: {
text_muted: ["mix", "text", "bg", 0.62],
text_subtle: ["mix", "text", "bg", 0.42],
surface_sunken: ["shade", "surface", -0.045],
surface_overlay: ["shade", "surface", 0.02],
hairline: ["alpha", "text", 0.1],
hover_faint: ["alpha", "text", 0.05],
brand_hover: ["shade", "brand", -0.05],
brand_subtle: ["mix", "brand", "bg", 0.16],
brand_subtle_hover: ["shade", "brand_subtle", 0.04],
focus_ring: ["alpha", "brand", 0.6],
// Derived from brand like light — not hardcoded — so the bar tracks the
// active brand colour instead of a frozen teal.
progress_start: ["mix", "brand", "surface_sunken", 0.65],
progress_end: ["const", "var:brand"],
info_surface: ["set", "info", 0.32, 0.05],
warning_surface: ["set", "warning", 0.33, 0.06],
success_surface: ["set", "success", 0.3, 0.05],
danger_surface: ["set", "danger", 0.32, 0.08],
danger_surface_hover: ["shade", "danger_surface", 0.04],
// See light: same semantic token, recessed below the card in dark.
control_bg: ["const", "var:surface_sunken"],
scrim: ["const", "oklch(0 0 0 / 0.6)"],
// Deeper than surface_overlay (23%): surface_sunken (16.5%) tracks Apple's
// opened-panel #161617 (~18.5%). Fully opaque — a clean solid panel with no
// curtain bleed; the header lifts to this colour on open (see _layout.css)
// so bar and panel are one continuous surface.
mega_menu_bg: ["alpha", "surface_sunken", 1],
// Dark curtain stays a near-black dim (Apple's rgba(0,0,0,.4)); the dark
// panel and dark page already share a tone, so this only needs to deepen.
mega_menu_scrim: ["const", "oklch(0 0 0 / 0.5)"]
}
};
// aurora/defaults.js
var DEFAULTS = {
light: {
bg: "oklch(0.984 0.004 235)",
surface: "oklch(1 0 0)",
text: "oklch(0.215 0.02 250)",
brand: "oklch(0.58 0.14 233)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.58 0.14 233)",
// intentionally equals brand
info: "oklch(0.45 0.12 250)",
warning: "oklch(0.4 0.09 72)",
success: "oklch(0.38 0.1 172)",
danger: "oklch(0.42 0.15 22)"
},
dark: {
bg: "oklch(0.13 0.018 264)",
surface: "oklch(0.21 0.02 264)",
text: "oklch(0.985 0.002 264)",
brand: "oklch(0.6 0.13 188.745)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.6 0.13 188.745)",
// intentionally equals brand
info: "oklch(0.8 0.11 255)",
warning: "oklch(0.82 0.13 80)",
success: "oklch(0.72 0.13 158)",
danger: "oklch(0.7 0.16 22)"
}
};
// shim-color-global.js
var shim_color_global_default = globalThis.Color;
// operators.js
var C = (v) => v instanceof shim_color_global_default ? v : new shim_color_global_default(v);
var mix = (a, b, p) => shim_color_global_default.mix(C(a), C(b), 1 - p, { space: "oklab", outputSpace: "oklch" });
var shade = (a, dl) => {
const c = C(a).to("oklch");
c.coords[0] += dl;
return c;
};
var set = (a, L, Ch) => {
const c = C(a).to("oklch");
c.coords[0] = L;
const achromatic = c.coords[1] < 1e-3 || Number.isNaN(c.coords[2]);
c.coords[1] = achromatic ? 0 : Ch;
return c;
};
var alpha = (a, p) => {
const c = C(a).to("oklch");
c.alpha = p;
return c;
};
var konst = (s) => C(s).to("oklch");
var toOklch = (v) => C(v).to("oklch").toString({ precision: 4, format: "oklch" });
// resolve.js
function createResolver(derivations) {
return function resolveTokens2(mode, inputs) {
const derivs = derivations[mode];
if (!derivs) throw new Error(`unknown mode: ${mode}`);
const resolved = { ...inputs };
const ref = (name) => {
if (resolved[name] === void 0) compute(name);
return resolved[name];
};
function compute(name) {
const rule = derivs[name];
if (!rule) throw new Error(`unknown derived token: ${name}`);
const [op, ...args] = rule;
let color;
switch (op) {
case "mix":
color = mix(ref(args[0]), ref(args[1]), args[2]);
break;
case "shade":
color = shade(ref(args[0]), args[1]);
break;
case "set":
color = set(ref(args[0]), args[1], args[2]);
break;
case "alpha":
color = alpha(ref(args[0]), args[1]);
break;
case "const":
if (args[0].startsWith("var:")) {
resolved[name] = ref(args[0].slice(4));
return;
}
color = konst(args[0]);
break;
default:
throw new Error(`unknown op: ${op}`);
}
resolved[name] = toOklch(color);
}
for (const name of Object.keys(derivs)) compute(name);
return resolved;
};
}
// aurora/presets.js
var statusKeys = ["info", "warning", "success", "danger"];
var STATUS = {
light: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.light[k]])),
dark: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.dark[k]]))
};
var PRESETS = {
default: {
light: { ...DEFAULTS.light },
dark: { ...DEFAULTS.dark }
},
"sage-green": {
light: {
bg: "oklch(0.9761 0.0041 91.4461)",
surface: "oklch(1 0 0)",
text: "oklch(0.2417 0.0298 269.8827)",
brand: "oklch(0.6333 0.0309 154.9039)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.6333 0.0309 154.9039)",
...STATUS.light
},
dark: {
bg: "oklch(0.1448 0 0)",
surface: "oklch(0.1822 0 0)",
text: "oklch(0.9702 0 0)",
brand: "oklch(0.6333 0.0309 154.9039)",
on_brand: "oklch(0 0 0)",
link: "oklch(0.6333 0.0309 154.9039)",
...STATUS.dark
}
},
"amber-sand": {
light: {
bg: "oklch(0.9818 0.0054 95.0986)",
surface: "oklch(0.9818 0.0054 95.0986)",
text: "oklch(0.3438 0.0269 95.7226)",
brand: "oklch(0.6171 0.1375 39.0427)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.6171 0.1375 39.0427)",
...STATUS.light
},
dark: {
bg: "oklch(0.2679 0.0036 106.6427)",
surface: "oklch(0.3085 0.0035 106.6039)",
text: "oklch(0.8074 0.0142 93.0137)",
brand: "oklch(0.6724 0.1308 38.7559)",
on_brand: "oklch(0 0 0)",
link: "oklch(0.6724 0.1308 38.7559)",
...STATUS.dark
}
},
monochrome: {
light: {
bg: "oklch(0.9900 0 0)",
surface: "oklch(1 0 0)",
text: "oklch(0 0 0)",
brand: "oklch(0 0 0)",
on_brand: "oklch(1 0 0)",
link: "oklch(0 0 0)",
...STATUS.light
},
dark: {
bg: "oklch(0 0 0)",
surface: "oklch(0.1400 0 0)",
text: "oklch(1 0 0)",
brand: "oklch(1 0 0)",
on_brand: "oklch(0 0 0)",
link: "oklch(1 0 0)",
...STATUS.dark
}
},
"sky-blue": {
light: {
bg: "oklch(1 0 0)",
surface: "oklch(0.9784 0.0011 197.1387)",
text: "oklch(0.1884 0.0128 248.5103)",
brand: "oklch(0.6723 0.1606 244.9955)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.6723 0.1606 244.9955)",
...STATUS.light
},
dark: {
bg: "oklch(0 0 0)",
surface: "oklch(0.2097 0.0080 274.5332)",
text: "oklch(0.9328 0.0025 228.7857)",
brand: "oklch(0.6692 0.1607 245.0110)",
on_brand: "oklch(1 0 0)",
link: "oklch(0.6692 0.1607 245.0110)",
...STATUS.dark
}
}
};
// aurora/index.js
var resolveTokens = createResolver(DERIVATIONS);
// global.js
var INPUTS = Object.keys(DEFAULTS.light);
var DERIVED_KEYS = Object.keys(DERIVATIONS.light);
var resolve = resolveTokens;
return __toCommonJS(global_exports);
})();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,196 @@
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
// These assertions run against the bytes the router actually serves, not
// against the source they were made from. That is the whole point: the source
// is checked by every other test in here, and the interesting failures live in
// the gap between the two.
const OUT = repo("htdocs/luci-static/resources");
async function collect(dir, base = dir) {
const found = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) found.push(...(await collect(full, base)));
else if (entry.name.endsWith(".js")) found.push(full.slice(base.length + 1));
}
return found;
}
const shipped = await collect(OUT);
test("every shipped view is parseable JavaScript", async () => {
assert.ok(shipped.length > 0, "found no artifacts -- run `pnpm build`");
for (const rel of shipped) {
const code = await readFile(join(OUT, rel), "utf8");
// A LuCI view is evaluated as a function body, so a top-level return is
// legal there and only there -- new Function reproduces that context.
assert.doesNotThrow(() => new Function(code), `${rel} does not parse`);
}
});
test("every shipped file keeps the require directives its source declared", async () => {
for (const rel of shipped) {
const source = await readFile(srcPath(rel), "utf8");
const built = await readFile(join(OUT, rel), "utf8");
const declared = [...source.matchAll(/^"(require [^"]+)";$/gm)].map((m) => m[1]);
for (const directive of declared)
assert.ok(
built.includes(`"${directive}"`),
`${rel} lost its "${directive}" declaration -- LuCI will not load its dependency`,
);
}
});
test("every shipped view keeps the top-level return LuCI reads it for", async () => {
for (const rel of shipped) {
const source = await readFile(srcPath(rel), "utf8");
const match = /^return (view|baseclass|L\.Class)\.extend\(/m.exec(source);
if (!match) continue;
const built = await readFile(join(OUT, rel), "utf8");
assert.ok(
built.includes(`return ${match[1]}.extend(`),
`${rel} lost its module export -- LuCI would load an undefined view`,
);
}
});
test("the *.global.js artifacts still publish their globals", async () => {
// These two exist only to define a global for the page. mangle.toplevel or
// compress.toplevel would delete the declaration and terser would still
// report success -- the failure is a white page with nothing in the console.
for (const [rel, name] of [
["utils/color.global.js", "Color"],
["utils/tokens.global.js", "AuroraTokens"],
]) {
const built = await readFile(join(OUT, rel), "utf8");
assert.match(
built,
new RegExp(`var ${name}\\s*=`),
`${rel} no longer declares the global ${name} -- is toplevel mangling on?`,
);
}
});
test("vendored code ships the licence it is distributed under", async () => {
// MIT requires the notice to travel with every copy, and comments:false
// strips the plain // blocks the vendored file carries. Sidecars re-attach it.
let checked = 0;
for (const rel of shipped) {
let sidecar = null;
try {
sidecar = await readFile(`${srcPath(rel)}.license`, "utf8");
} catch {
continue;
}
checked += 1;
const built = await readFile(join(OUT, rel), "utf8");
assert.ok(built.startsWith("/*!"), `${rel} lost its licence banner`);
for (const line of sidecar.trimEnd().split("\n"))
assert.ok(built.includes(line), `${rel} banner is missing: ${line}`);
}
assert.ok(
checked > 0,
"no .license sidecar was found -- colorjs ships under MIT and must carry its notice",
);
});
test("no shipped file points at a sourcemap that is not installed", async () => {
for (const rel of shipped) {
const built = await readFile(join(OUT, rel), "utf8");
assert.ok(
!built.includes("sourceMappingURL"),
`${rel} would cost every visitor a 404 for a map the package does not ship`,
);
}
});
test("translatable strings survive minification byte-for-byte", async () => {
// gen-pot reads the source; the runtime looks the string up in the artifact.
// If the two ever disagree the translation silently falls back to English.
const msgid = /\b_\(\s*"((?:[^"\\]|\\.)*)"/g;
// Comment-only lines are dropped first: asset-upload.js documents its own
// options with `// badgeHeader, // _("Slot") or _("Type")`, and a string the
// compressor removes along with its comment was never looked up at runtime.
// (gen-pot does record such lines as references -- harmless, since the msgid
// is also used for real elsewhere -- but that is its business, not this
// test's.)
const code = (text) =>
text
.split("\n")
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join("\n");
for (const rel of shipped) {
const source = code(await readFile(srcPath(rel), "utf8"));
const built = await readFile(join(OUT, rel), "utf8");
const fromSource = new Set([...source.matchAll(msgid)].map((m) => m[1]));
const fromBuilt = new Set([...built.matchAll(msgid)].map((m) => m[1]));
for (const id of fromSource)
assert.ok(
fromBuilt.has(id),
`${rel}: msgid ${JSON.stringify(id)} was rewritten -- gen-pot reads the ` +
`source, so the runtime would look up a key no .po file contains`,
);
}
});
test("no hidden file was shipped", async () => {
// .DS_Store rode along the first time the build ran and would have been
// installed onto every router that upgraded.
const all = [];
const walk = async (dir) => {
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.isDirectory()) await walk(join(dir, entry.name));
else all.push(entry.name);
}
};
await walk(OUT);
const hidden = all.filter((name) => name.startsWith("."));
assert.deepEqual(hidden, [], `hidden files must not be installed: ${hidden}`);
});
// Every resource this package fetches by a URL it builds itself must carry a
// version, or browsers keep the copy they have: uhttpd dates our installed
// files at the epoch and sends no Cache-Control, so the heuristic freshness a
// browser computes runs to years and it never revalidates. This is how the
// Marketplace spent months rendering built-in cards from a presets.json whose
// shape had changed underneath it.
//
// Resources LuCI itself `require`s are out of scope -- LuCI appends its own
// ?v=, taken from luci-base's version, which we cannot influence.
test("self-fetched resources carry a cache-busting version", async () => {
const sentinel = /__ASSET_HASH\(/;
for (const rel of shipped) {
const built = await readFile(join(OUT, rel), "utf8");
assert.ok(
!sentinel.test(built),
`${rel} still contains an unresolved __ASSET_HASH sentinel`,
);
}
const theme = await readFile(join(OUT, "view/aurora/studio.js"), "utf8");
// The version is the loader's second argument; the "?v=" concatenation lives
// inside loadGlobalScript, so assert on both halves.
assert.match(
theme,
/L\.resource\([^)]*\)\+\([^)]*\?"\?v="\+/,
"loadGlobalScript no longer appends the version it is given",
);
for (const asset of ["color.global.js", "tokens.global.js"])
assert.match(
theme,
new RegExp(`"utils/${asset.replace(/\./g, "\\.")}",\\s*"[^"]+"`),
`theme.js loads ${asset} without a version argument -- a browser that ` +
`already has it will never ask for it again`,
);
const gallery = await readFile(join(OUT, "view/aurora/marketplace.js"), "utf8");
assert.match(
gallery,
/aurora\/presets\.json"\)\s*\+\s*"\?v=[^"]+"/,
"marketplace.js fetches presets.json with no ?v=",
);
});
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("utils/asset-upload.js");
test("asset-upload module exposes the shared surface", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /^"require baseclass";/m);
assert.match(src, /^"require rpc";/m);
assert.match(src, /^"require ui";/m);
assert.match(src, /return baseclass\.extend\(/);
for (const name of [
"MAX_UPLOAD",
"formatSize",
"extOf",
"checkFile",
"uploadToRouter",
"createAssetManager",
"confirmDelete",
])
assert.ok(src.includes(name), `missing export: ${name}`);
assert.ok(!src.includes("createDropzone"), "dead export must stay deleted");
assert.ok(!src.includes("createProgressRow"), "dead export must stay deleted");
});
test("module owns the only cgi-upload pipeline", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /\/cgi-bin\/cgi-upload/);
assert.match(src, /filemode.*0600/);
});
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const BUILD = fileURLToPath(new URL("../scripts/build-js.mjs", import.meta.url));
const VIEW = `"use strict";
"require view";
"require form";
const LABEL = _("A Translatable Label");
return view.extend({
render() {
const unusedButNamed = 1;
return LABEL + unusedButNamed;
},
});
`;
function scratch(files) {
const root = mkdtempSync(join(tmpdir(), "aurora-build-"));
const src = join(root, "src");
for (const [rel, content] of Object.entries(files)) {
const full = join(src, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content);
}
return { root, src, out: join(root, "out") };
}
const run = (args) =>
execFileSync(process.execPath, [BUILD, ...args], { encoding: "utf8" });
test("a LuCI view keeps its directive prologue, its top-level return and its _() calls", () => {
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
try {
run(["--src", src, "--out", out]);
const built = readFileSync(join(out, "view/aurora/studio.js"), "utf8");
assert.ok(
built.startsWith('"use strict";"require view";"require form";'),
`directive prologue was dropped: ${built.slice(0, 80)}`,
);
assert.match(built, /return view\.extend/, "top-level return was dropped");
assert.match(built, /_\("A Translatable Label"\)/, "_() msgid was rewritten");
assert.ok(built.length < VIEW.length, "output is not smaller than the source");
assert.ok(
!built.includes("sourceMappingURL"),
"a sourceMappingURL comment costs every visitor a 404",
);
assert.ok(!built.includes("unusedButNamed"), "locals were not mangled");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("json is compacted and stays parseable", () => {
const pretty =
JSON.stringify({ presets: { a: { light: { bg: "#fff" } } } }, null, 2) + "\n";
const { root, src, out } = scratch({ "aurora/presets.json": pretty });
try {
run(["--src", src, "--out", out]);
const built = readFileSync(join(out, "aurora/presets.json"), "utf8");
assert.ok(built.length < pretty.length, "json was not compacted");
assert.deepEqual(JSON.parse(built), JSON.parse(pretty), "json content changed");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("--check exits non-zero and names the stale file", () => {
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
try {
run(["--src", src, "--out", out]);
run(["--check", "--src", src, "--out", out]); // fresh: must not throw
writeFileSync(join(out, "view/aurora/studio.js"), "stale");
assert.throws(
() => run(["--check", "--src", src, "--out", out]),
(error) => {
assert.equal(error.status, 1, "--check must exit 1 when stale");
assert.match(
String(error.stdout) + String(error.stderr),
/view\/aurora\/studio\.js/,
);
return true;
},
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("build removes artifacts whose source is gone", () => {
const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW });
try {
run(["--src", src, "--out", out]);
mkdirSync(join(out, "utils"), { recursive: true });
writeFileSync(join(out, "utils/orphan.js"), "// no source produces this");
run(["--src", src, "--out", out]);
assert.throws(
() => readFileSync(join(out, "utils/orphan.js")),
"a stale artifact would keep shipping after its source was deleted",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a top-level global survives, and its .license sidecar rides along", () => {
const lib = `var Color = (function () {
// The MIT License (MIT)
// Copyright (c) 2024 Somebody
var helperName = 1;
return helperName;
})();
`;
const { root, src, out } = scratch({
"utils/color.global.js": lib,
"utils/color.global.js.license":
"colorjs.io -- MIT\nCopyright (c) 2024 Somebody",
});
try {
run(["--src", src, "--out", out]);
const built = readFileSync(join(out, "utils/color.global.js"), "utf8");
// The whole point of this file is the global it publishes. mangle.toplevel
// or compress.toplevel would delete the declaration outright, and terser
// would report success.
assert.match(built, /var Color=/, "the top-level global was mangled or dropped");
assert.ok(!built.includes("helperName"), "inner locals should still mangle");
assert.ok(
built.startsWith("/*!"),
"a vendored file must ship its licence: MIT requires the notice in every copy",
);
assert.match(built, /Copyright \(c\) 2024 Somebody/);
assert.throws(
() => readFileSync(join(out, "utils/color.global.js.license")),
"the sidecar is a source, not an artifact",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a syntax error in a source file fails the build loudly", () => {
const { root, src, out } = scratch({ "utils/broken.js": "const = ;" });
try {
assert.throws(
() => run(["--src", src, "--out", out]),
(error) => {
assert.notEqual(error.status, 0, "a broken source must not build");
assert.match(String(error.stderr), /broken\.js/);
return true;
},
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,431 @@
// A built-in preset is a whole look, not a palette. Before this, all five
// templates carried nothing but 62 colour options, so "Sage Green" and
// "Monochrome" differed by hue alone while the theme's navigation shape,
// corner radius, spacing, content width and typography -- everything the
// settings page can actually configure -- stayed wherever the user had left
// them. These tests hold the three halves of that fix together: the templates
// carry the structure, rpcd applies it, and the Marketplace's offline copy
// says the same thing the templates do.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import path from "node:path";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
const read = (rel) => readFileSync(path.join(repoRoot, rel), "utf8");
const PRESET_IDS = ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"];
// Duplicated from PRESET_STRUCT_KEYS in root/usr/libexec/rpcd/luci.aurora and
// from LAYOUT_KEYS/TYPOGRAPHY_KEYS in scripts/gen-presets.mjs. A test below
// asserts the shell list matches this one.
const LAYOUT_KEYS = [
"nav_type",
"struct_spacing",
"struct_radius_base",
"struct_content_width_centered",
"toolbar_enabled",
];
const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"];
const STRUCT_KEYS = LAYOUT_KEYS.concat(TYPOGRAPHY_KEYS);
const templateOf = (id) => read(`root/usr/share/aurora/${id}.template`);
const readOption = (source, key) => {
const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source);
return match ? match[1] : null;
};
const structOf = (id) => {
const source = templateOf(id);
return STRUCT_KEYS.reduce((acc, key) => {
acc[key] = readOption(source, key);
return acc;
}, {});
};
const toolbarOf = (source) => {
const items = [];
let current = null;
for (const raw of source.split("\n")) {
const line = raw.trim();
if (/^config\s+toolbar_item\s*$/.test(line)) {
if (current) items.push(current);
current = {};
continue;
}
if (/^config\s/.test(line)) {
if (current) items.push(current);
current = null;
continue;
}
if (!current) continue;
const m = line.match(/^option\s+(title|url|icon|enabled)\s+'([^']*)'/);
if (m && m[2] !== "") current[m[1]] = m[2];
}
if (current) items.push(current);
return items.map((it) =>
it.icon
? { title: it.title, url: it.url, icon: it.icon, enabled: it.enabled }
: { title: it.title, url: it.url, enabled: it.enabled },
);
};
const rpcd = read("root/usr/libexec/rpcd/luci.aurora");
const gallery = read(".dev/src/resource/view/aurora/marketplace.js");
const browserPresets = JSON.parse(
read(".dev/src/resource/aurora/presets.json"),
).presets;
test("every preset template carries the full structural block", () => {
for (const id of PRESET_IDS) {
const struct = structOf(id);
for (const key of STRUCT_KEYS)
assert.ok(
struct[key],
`${id}.template is missing option ${key} -- load_preset_struct_snapshot ` +
`rejects a partial preset, so this template would fail to apply at all`,
);
}
});
// The same bounds validate_and_apply_hub_payload enforces on an inbound hub
// configuration. A preset that could write a value the hub rejects would mean
// the package held two different definitions of a valid look.
test("preset structure values stay inside the bounds the hub path enforces", () => {
const rem = (value) => {
assert.match(value, /^[0-9]+(\.[0-9]+)?rem$/);
return parseFloat(value);
};
for (const id of PRESET_IDS) {
const s = structOf(id);
assert.ok(
["mega-menu", "dropdown", "sidebar"].includes(s.nav_type),
`${id}: unknown nav_type ${s.nav_type}`,
);
assert.ok(["0", "1"].includes(s.toolbar_enabled), `${id}: bad toolbar_enabled`);
const spacing = rem(s.struct_spacing);
assert.ok(spacing >= 0.05 && spacing <= 1, `${id}: spacing out of range`);
const radius = rem(s.struct_radius_base);
assert.ok(radius >= 0 && radius <= 2, `${id}: radius out of range`);
const width = rem(s.struct_content_width_centered);
assert.ok(width >= 40 && width <= 160, `${id}: content width out of range`);
// The settings page writes these through range inputs, so a preset landing
// between two stops would snap to a different value the first time anyone
// touches the slider -- and the config would silently stop matching the
// preset it says is active.
assert.equal(
Math.round(spacing * 100) % 5,
0,
`${id}: spacing must sit on the 0.05rem slider step`,
);
assert.equal(
Math.round(radius * 1000) % 125,
0,
`${id}: radius must sit on the 0.125rem slider step`,
);
assert.equal(width % 1, 0, `${id}: content width must sit on the 1rem slider step`);
// Written into a single-quoted `uci batch` line; is_valid_font_stack
// rejects a literal quote, and so does this.
for (const key of TYPOGRAPHY_KEYS) {
assert.ok(s[key].length <= 200, `${id}: ${key} too long`);
assert.match(s[key], /^[A-Za-z0-9 ,"-]+$/, `${id}: ${key} has illegal characters`);
}
}
});
// The whole point of the change. Colour-only presets were five paint jobs on
// one layout; if a new preset ships without pulling any other lever, the store
// is back to selling hues.
test("the five presets are five different looks, not five palettes", () => {
const structs = PRESET_IDS.map(structOf);
const distinct = (key) => new Set(structs.map((s) => s[key])).size;
assert.ok(distinct("nav_type") >= 3, "all three navigation shapes should be on offer");
assert.equal(distinct("struct_radius_base"), 5, "every preset should own its corner radius");
assert.equal(distinct("struct_font_sans"), 5, "every preset should own its sans typeface");
assert.ok(distinct("struct_spacing") >= 3, "all three spacing stops should be on offer");
assert.ok(distinct("struct_content_width_centered") >= 3, "content width should vary");
assert.equal(distinct("toolbar_enabled"), 2, "at least one preset should turn the toolbar off");
// No two presets may agree on their entire structure: that would be two
// names for one look.
const signatures = structs.map((s) => JSON.stringify(s));
assert.equal(new Set(signatures).size, PRESET_IDS.length);
});
// Spacing scales the padding and gaps of every component at once, so it is the
// lever with the least room to be wrong: three stops are usable and one
// combination is not. A looser scale needs somewhere to breathe, and the
// sidebar shell has none -- its first column already eats the horizontal
// margin and the page runs full-width, so 0.3rem there reads as the layout
// having come apart rather than as a roomier theme. It belongs on a centred
// layout with a wide content column.
const SPACING_STOPS = ["0.2rem", "0.25rem", "0.3rem"];
test("spacing stays on its three stops, and the loose one avoids the sidebar", () => {
assert.equal(
structOf("default").struct_spacing,
"0.25rem",
"the default preset ships the middle stop",
);
const defaultWidth = parseFloat(structOf("default").struct_content_width_centered);
for (const id of PRESET_IDS) {
const s = structOf(id);
assert.ok(
SPACING_STOPS.includes(s.struct_spacing),
`${id}: ${s.struct_spacing} is not one of ${SPACING_STOPS.join(" / ")}`,
);
if (s.struct_spacing !== "0.3rem") continue;
assert.notEqual(
s.nav_type,
"sidebar",
`${id}: the loose spacing must not ride on the sidebar shell`,
);
assert.ok(
parseFloat(s.struct_content_width_centered) > defaultWidth,
`${id}: the loose spacing needs a content column wider than the default ` +
`${defaultWidth}rem to sit in`,
);
}
});
// The font stack is how the device reverse-maps a configuration back to a
// roster entry (resolve_font_preset_id / get_font_preset_by_stack). A stack
// retyped by hand instead of copied would resolve to nothing: the settings
// page would show the wrong font, and sync_and_cache_fonts_from_uci would
// never fetch the woff2 files, so the preset would silently render its
// fallback family.
test("every preset font stack is a verbatim font-presets.conf roster stack", () => {
const roster = { sans: new Map(), mono: new Map() };
const files = new Set();
for (const line of read("root/usr/share/aurora/font-presets.conf").split("\n")) {
const parts = line.split("|");
if (parts[0] === "font") roster[parts[1]]?.set(parts[6], parts[2]);
if (parts[0] === "file") files.add(`${parts[1]}/${parts[2]}`);
}
for (const id of PRESET_IDS) {
const s = structOf(id);
for (const slot of ["sans", "mono"]) {
const fontId = roster[slot].get(s[`struct_font_${slot}`]);
assert.ok(fontId, `${id}: struct_font_${slot} matches no ${slot} roster entry`);
// marketplace.js reads typography.font_* to decide whether to warn that the
// typeface has to be downloaded first, treating exactly "default" and
// "system" as already on the router. That rule only holds while those
// two ids are the ones with no woff2 files to fetch.
const bundled = fontId === "default" || fontId === "system";
assert.equal(
files.has(`${slot}/${fontId}`),
!bundled,
`${slot}/${fontId}: BUNDLED_FONT_IDS in marketplace.js assumes only ` +
`default/system need no download`,
);
}
}
});
test("rpcd applies the same structural keys the templates carry", () => {
const declared = /readonly PRESET_STRUCT_KEYS="([^"]+)"/.exec(rpcd);
assert.ok(declared, "PRESET_STRUCT_KEYS is not declared");
assert.deepEqual(
declared[1].split(/\s+/).filter(Boolean).sort(),
STRUCT_KEYS.slice().sort(),
);
// The colour snapshot alone is not enough any more: apply_theme_preset
// writes one flat list, and the structural half has to be in it.
assert.match(
rpcd,
/struct=\$\(load_preset_struct_snapshot "\$template"\) \|\| \{ PRESET_SNAPSHOT=""; return 1; \}/,
"load_preset_snapshot must fail the whole preset when its structure is unusable",
);
const start = rpcd.indexOf('"apply_theme_preset")');
const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start));
assert.match(applyBlock, /backup_if_mine/, "删之前必须先留底");
assert.match(
applyBlock,
/delete aurora\.@toolbar_item\[0\]/,
"applying a built-in preset replaces the shortcuts with its own",
);
assert.match(applyBlock, /load_preset_toolbar "\$template"/);
});
// PRESET_SNAPSHOT is one flat list now that it carries structure as well as
// colour. get_theme_preset publishes it grouped the way a hub payload groups
// the same fields -- an object called "colors" holding nav_type would sit
// undetected until something iterated it, because the settings UI only ever
// looks colours up by key.
test("get_theme_preset publishes the snapshot grouped, not dumped into colors", () => {
const start = rpcd.indexOf("json_add_theme_preset_snapshot() {");
assert.ok(start !== -1, "json_add_theme_preset_snapshot not found");
// The top-level dispatcher, not one of the `case "$1" in` lines inside a
// function -- those all sit above this one and would slice to nothing.
const fn = rpcd.slice(start, rpcd.indexOf('\ncase "$1" in'));
assert.ok(fn.length, "the function body is empty");
for (const group of ["colors", "layout", "typography"])
assert.match(fn, new RegExp(`json_add_object "${group}"`), `missing ${group} group`);
assert.match(
fn,
/light_\*\|dark_\*\) json_add_string/,
"the colors object must take only the colour keys",
);
assert.match(fn, /struct_font_sans\|struct_font_mono\) json_add_string/);
assert.ok(
!/\[ -n "\$key" \] && json_add_string/.test(fn),
"the unfiltered dump would put struct_* keys inside colors",
);
});
test("applying a preset fetches the typeface it names", () => {
assert.match(rpcd, /sync_and_cache_fonts_from_uci\(\)\s*\{/);
assert.match(
rpcd,
/cache_font_slot "sans" "\$sans_preset"/,
"the helper must download, not merely rewrite the CSS",
);
// Detached: it talks to the network and the rpc call must not wait on it.
assert.match(rpcd, /\) <\/dev\/null >\/dev\/null 2>&1 &/);
const start = rpcd.indexOf('"apply_theme_preset")');
const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start));
assert.ok(
applyBlock.indexOf('uci -q commit aurora') <
applyBlock.indexOf("sync_and_cache_fonts_from_uci"),
"apply_theme_preset must resync fonts after committing",
);
});
test("the browser copy of the presets matches the templates", () => {
assert.deepEqual(Object.keys(browserPresets).sort(), PRESET_IDS.slice().sort());
for (const id of PRESET_IDS) {
const entry = browserPresets[id];
const struct = structOf(id);
for (const key of LAYOUT_KEYS)
assert.equal(entry.layout[key], struct[key], `${id}: layout.${key} drifted`);
for (const key of TYPOGRAPHY_KEYS)
assert.equal(entry.typography[key], struct[key], `${id}: typography.${key} drifted`);
// The hub's payload shape, so the store reads a built-in preset with the
// accessors it already uses for a shared configuration.
assert.ok(Array.isArray(entry.toolbar), `${id}: toolbar must be an array`);
assert.deepEqual(
entry.toolbar,
toolbarOf(templateOf(id)),
`${id}: toolbar drifted from the template`,
);
assert.ok(entry.toolbar.length > 0, `${id}: every preset ships its own shortcuts`);
for (const key of Object.keys(entry.colors))
assert.equal(
entry.colors[key],
readOption(templateOf(id), key),
`${id}: colors.${key} drifted from the template`,
);
}
});
// The browser copy is downloaded by every visitor to the store; the templates
// are not. So it carries only what the store reads, and the store reads only
// the swatch keys -- applying a built-in preset goes through
// apply_theme_preset, which reads the full template on the router. Shipping all
// 62 colours cost 7,968 bytes that nothing looked at.
test("the browser copy carries exactly the swatch keys the store reads", () => {
const declared = /const SWATCH_KEYS = \[([^\]]*)\]/.exec(gallery);
assert.ok(declared, "marketplace.js no longer declares SWATCH_KEYS");
const swatchKeys = [...declared[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
assert.ok(swatchKeys.length > 0, "SWATCH_KEYS parsed empty");
const expected = ["light", "dark"]
.flatMap((mode) => swatchKeys.map((key) => `${mode}_${key}`))
.sort();
for (const id of PRESET_IDS) {
assert.deepEqual(
Object.keys(browserPresets[id]).includes("colors")
? Object.keys(browserPresets[id].colors).sort()
: null,
expected,
`${id}: the browser copy must carry the store's swatch keys and nothing else ` +
`-- add a key to SWATCH_KEYS and gen-presets.mjs must follow, or the ` +
`card renders a blank swatch`,
);
}
});
test("the Marketplace no longer promises that a preset only repaints", () => {
assert.ok(
!gallery.includes("Layout (unchanged by this preset)"),
"the built-in drawer must show the preset's own layout, not the current one",
);
assert.ok(
!/Presets set the light and dark colors only/.test(gallery),
"the apply confirmation must stop claiming a preset changes colours alone",
);
assert.match(
gallery,
/const buildLayoutRows = /,
"built-in and community drawers must share one layout table",
);
assert.match(
gallery,
/buildLayoutRows\(preset\.preview\.layout \|\| \{\}, typography\)/,
"the built-in drawer must render that table",
);
// The card advertises "Works offline"; a preset naming a Fontsource family
// is not fully offline until the woff2 files land.
assert.match(gallery, /const needsFontDownload = /);
assert.match(gallery, /const BUNDLED_FONT_IDS = \["default", "system"\]/);
});
// The shape of presets.json changed once already (151e50a moved it from
// {light,dark} to {colors,layout,typography,toolbar}), and browsers kept
// serving the old one for months: uhttpd reports Last-Modified as the epoch
// and sends no Cache-Control, so a browser's heuristic freshness works out to
// roughly 5.6 years and it never revalidates. A cached copy of the old shape
// renders every built-in card with the fallback palette and a top nav bar --
// no colours, no sidebar, no font chip -- and nothing in the UI says why.
//
// So the fetch carries a content hash, the way theme.js does for the token
// engine. gen-presets.mjs stamps it; this checks the two agree.
test("the store cache-busts presets.json with a hash of its contents", () => {
const data = read(".dev/src/resource/aurora/presets.json");
const expected = createHash("sha256").update(data).digest("hex").slice(0, 8);
const stamped = /const PRESETS_VERSION = "([^"]+)";/.exec(gallery)?.[1];
assert.ok(stamped, "marketplace.js declares PRESETS_VERSION");
assert.equal(
stamped,
expected,
"PRESETS_VERSION is stale -- rerun `pnpm gen-presets`",
);
assert.match(
gallery,
/L\.resource\("aurora\/presets\.json"\)\s*\+\s*"\?v="\s*\+\s*PRESETS_VERSION/,
"the fetch must carry the stamp, or browsers keep the copy they already have",
);
});
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("utils/feed-check.js");
// These are pure functions, so the test actually runs them rather than
// pattern-matching the source: strip LuCI's "require" directives and hand the
// remaining function body a stub baseclass.
async function load() {
const src = await readFile(SRC, "utf8");
const body = src
.replace(/^"use strict";$/m, "")
.replace(/^"require [^"]+";$/gm, "");
return new Function("baseclass", body)({ extend: (obj) => obj });
}
test("parseRevision pulls the rYYYYMMDD stamp", async () => {
const m = await load();
assert.equal(m.parseRevision("1.1.3-r20260803"), 20260803);
assert.equal(m.parseRevision("1.1.13-r20260802"), 20260802);
});
test("parseRevision returns null for anything else", async () => {
const m = await load();
assert.equal(m.parseRevision("26.192.49224.6b21ba7"), null);
assert.equal(m.parseRevision("1.1.3"), null);
assert.equal(m.parseRevision(""), null);
assert.equal(m.parseRevision(undefined), null);
assert.equal(m.parseRevision(null), null);
});
test("isNewer never orders versions it cannot parse", async () => {
const m = await load();
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4-r20260901"), true);
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.3-r20260803"), false);
// Measured on a live router: the installed config was newer than the feed's
// snapshot build. Claiming an update there would be a lie.
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.2-r20260727"), false);
// install.sh: opkg and apk version schemes are not comparable -- never order.
assert.equal(m.isNewer("1.1.3", "1.1.4"), false);
assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4"), false);
});
test("findManifestVersion reads the live manifest shape", async () => {
const m = await load();
// Shape confirmed against https://openwrt.eamonxg.fun/manifest.json
const manifest = {
generated: "...",
channels: {
snapshots: {
apk: [
{ pkg: "luci-theme-aurora", version: "1.1.13-r20260802" },
{ pkg: "luci-app-aurora-config", version: "1.1.2-r20260727" },
],
opkg: [{ pkg: "luci-theme-aurora", version: "1.1.13-r20260802" }],
},
},
};
assert.equal(
m.findManifestVersion(manifest, "snapshots", "apk", "luci-app-aurora-config"),
"1.1.2-r20260727",
);
assert.equal(m.findManifestVersion(manifest, "releases", "apk", "x"), null);
assert.equal(m.findManifestVersion(manifest, "snapshots", "apk", "nope"), null);
assert.equal(m.findManifestVersion(null, "snapshots", "apk", "x"), null);
});
test("pickPackageManagerPath reads the live menu instead of guessing", async () => {
const m = await load();
const tree = (children) => ({
children: { admin: { children: { system: { children } } } },
});
assert.equal(
m.pickPackageManagerPath(tree({ "package-manager": {} })),
"admin/system/package-manager",
);
assert.equal(m.pickPackageManagerPath(tree({ opkg: {} })), "admin/system/opkg");
});
test("pickPackageManagerPath skips nodes the user may not open", async () => {
const m = await load();
const tree = (children) => ({
children: { admin: { children: { system: { children } } } },
});
// ui.menu keeps unauthorised nodes but flags them; linking there would 403.
assert.equal(
m.pickPackageManagerPath(tree({ "package-manager": { satisfied: false } })),
null,
);
assert.equal(
m.pickPackageManagerPath(
tree({ "package-manager": { satisfied: false }, opkg: {} }),
),
"admin/system/opkg",
);
});
test("pickPackageManagerPath yields null when there is no software page", async () => {
const m = await load();
assert.equal(m.pickPackageManagerPath({ children: {} }), null);
assert.equal(m.pickPackageManagerPath(null), null);
assert.equal(
m.pickPackageManagerPath({
children: { admin: { children: { system: { children: { reboot: {} } } } } },
}),
null,
);
});
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
test("rpcd: the package manager is detected from its database, like install.sh", async () => {
const src = await readFile(RPCD, "utf8");
const block = src.slice(
src.indexOf("detect_package_manager()"),
src.indexOf("get_system_lang()"),
);
assert.match(block, /\/lib\/apk\/db\/installed/);
assert.match(block, /\/usr\/lib\/opkg\/status/);
assert.ok(
!/command -v (opkg|apk)/.test(block),
"binary probing can disagree with install.sh on the same machine",
);
});
test("rpcd: apk versions are read from the database, not by spawning apk", async () => {
const src = await readFile(RPCD, "utf8");
// Match a command substitution, not the words: the comment above the awk
// block names the call it replaced, and that is worth keeping.
assert.ok(
!/\$\(apk list -I/.test(src),
"spawning apk parses the whole installed db on every page load",
);
// Verified on a live snapshot router: records are P:<name> then V:<version>.
assert.match(src, /\^P:/, "records are keyed by the P: field");
assert.match(src, /\^V:/, "versions come from the V: field");
});
test("rpcd: feed status rides along on get_init_data", async () => {
const src = await readFile(RPCD, "utf8");
assert.match(src, /json_add_feed_status\(\)/, "missing the status helper");
assert.match(
src,
/json_add_installed_versions[\s\S]{0,200}json_add_feed_status/,
"feed status must be emitted inside the existing init-data call",
);
assert.ok(
!/"get_feed_status"/.test(src),
"a separate method would add a round trip on every page load",
);
});
test("rpcd: feed constants match the spec", async () => {
const src = await readFile(RPCD, "utf8");
assert.match(src, /AURORA_FEED_HOST=["']?openwrt\.eamonxg\.fun/);
assert.match(src, /AURORA_FEED_FPR=["']?82d72f5ededb6163/);
assert.match(src, /AURORA_FEED_CHANNEL=["']?snapshots/);
});
test("rpcd: add_feed drops its own lines before appending", async () => {
const src = await readFile(RPCD, "utf8");
const block = src.slice(src.indexOf("aurora_add_feed()"));
assert.match(block, /aurora_drop_feed_lines/, "old lines must be removed first");
assert.match(
block,
/https:\/\/\$AURORA_FEED_HOST\//,
"apk lines are matched by the host prefix",
);
assert.match(block, /src\/gz eamonxg /, "opkg lines are matched by the feed name");
});
test("rpcd: add_feed distinguishes a write failure from an index failure", async () => {
const src = await readFile(RPCD, "utf8");
assert.match(src, /json_add_boolean "index_refreshed"/);
});
test("rpcd: add_feed installs the keys where each manager looks for them", async () => {
const src = await readFile(RPCD, "utf8");
assert.match(src, /\/etc\/opkg\/keys\/\$AURORA_FEED_FPR/);
assert.match(src, /\/etc\/apk\/keys\/eamonxg\.pem/);
});
test("rpcd: add_feed is registered on the ubus object", async () => {
const src = await readFile(RPCD, "utf8");
assert.match(src, /"add_feed"/);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("view/aurora/studio.js");
test("feed ui: no manifest request when the feed is not configured", async () => {
const src = await readFile(SRC, "utf8");
assert.match(
src,
/if \(!feedStatus\.configured\) return Promise\.resolve\(null\)/,
"an unconfigured router must not reach out to the network at all",
);
});
test("feed ui: the manifest is fetched by the browser, never through rpcd", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const FEED_HOST = "openwrt\.eamonxg\.fun"/);
assert.match(src, /const MANIFEST_URL = `https:\/\/\$\{FEED_HOST\}\/manifest\.json`/);
assert.match(src, /fetch\(MANIFEST_URL/);
assert.ok(
!/callFetchManifest|hub_http|fetch_url/.test(src),
"the router must not proxy the manifest",
);
});
test("feed ui: the result is cached for the session", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /sessionStorage/);
});
test("feed ui: the update-source prompt left Studio for the Marketplace inbox", async () => {
const src = await readFile(SRC, "utf8");
for (const gone of ["buildFeedNotice", "FEED_NOTICE_KEY", "feed_notice_dismissed", "callAddFeed", "Add update source", "Upgrading Aurora needs"])
assert.ok(!src.includes(gone), gone);
assert.ok(
!/uci\.set\("aurora", "theme", "feed_/.test(src),
"nothing about the feed is a uci change",
);
// "A newer build exists" is a different thing and stays in Studio.
assert.match(src, /_\("%s available"\)/);
});
test("feed ui: nothing is said when there is no update", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!/Up to date|已是最新/.test(src), "silence is the up-to-date state");
});
test("feed ui: add_feed is the only rpcd method, and status still rides on get_init_data", async () => {
const hubApi = await readFile(srcPath("utils/hub-api.js"), "utf8");
assert.match(hubApi, /method: "add_feed"/, "add_feed must be declared");
for (const file of [hubApi, await readFile(SRC, "utf8")])
assert.ok(!/method: "get_feed_status"/.test(file), "feed status rides on get_init_data");
});
test("feed ui: a network failure degrades in silence", async () => {
const src = await readFile(SRC, "utf8");
const block = src.slice(
src.indexOf("const checkForUpdates ="),
src.indexOf("const showUpdateCapsule ="),
);
assert.match(block, /\.catch\(\(\) => null\)/, "no error toast, no console noise");
});
@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const confPath = fileURLToPath(
new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url),
);
const lines = readFileSync(confPath, "utf8").trim().split("\n");
test("header declares v2 generated format", () => {
assert.match(lines[0], /^v2\|generated-by-gen-font-presets\|do-not-edit$/);
});
const fonts = lines.filter((l) => l.startsWith("font|")).map((l) => l.split("|"));
const files = lines.filter((l) => l.startsWith("file|")).map((l) => l.split("|"));
test("font lines have 7 fields and known slots", () => {
assert.ok(fonts.length >= 8);
for (const f of fonts) {
assert.equal(f.length, 7, `bad font line: ${f.join("|")}`);
assert.ok(["sans", "mono"].includes(f[1]));
assert.ok(/^[a-z0-9-]+$/.test(f[2]), `bad name: ${f[2]}`);
assert.ok(f[6].length > 0, "stack required");
}
});
test("file lines carry valid sha256 and pinned dual-source urls", () => {
assert.ok(files.length > 0);
for (const f of files) {
const [, slot, name, weight, sha256, url1, url2] = f;
assert.equal(f.length, 7, `bad file line: ${f.join("|")}`);
assert.ok(["sans", "mono"].includes(slot));
assert.ok(/^(400|500|600|700)$/.test(weight));
assert.match(sha256, /^[0-9a-f]{64}$/);
assert.match(url1, /^https:\/\/cdn\.jsdelivr\.net\/npm\/@fontsource\/[a-z0-9-]+@\d+\.\d+\.\d+\/files\/.+\.woff2$/);
assert.match(url2, /^https:\/\/registry\.npmmirror\.com\/@fontsource\/[a-z0-9-]+\/\d+\.\d+\.\d+\/files\/files\/.+\.woff2$/);
assert.ok(fonts.some((x) => x[1] === slot && x[2] === name), `orphan file line for ${slot}/${name}`);
}
});
test("every webfont preset stays within 4 files, non-webfont has none", () => {
for (const f of fonts) {
const [, slot, name, , , family] = f;
const n = files.filter((x) => x[1] === slot && x[2] === name).length;
if (name === "default" || name === "system") assert.equal(n, 0);
else {
assert.ok(n >= 2 && n <= 4, `${slot}/${name} has ${n} files`);
assert.ok(files.some((x) => x[1] === slot && x[2] === name && x[3] === "400"), `${slot}/${name} missing weight 400`);
assert.ok(family.length > 0);
}
}
});
@@ -0,0 +1,745 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("utils/hub-api.js");
test("hub-api module exposes the shared surface", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /^"require baseclass";/m);
assert.match(src, /^"require rpc";/m);
assert.ok(src.includes("aurora.hub.list"), "missing cache key");
assert.ok(src.includes("callHubList"), "missing callHubList");
assert.ok(src.includes("callHubGet"), "missing callHubGet");
assert.ok(src.includes("getStale"), "missing getStale");
assert.match(src, /return baseclass\.extend\(/);
});
test("hub-api list/detail go straight to the hub from the browser", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const HUB_BASE = "https:\/\/themes\.eamonxg\.fun"/);
assert.match(src, /fetch\(HUB_BASE \+ path/, "list/get must use fetch, not ubus");
assert.match(src, /AbortController/, "missing fetch timeout");
assert.match(src, /hub_unreachable/, "network failures must map to the rpcd envelope");
assert.match(src, /invalid_id/, "bad ids must short-circuit without a request");
assert.match(src, /result: 0, data/, "success must keep the rpcd envelope");
// list/get 不得再走 rpc.declare
assert.ok(
!/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_list"/.test(src),
"hub_list still declared over ubus",
);
assert.ok(
!/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_get"/.test(src),
"hub_get still declared over ubus",
);
});
// 两份缓存共享一个语义:只作首帧种子,不设 TTL。fetchSort / refreshMyShares
// 每次渲染都会覆盖它们,所以"过期"没有意义 —— 而一份永远可用的旧数据,正是
// 首屏不必等待 205ms 跨太平洋往返的前提。
test("hub-api: caches are first-paint seeds, not TTL'd stores", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!src.includes("CACHE_TTL"), "CACHE_TTL had no live reader; it should be gone");
assert.ok(
!/^\s+get\(\)\s*\{/m.test(src),
"listCache.get() had no caller; it should be gone",
);
assert.match(src, /localStorage\.getItem\(/);
assert.match(src, /localStorage\.setItem\(/);
assert.match(src, /localStorage\.removeItem\(/);
});
test("hub-api exposes meCache alongside listCache", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(src.includes("meCache"), "missing meCache");
assert.ok(src.includes("aurora.hub.list"), "missing list cache key");
assert.ok(src.includes("aurora.hub.me"), "missing me cache key");
});
test("hub-api module exposes the apply/status/restore declares (Task 6)", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(src.includes("callHubApply"), "missing callHubApply");
assert.match(src, /method:\s*"hub_apply"/);
assert.match(src, /params:\s*\["id"\]/);
assert.ok(src.includes("callGetHubStatus"), "missing callGetHubStatus");
assert.match(src, /method:\s*"get_hub_status"/);
assert.match(src, /params:\s*\["job_id"\]/);
assert.ok(src.includes("callHubRestore"), "missing callHubRestore");
assert.match(src, /method:\s*"hub_restore_backup"/);
});
test("hub-api module exposes the publish/my-shares/delete declares (Task 8)", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(src.includes("callHubShareBegin"), "missing callHubShareBegin");
assert.ok(src.includes("callHubShareCommit"), "missing callHubShareCommit");
assert.ok(src.includes("callHubMe"), "missing callHubMe");
assert.match(src, /method:\s*"hub_me"/);
assert.ok(src.includes("callHubDelete"), "missing callHubDelete");
assert.match(src, /method:\s*"hub_delete"/);
// 单请求的老路必须彻底消失:它把整张登录背景 base64 塞进一个 1.6MB 的
// body,而 uclient-fetch 走 TLS 推不动 —— 留着它就是留着那个 bug。
assert.ok(!/method:\s*"hub_share"/.test(src), "hub_share must not be re-declared");
assert.ok(!/method:\s*"hub_update"/.test(src), "hub_update must not be re-declared");
});
// hubAssetUrl is pure, so these tests actually run it rather than
// pattern-matching the source. Same harness as feed-check-module.test.mjs:
// strip LuCI's "require" directives and hand the body stub globals.
const strip = (src) =>
src.replace(/^"use strict";$/m, "").replace(/^"require [^"]+";$/gm, "");
// hub-api requires utils.notices; hand it the real module rather than a stub,
// so the refresh tests below exercise the sanitizer the router will run.
async function load(rpc) {
const notices = new Function(
"baseclass",
strip(await readFile(srcPath("utils/notices.js"), "utf8")),
)({ extend: (obj) => obj });
return new Function("baseclass", "rpc", "notices", strip(await readFile(SRC, "utf8")))(
{ extend: (obj) => obj },
rpc || { declare: () => () => Promise.resolve(null) },
notices,
);
}
test("hubAssetUrl makes the hub's relative asset path absolute", async () => {
const m = await load();
assert.equal(
m.hubAssetUrl("/assets/6zcxcg07/logo_svg"),
"https://themes.eamonxg.fun/assets/6zcxcg07/logo_svg",
);
assert.equal(
m.hubAssetUrl("/assets/abc12345/favicon_png"),
"https://themes.eamonxg.fun/assets/abc12345/favicon_png",
);
});
test("hubAssetUrl rejects anything that is not a hub asset path", async () => {
const m = await load();
// The path is hub-supplied and therefore untrusted: everything that is not
// the one shape the hub can legitimately produce yields "", and the caller
// draws its plain-colour fallback instead.
assert.equal(m.hubAssetUrl(""), "");
assert.equal(m.hubAssetUrl(null), "");
assert.equal(m.hubAssetUrl(undefined), "");
assert.equal(m.hubAssetUrl("assets/abc/logo_svg"), "");
assert.equal(m.hubAssetUrl("/assets/abc/../../etc/passwd"), "");
assert.equal(m.hubAssetUrl("/assets/abc/logo_svg?x=1"), "");
assert.equal(m.hubAssetUrl("//evil.example/assets/abc/logo_svg"), "");
assert.equal(m.hubAssetUrl("https://evil.example/assets/abc/logo_svg"), "");
assert.equal(m.hubAssetUrl("javascript:alert(1)"), "");
assert.equal(m.hubAssetUrl({ toString: () => "/assets/abc/logo_svg" }), "");
});
test("hub-api: publishing drops the author parameter, nickname gets its own call", async () => {
const src = await readFile(SRC, "utf8");
// 署名是账号属性,由 hub 从 device_token 解析 —— 不是每次发布可以挑的字段。
assert.match(
src,
/method:\s*"hub_share_begin",\s*\n\s*params:\s*\["name", "description", "target_id"\]/,
);
assert.ok(src.includes("callHubSetNickname"), "missing callHubSetNickname");
assert.match(src, /method:\s*"hub_set_nickname"/);
assert.match(src, /params:\s*\["nickname"\]/);
});
test("hub-api exposes the creator key export/import declares", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(src.includes("callHubExportKey"), "missing callHubExportKey");
assert.match(src, /method:\s*"hub_export_key"/);
assert.ok(src.includes("callHubImportKey"), "missing callHubImportKey");
assert.match(src, /method:\s*"hub_import_key"/);
assert.match(src, /params:\s*\["key"\]/);
});
test("hub-api exposes callHubMe and drops callHubMyShares", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(src.includes("callHubMe"), "missing callHubMe");
assert.ok(!src.includes("callHubMyShares"), "callHubMyShares must be gone");
assert.match(src, /method:\s*"hub_me"/);
});
// localStorage 在 node 里不存在。模块只在方法体内引用它,因此调用前把桩挂到
// globalThis 即可 —— 与上面 hubAssetUrl 那两个测试一样,真的把代码跑起来,
// 而不是对源码做模式匹配。
function withLocalStorage(fn) {
const store = new Map();
const had = "localStorage" in globalThis;
const prev = globalThis.localStorage;
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
try {
return fn(store);
} finally {
if (had) globalThis.localStorage = prev;
else delete globalThis.localStorage;
}
}
test("meCache round-trips the creator profile and clear() empties it", async () => {
const m = await load();
withLocalStorage(() => {
assert.equal(m.meCache.getStale(), null);
m.meCache.set({ id: "abc12345", nickname: "eamon", configs: [{ id: "x" }] });
assert.deepEqual(m.meCache.getStale(), {
id: "abc12345",
nickname: "eamon",
configs: [{ id: "x" }],
});
m.meCache.clear();
assert.equal(m.meCache.getStale(), null);
});
});
test("meCache.getStale never expires -- it is a first-paint seed", async () => {
const m = await load();
withLocalStorage((store) => {
// 一条 1970 年的信封仍必须返回。过期没有意义:refreshMyShares 每次渲染
// 都会覆盖它,而扔掉它只会换来一次 0.9s 的白屏。
store.set(
"aurora.hub.me",
JSON.stringify({ timestamp: 0, value: { id: "old", nickname: null, configs: [] } }),
);
assert.deepEqual(m.meCache.getStale(), {
id: "old",
nickname: null,
configs: [],
});
});
});
test("both caches survive corrupt localStorage", async () => {
const m = await load();
withLocalStorage((store) => {
store.set("aurora.hub.me", "{not json");
store.set("aurora.hub.list", "{not json");
assert.equal(m.meCache.getStale(), null);
assert.equal(m.listCache.getStale(), null);
});
});
test("the two caches are independent -- clearing one keeps the other", async () => {
const m = await load();
withLocalStorage(() => {
m.listCache.set({ items: [{ id: "a" }] });
m.meCache.set({ id: "me", nickname: null, configs: [] });
m.meCache.clear();
assert.equal(m.meCache.getStale(), null);
assert.deepEqual(m.listCache.getStale(), { items: [{ id: "a" }] });
});
});
// 三段式发布:两端走 ubus(device_token 留在路由器),中间那段字节由浏览器
// 直接 PUT 到 hub —— uclient-fetch 走 TLS 推不动 1.2MB。
test("hub-api declares the three-step publish", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /method: "hub_share_begin"/);
assert.match(src, /params: \["name", "description", "target_id"\]/);
assert.match(src, /method: "hub_share_commit"/);
assert.match(src, /params: \["draft_id"\]/);
});
test("publishCurrentConfig uploads asset bytes from the browser", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /publishCurrentConfig/);
assert.match(src, /XMLHttpRequest/, "needs upload progress events");
assert.match(src, /upload\.onprogress/);
assert.match(src, /"Bearer " \+/, "asset PUT authenticates with the ticket");
assert.match(src, /HUB_BASE \+ entry\.url/);
// 票据是浏览器唯一拿到的凭证。断言只针对代码 —— 注释里解释"为什么浏览器
// 拿不到 device_token"正是该留下的东西,把它一起禁掉会逼人删掉理由。
const code = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
assert.ok(
!/device_token/.test(code),
"the device token must never appear in browser code",
);
});
// 调用方要据此决定发布成功那句话提不提审核 —— 这个数它已经在手里(begun.assets),
// 为一句文案再往 hub 跑一趟是白跑。
test("publishCurrentConfig reports how many assets it uploaded", async () => {
const src = await readFile(SRC, "utf8");
assert.match(
src,
/result:\s*0,\s*id:\s*done\.id,\s*assets:\s*entries\.length/,
"the caller cannot word the review notice without knowing whether anything was uploaded",
);
});
// ---------------------------------------------------------------------------
// Schema compat + notices
// fetch 同样只在方法体内被引用,所以和 localStorage 一样挂到 globalThis 上。
// respond 收到 URL,返回 { status, body } 或抛错(断网)。
async function withFetch(respond, fn) {
const calls = [];
const prev = globalThis.fetch;
globalThis.fetch = async (url) => {
calls.push(String(url));
const { status, body } = await respond(String(url));
return { status, ok: status >= 200 && status < 300, json: async () => body };
};
try {
return await fn(calls);
} finally {
globalThis.fetch = prev;
}
}
// withLocalStorage 在 fn 返回的那一刻就拆桩,等不了 promise。
async function withStorage(fn) {
const store = new Map();
const had = "localStorage" in globalThis;
const prev = globalThis.localStorage;
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
try {
return await fn(store);
} finally {
if (had) globalThis.localStorage = prev;
else delete globalThis.localStorage;
}
}
// refreshNotices 用到 L.resolveDefault;LuCI 里它是全局的。
async function withLuci(fn) {
const had = "L" in globalThis;
const prev = globalThis.L;
globalThis.L = {
resolveDefault: (promise, fallback) => Promise.resolve(promise).catch(() => fallback),
};
try {
return await fn();
} finally {
if (had) globalThis.L = prev;
else delete globalThis.L;
}
}
// 记录每一次 ubus 调用;me 是 hub_me 的应答。
const recordingRpc = (me, init) => {
const calls = [];
return {
calls,
declare: (spec) => () => {
calls.push(spec.method);
return Promise.resolve(
spec.method === "hub_me" ? me : spec.method === "get_init_data" ? init || null : null,
);
},
};
};
const feedNotice = (over) => ({
id: "n1",
level: "warning",
audience: "all",
title: "Heads up",
body: "Something changed.",
url: "",
i18n: {},
at: "",
...over,
});
test("the client schema is one named constant, and the host string is not repeated", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /^const CLIENT_SCHEMA = 1;$/m);
assert.equal((await load()).CLIENT_SCHEMA, 1);
assert.equal(src.split("themes.eamonxg.fun").length - 1, 1, "HUB_BASE must stay the only copy");
for (const rel of ["utils/notices.js", "preload/aurora-notices.js"])
assert.ok(
!(await readFile(srcPath(rel), "utf8")).includes("eamonxg"),
`${rel} must reach the hub through hub-api`,
);
});
test("callHubList tells the hub which schema this client reads", async () => {
const m = await load();
await withFetch(
() => ({ status: 200, body: { items: [] } }),
async (calls) => {
await m.callHubList("new", 3);
await m.callHubList("bogus", -1);
assert.deepEqual(calls, [
"https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=new&page=3&schema=1",
"https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=hot&page=1&schema=1",
]);
},
);
});
test("callHubNotices reads the public feed straight from the browser", async () => {
const m = await load();
await withFetch(
() => ({ status: 200, body: { notices: [feedNotice()] } }),
async (calls) => {
const res = await m.callHubNotices();
assert.deepEqual(calls, ["https://themes.eamonxg.fun/api/v1/notices?theme=aurora&schema=1"]);
assert.deepEqual(res, { result: 0, data: { notices: [feedNotice()] } });
},
);
});
test("a hub that has not deployed the feed yet means no notices, not an error", async () => {
const m = await load();
await withFetch(
() => ({ status: 404, body: { error: "not_found" } }),
async () => {
assert.deepEqual(await m.callHubNotices(), { result: 0, data: { notices: [] } });
// The same 404 still means "gone" everywhere else.
assert.deepEqual(await m.callHubGet("abc123"), { result: 1, error: "invalid_id" });
},
);
await withFetch(
() => ({ status: 500, body: {} }),
async () =>
assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }),
);
await withFetch(
() => {
throw new TypeError("network down");
},
async () =>
assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }),
);
});
test("inbox state lives under its documented keys; the dismissed list is gone", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!src.includes("noticesDismissed"), "replaced by inboxRead / inboxDone");
const m = await load();
withLocalStorage((store) => {
assert.equal(m.noticesCache.getStale(), null);
assert.equal(m.noticesCheckedAt(), 0);
assert.deepEqual(m.inboxState(), { read: [], done: [] });
m.noticesCache.set({ notices: [] });
m.markNoticesChecked(1790000000000);
m.setInboxState({ read: ["n1", "rejected:c1"], done: ["n2"] });
assert.deepEqual([...store.keys()].sort(), [
"aurora.hub.inboxDone",
"aurora.hub.inboxRead",
"aurora.hub.notices",
"aurora.hub.noticesChecked",
]);
assert.deepEqual(m.noticesCache.getStale(), { notices: [] });
assert.equal(m.noticesCheckedAt(), 1790000000000);
assert.deepEqual(m.inboxState(), { read: ["n1", "rejected:c1"], done: ["n2"] });
});
});
test("inbox state survives corrupt, hostile and unavailable storage", async () => {
const m = await load();
withLocalStorage((store) => {
store.set("aurora.hub.noticesChecked", "{not json");
store.set("aurora.hub.inboxRead", "{not json");
store.set("aurora.hub.inboxDone", '{"length":1,"0":"n1"}');
assert.equal(m.noticesCheckedAt(), 0);
assert.deepEqual(m.inboxState(), { read: [], done: [] });
store.set("aurora.hub.noticesChecked", '"1790000000000"');
store.set("aurora.hub.inboxRead", '["n1",7,null,{"id":"x"},"n2"]');
assert.equal(m.noticesCheckedAt(), 0);
assert.deepEqual(m.inboxState().read, ["n1", "n2"]);
});
// Safari private mode, a full quota, storage disabled by policy: every
// accessor throws. None of it may reach the page.
const had = "localStorage" in globalThis;
const prev = globalThis.localStorage;
const boom = () => {
throw new Error("SecurityError");
};
globalThis.localStorage = { getItem: boom, setItem: boom, removeItem: boom };
const prevError = console.error;
console.error = () => {};
try {
assert.equal(m.noticesCheckedAt(), 0);
assert.deepEqual(m.inboxState(), { read: [], done: [] });
assert.equal(m.noticesCache.getStale(), null);
assert.doesNotThrow(() => m.markNoticesChecked(1));
assert.doesNotThrow(() => m.setInboxState({ read: ["n1"], done: [] }));
assert.doesNotThrow(() => m.noticesCache.set({ notices: [] }));
assert.doesNotThrow(() => m.noticesCache.clear());
assert.doesNotThrow(() => m.setNoticesLocal(null));
} finally {
console.error = prevError;
if (had) globalThis.localStorage = prev;
else delete globalThis.localStorage;
}
// With no localStorage at all the reference itself throws.
assert.equal(m.noticesCheckedAt(), 0);
assert.doesNotThrow(() => m.markNoticesChecked(1));
});
const meReply = (...configs) => ({ result: 0, data: { id: "me", nickname: "eamon", configs } });
const ownShare = (over) => ({ id: "c1", name: "Nord Night", status: "active", ...over });
test("refreshNotices caches a sanitized feed, stamps the check and prunes read/done keys", async () => {
const rpc = recordingRpc(null);
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => ({
status: 200,
body: {
notices: [
feedNotice({ id: "keep" }),
feedNotice({ id: "bad id" }),
feedNotice({ id: "info1", level: "info", junk: "<script>" }),
],
},
}),
() =>
withStorage(async () => {
m.setInboxState({ read: ["keep", "longGone"], done: ["info1", "alsoGone", "rejected:c9"] });
const before = Date.now();
const snapshot = await m.refreshNotices();
assert.deepEqual(snapshot.notices.map((n) => n.id), ["keep", "info1"]);
assert.equal("junk" in snapshot.notices[1], false);
assert.equal("muted" in snapshot, false);
assert.deepEqual(m.noticesCache.getStale(), snapshot);
assert.ok(m.noticesCheckedAt() >= before);
// hub_me was not asked, so nothing is known about derived items and
// their keys stay put.
assert.deepEqual(m.inboxState(), { read: ["keep"], done: ["info1", "rejected:c9"] });
assert.deepEqual(rpc.calls, ["get_init_data"]);
}),
),
);
});
test("refreshNotices asks hub_me only for a creators notice or a router known to have shares", async () => {
const creatorsFeed = { notices: [feedNotice({ id: "cr", audience: "creators" })] };
const plainFeed = { notices: [feedNotice()] };
const cases = [
{ feed: plainFeed, cachedMe: null, asked: false },
{ feed: plainFeed, cachedMe: { id: "me", configs: [] }, asked: false },
{ feed: creatorsFeed, cachedMe: null, asked: true },
{ feed: plainFeed, cachedMe: { id: "me", configs: [ownShare()] }, asked: true },
];
for (const { feed, cachedMe, asked } of cases) {
const fresh = meReply(ownShare({ id: "c2", status: "removed" }));
const rpc = recordingRpc(fresh);
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => ({ status: 200, body: feed }),
() =>
withStorage(async () => {
if (cachedMe) m.meCache.set(cachedMe);
await m.refreshNotices();
assert.deepEqual(rpc.calls, asked ? ["get_init_data", "hub_me"] : ["get_init_data"]);
// The reply lands in the cache the preload derives its items from.
assert.deepEqual(m.meCache.getStale(), asked ? fresh.data : cachedMe);
}),
),
);
}
});
test("a failed hub_me keeps the cached profile and every derived key", async () => {
for (const reply of [null, { result: 1, error: "hub_unreachable" }, { result: 0 }]) {
const rpc = recordingRpc(reply);
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => ({ status: 200, body: { notices: [] } }),
() =>
withStorage(async () => {
const cached = { id: "me", configs: [ownShare({ assets_status: "rejected" })] };
m.meCache.set(cached);
m.setInboxState({ read: ["gone"], done: ["rejected:c1", "removed:c7"] });
await m.refreshNotices();
assert.deepEqual(rpc.calls, ["get_init_data", "hub_me"]);
assert.deepEqual(m.meCache.getStale(), cached);
assert.deepEqual(m.inboxState(), { read: [], done: ["rejected:c1", "removed:c7"] });
}),
),
);
}
});
test("a caller already running hub_me hands it over: no second call, and its answer prunes", async () => {
// Thunks: a rejected promise built up front is reported as unhandled before
// refreshNotices ever gets to adopt it.
const cases = [
[() => meReply(ownShare({ assets_status: "rejected" })).data, ["rejected:c1"]],
[() => Promise.resolve(meReply().data), []],
[() => null, ["rejected:c1", "removed:c7"]],
[() => Promise.reject(new Error("hub_me failed")), ["rejected:c1", "removed:c7"]],
];
for (const [makeMe, doneAfter] of cases) {
const rpc = recordingRpc(meReply(ownShare()));
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => ({ status: 200, body: { notices: [feedNotice({ id: "cr", audience: "creators" })] } }),
() =>
withStorage(async () => {
m.setInboxState({ read: [], done: ["rejected:c1", "removed:c7"] });
await m.refreshNotices({ me: makeMe() });
assert.deepEqual(rpc.calls, ["get_init_data"]);
assert.deepEqual(m.inboxState().done, doneAfter);
// The view owns its own meCache write; this path must not clobber it.
assert.equal(m.meCache.getStale(), null);
}),
),
);
}
});
test("a failed refresh keeps the previous feed and state, and still counts as a check", async () => {
const rpc = recordingRpc(meReply(ownShare()));
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => {
throw new TypeError("network down");
},
() =>
withStorage(async () => {
m.noticesCache.set({ notices: [feedNotice({ id: "old" })], muted: true });
m.meCache.set({ id: "me", configs: [ownShare()] });
m.setInboxState({ read: ["old", "gone"], done: ["removed:c7"] });
const before = Date.now();
// A 1.2.x opt-out flag in the cache is dropped, not carried forward.
const kept = { notices: [feedNotice({ id: "old" })], local: null };
assert.deepEqual(await m.refreshNotices(), kept);
assert.deepEqual(m.noticesCache.getStale(), kept);
assert.deepEqual(m.inboxState(), { read: ["old", "gone"], done: ["removed:c7"] });
assert.deepEqual(rpc.calls, ["get_init_data"], "an unreachable hub is not asked a second question");
// One attempt per interval whether or not the hub answers: a browser
// that cannot reach it must not retry on every page.
assert.ok(m.noticesCheckedAt() >= before);
}),
),
);
});
test("a malformed feed body degrades to an empty list", async () => {
for (const body of [null, [], "notices", { notices: "x" }, { notices: { 0: feedNotice() } }]) {
const m = await load();
await withLuci(() =>
withFetch(
() => ({ status: 200, body }),
() =>
withStorage(async () => {
assert.deepEqual(await m.refreshNotices(), { notices: [], local: null });
}),
),
);
}
});
const initReply = (configured) => ({
feed: { pm: "apk", configured, channel: configured ? "snapshots" : "" },
versions: { theme: { installed_version: "1.3.8" }, config: { installed_version: "1.2.0" } },
});
test("refreshNotices probes the update source once and caches the verdict with the feed", async () => {
const rpc = recordingRpc(null, initReply(false));
const m = await load(rpc);
await withLuci(() =>
withFetch(
() => ({ status: 200, body: { notices: [] } }),
() =>
withStorage(async () => {
m.setInboxState({ read: ["feed:missing"], done: [] });
const snapshot = await m.refreshNotices();
assert.deepEqual(snapshot.local, { feedMissing: true, theme: "1.3.8", app: "1.2.0" });
assert.deepEqual(m.noticesCache.getStale().local, snapshot.local);
assert.deepEqual(m.inboxState().read, ["feed:missing"]);
assert.deepEqual(rpc.calls, ["get_init_data"]);
}),
),
);
});
test("once the source is configured the item's keys are pruned; an unanswered probe keeps everything", async () => {
for (const [init, local, read] of [
[initReply(true), { feedMissing: false, theme: "1.3.8", app: "1.2.0" }, []],
[null, { feedMissing: true, theme: "", app: "" }, ["feed:missing"]],
[{ feed: { pm: "unknown" } }, { feedMissing: true, theme: "", app: "" }, ["feed:missing"]],
]) {
const m = await load(recordingRpc(null, init));
await withLuci(() =>
withFetch(
() => ({ status: 200, body: { notices: [] } }),
() =>
withStorage(async () => {
m.noticesCache.set({ notices: [], local: { feedMissing: true, theme: "", app: "" } });
m.setInboxState({ read: ["feed:missing"], done: [] });
assert.deepEqual((await m.refreshNotices()).local, local);
assert.deepEqual(m.inboxState().read, read);
}),
),
);
}
});
test("the local verdict survives a dead hub, and can be set on its own", async () => {
const m = await load(recordingRpc(null, initReply(false)));
await withLuci(() =>
withFetch(
() => {
throw new TypeError("network down");
},
() =>
withStorage(async () => {
// The router answers even when the hub does not.
assert.equal((await m.refreshNotices()).local.feedMissing, true);
const after = m.setNoticesLocal({ feedMissing: false, theme: "1.3.8", app: "1.2.0" });
assert.deepEqual(after, {
notices: [],
local: { feedMissing: false, theme: "1.3.8", app: "1.2.0" },
});
assert.deepEqual(m.noticesCache.getStale(), after);
}),
),
);
});
test("hub-api declares the two local calls the inbox needs, and nothing new on the router", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /callGetInitData: rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "get_init_data",/);
assert.match(src, /callAddFeed: rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "add_feed",/);
const acl = JSON.parse(await readFile(repo("root/usr/share/rpcd/acl.d/luci-app-aurora.json"), "utf8"))[
"luci-app-aurora"
];
assert.ok(acl.read.ubus["luci.aurora"].includes("get_init_data"));
assert.ok(acl.write.ubus["luci.aurora"].includes("add_feed"));
});
test("the opt-out is gone: no muting API, no muted flag written, a stale one dropped", async () => {
const src = await readFile(SRC, "utf8");
for (const gone of ["muted", "muteNotices", "setNoticesMuted", "hub_notices"]) assert.ok(!src.includes(gone), gone);
const m = await load(recordingRpc(null, null));
await withLuci(() =>
withFetch(
() => ({ status: 200, body: { notices: [feedNotice()] } }),
() =>
withStorage(async () => {
m.noticesCache.set({ notices: [], muted: true, local: null });
assert.equal("muted" in m.setNoticesLocal(null), false);
m.noticesCache.set({ notices: [], muted: true, local: null });
const snapshot = await m.refreshNotices();
assert.deepEqual(Object.keys(snapshot).sort(), ["local", "notices"]);
assert.equal("muted" in m.noticesCache.getStale(), false);
}),
),
);
});
@@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
const rpcd = readFileSync(
path.join(repoRoot, "root/usr/libexec/rpcd/luci.aurora"),
"utf8",
);
const acl = readFileSync(
path.join(repoRoot, "root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
"utf8",
);
const hubApi = readFileSync(
path.join(repoRoot, ".dev/src/resource/utils/hub-api.js"),
"utf8",
);
// Phase 1 把列表与详情改成浏览器直连时,rpcd 的两个 handler 被留作回滚路径。
// 直连已稳定运行,这里兑现当时写下的 Phase 4。
test("rpcd script: the hub_list and hub_get handlers are gone", () => {
assert.ok(!rpcd.includes('"hub_list")'), "hub_list handler should be gone");
assert.ok(!rpcd.includes('"hub_get")'), "hub_get handler should be gone");
assert.ok(
!rpcd.includes('json_add_object "hub_list"'),
"hub_list should not be declared in the list branch",
);
assert.ok(
!rpcd.includes('json_add_object "hub_get"'),
"hub_get should not be declared in the list branch",
);
// 只有 hub_list 构造带 sort/page 的浏览 URL,可以拿它当判据。
assert.ok(
!rpcd.includes("/api/v1/themes/aurora/configs?sort="),
"the browse URL should only be built in the browser now",
);
});
// ⚠️ 陷阱:被删的 hub_get 与仍在的 hub_apply_worker 使用的是逐字相同的一行
// —— hub_http_get "/api/v1/themes/aurora/configs/$id"。所以「详情 URL 是否
// 消失」不能作为删除判据,上面那条测试只认 case 标签 '"hub_get")'。反过来,
// 这行的存在正好证明 apply 路径没被误删。
//
// 删 handler 不等于删这个工具函数:apply 仍然必须由路由器自己去取 payload,
// 因为它随后要在本地校验并写进 uci —— 那条路径不经过浏览器。
test("rpcd script: hub_http_get survives, apply still needs it", () => {
assert.match(rpcd, /hub_http_get\(\)\s*\{/);
assert.match(rpcd, /hub_apply_worker\(\)\s*\{/);
assert.match(
rpcd,
/hub_http_get "\/api\/v1\/themes\/aurora\/configs\/\$id"/,
"the apply worker must still fetch the payload from the router",
);
});
test("acl: hub_list and hub_get are no longer granted", () => {
const readMethods = JSON.parse(acl)["luci-app-aurora"].read.ubus["luci.aurora"];
assert.ok(!readMethods.includes("hub_list"), "hub_list should be gone from read");
assert.ok(!readMethods.includes("hub_get"), "hub_get should be gone from read");
});
test("acl: the methods the page still calls are kept", () => {
const acljson = JSON.parse(acl)["luci-app-aurora"];
const read = acljson.read.ubus["luci.aurora"];
const write = acljson.write.ubus["luci.aurora"];
assert.ok(read.includes("hub_me"), "hub_me is still called on every page load");
assert.ok(read.includes("get_hub_status"), "apply polling still needs get_hub_status");
assert.ok(write.includes("hub_apply"));
assert.ok(write.includes("hub_share_begin"));
assert.ok(write.includes("hub_share_commit"));
assert.ok(write.includes("hub_delete"));
assert.ok(write.includes("hub_import_key"));
});
test("hub-api: browse and detail are browser-direct, so nothing regressed to ubus", () => {
assert.match(hubApi, /fetch\(HUB_BASE \+ path/);
assert.ok(!/method:\s*"hub_list"/.test(hubApi), "hub_list must not be re-declared");
assert.ok(!/method:\s*"hub_get"/.test(hubApi), "hub_get must not be re-declared");
});
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { srcPath } from "./paths.mjs";
const SRC = srcPath("utils/markdown.js");
async function load() {
const src = await readFile(SRC, "utf8");
const body = src
.replace(/^"use strict";$/m, "")
.replace(/^"require [^"]+";$/gm, "");
return new Function("baseclass", body)({ extend: (obj) => obj });
}
// A document that can only do what the renderer is allowed to do. There is no
// innerHTML here to assign to: markup can only come out as element nodes the
// renderer chose to create.
const makeDoc = () => {
const node = (tag) => ({
tag,
attrs: {},
children: [],
appendChild(child) {
this.children.push(child);
return child;
},
setAttribute(name, value) {
this.attrs[name] = String(value);
},
});
return {
createDocumentFragment: () => node("#fragment"),
createElement: (tag) => node(tag),
createTextNode: (text) => ({ text: String(text) }),
};
};
const show = (n) => {
if (n.text !== undefined) return n.text;
const attrs = Object.keys(n.attrs)
.map((k) => ` ${k}="${n.attrs[k]}"`)
.join("");
const inner = n.children.map(show).join("");
return n.tag === "#fragment" ? inner : `<${n.tag}${attrs}>${inner}</${n.tag}>`;
};
const allow = (url) =>
url.startsWith("https://") ? url : url.startsWith("admin/") ? "/cgi-bin/luci/" + url : "";
const html = (m, src, safeUrl) => show(m.render(src, makeDoc(), safeUrl || allow));
const tags = (n, out = []) => {
if (n.tag && n.tag !== "#fragment") out.push(n.tag);
(n.children || []).forEach((child) => tags(child, out));
return out;
};
test("markdown.js: one dependency, a marked pure core, and no way to parse markup", async () => {
const src = await readFile(SRC, "utf8");
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
assert.deepEqual(requires, ["baseclass"]);
const open = src.indexOf("// <markdown-core>\n");
const close = src.indexOf("// </markdown-core>\n");
assert.ok(open > 0 && close > open, "the hub's admin console syncs the text between the markers");
const core = src.slice(open, close);
assert.match(core, /^function renderMarkdown\(src, doc, safeUrl\) \{$/m);
assert.match(core, /^function markdownToText\(src\) \{$/m);
for (const forbidden of ["innerHTML", "outerHTML", "insertAdjacentHTML", "DOMParser", "document.", "window", "L.", "baseclass", "_("])
assert.ok(!core.includes(forbidden), `the core must not mention ${forbidden}`);
// The core stands alone: evaluated with nothing in scope it still works.
const standalone = new Function(core + "; return { renderMarkdown, markdownToText };")();
assert.equal(show(standalone.renderMarkdown("**hi**", makeDoc(), allow)), "<p><strong>hi</strong></p>");
assert.equal(standalone.markdownToText("# Title"), "Title");
});
test("markdown: paragraphs, headings, emphasis, inline code", async () => {
const m = await load();
assert.equal(html(m, "One\ntwo.\n\nThree."), "<p>One two.</p><p>Three.</p>");
// The pane title is the only big heading, so # starts at h4.
assert.equal(html(m, "# A\n## B\n### C\n#### D"), "<h4>A</h4><h5>B</h5><h6>C</h6><p>#### D</p>");
assert.equal(html(m, "#nospace"), "<p>#nospace</p>");
assert.equal(
html(m, "**bold** and *italic* and `code <b>`"),
"<p><strong>bold</strong> and <em>italic</em> and <code>code <b></code></p>",
);
assert.equal(html(m, "**bold with *italic* inside**"), "<p><strong>bold with <em>italic</em> inside</strong></p>");
assert.equal(html(m, "2 * 3 * 4 and a ** b"), "<p>2 * 3 * 4 and a ** b</p>");
assert.equal(html(m, "`*not italic*`"), "<p><code>*not italic*</code></p>");
});
test("markdown: fenced code, lists, blockquote, rule", async () => {
const m = await load();
assert.equal(
html(m, "```\nuci set a.b='1'\n**raw** <b>\n\nstill code\n```\nafter"),
"<pre><code>uci set a.b='1'\n**raw** <b>\n\nstill code</code></pre><p>after</p>",
);
assert.equal(html(m, "```sh\nopkg update"), "<pre><code>opkg update</code></pre>", "an open fence runs to the end");
assert.equal(html(m, "- one\n- **two**\n* three"), "<ul><li>one</li><li><strong>two</strong></li><li>three</li></ul>");
assert.equal(html(m, "1. one\n2. two\n\n3. three"), "<ol><li>one</li><li>two</li></ol><ol><li>three</li></ol>");
assert.equal(html(m, "- one\n wrapped\n- two"), "<ul><li>one wrapped</li><li>two</li></ul>");
// Single level: an indented marker is just another item, never a nested list.
assert.equal(html(m, "- one\n - inner"), "<ul><li>one</li><li>inner</li></ul>");
assert.equal(html(m, "> kept\n> together\n\ntext"), "<blockquote>kept together</blockquote><p>text</p>");
assert.equal(html(m, "above\n\n---\n\nbelow"), "<p>above</p><hr></hr><p>below</p>");
});
test("markdown: links go through the caller's url rule, and nothing else becomes a link", async () => {
const m = await load();
assert.equal(
html(m, "Read the [migration notes](https://themes.eamonxg.fun/notes)."),
'<p>Read the <a href="https://themes.eamonxg.fun/notes" target="_blank" rel="noreferrer">migration notes</a>.</p>',
);
assert.equal(
html(m, "[Open the store](admin/system/aurora/marketplace)"),
'<p><a href="/cgi-bin/luci/admin/system/aurora/marketplace">Open the store</a></p>',
);
for (const url of ["javascript:alert`1`", "http://example.com/", "data:text/html,x", "//evil.example", "JAVASCRIPT:x"])
assert.equal(html(m, `[click](${url})`), "<p>click</p>", url);
// A parenthesis never belongs to a url here, so this is not a link at all.
assert.equal(html(m, "[click](javascript:alert(1))"), "<p>[click](javascript:alert(1))</p>");
// A rule that throws nothing but returns junk is still a refusal.
for (const rule of [() => null, () => undefined, () => 1, () => ({}), () => "", null, undefined, "https://x"])
assert.equal(show(m.render("[a](https://ok.example/)", makeDoc(), rule)), "<p>a</p>");
// Bare urls, images and reference links are outside the subset.
assert.equal(html(m, "https://example.com"), "<p>https://example.com</p>");
assert.equal(html(m, "![alt](https://example.com/x.png)"), '<p>!<a href="https://example.com/x.png" target="_blank" rel="noreferrer">alt</a></p>');
assert.deepEqual(tags(m.render("![alt](https://example.com/x.png)", makeDoc(), allow)).includes("img"), false);
assert.equal(html(m, "[a [b](https://ok.example/) c]"), '<p>[a <a href="https://ok.example/" target="_blank" rel="noreferrer">b</a> c]</p>');
assert.equal(html(m, "[a](https://ok.example/ title)"), "<p>[a](https://ok.example/ title)</p>");
});
test("markdown: raw HTML is text", async () => {
const m = await load();
const hostile = '<img src=x onerror="alert(1)"><script>alert(1)</script>';
const tree = m.render(hostile + "\n\n<b>bold?</b> &amp; <!-- c -->", makeDoc(), allow);
assert.deepEqual(tags(tree), ["p", "p"]);
assert.equal(show(tree), `<p>${hostile}</p><p><b>bold?</b> &amp; <!-- c --></p>`);
const allowed = ["p", "h4", "h5", "h6", "strong", "em", "code", "pre", "ul", "ol", "li", "blockquote", "hr", "a"];
const everything = "# h\n\n**b** *i* `c` [l](https://a.b/)\n\n- x\n\n1. y\n\n> q\n\n---\n\n```\nz\n```\n\n| a | b |\n|---|---|";
for (const tag of tags(m.render(everything, makeDoc(), allow))) assert.ok(allowed.includes(tag), tag);
assert.equal(html(m, "| a | b |"), "<p>| a | b |</p>", "tables are outside the subset");
});
test("markdown: control characters are dropped, other input is coerced to nothing", async () => {
const m = await load();
const dirty = "a" + String.fromCharCode(0) + "b" + String.fromCharCode(27) + "[31mc" + String.fromCharCode(127) + "\r\nd\te";
assert.equal(html(m, dirty), "<p>ab[31mc d\te</p>");
for (const junk of [null, undefined, 5, {}, ["**x**"], { toString: () => "**x**" }]) {
assert.equal(html(m, junk), "");
assert.equal(m.toText(junk), "");
}
assert.equal(html(m, ""), "");
assert.equal(html(m, "\n\n \n"), "");
});
test("markdown: unterminated markers fall back to the characters that were typed", async () => {
const m = await load();
assert.equal(html(m, "**never closed"), "<p>**never closed</p>");
assert.equal(html(m, "*never closed"), "<p>*never closed</p>");
assert.equal(html(m, "`never closed"), "<p>`never closed</p>");
assert.equal(html(m, "[never closed"), "<p>[never closed</p>");
assert.equal(html(m, "[text](never closed"), "<p>[text](never closed</p>");
assert.equal(html(m, "[](https://a.b/)"), "<p>[](https://a.b/)</p>");
assert.equal(html(m, "[x]()"), "<p>[x]()</p>");
assert.equal(html(m, "****"), "<p>****</p>");
assert.equal(html(m, "``"), "<p>``</p>");
assert.equal(html(m, "***x***"), "<p><strong>*x</strong>*</p>");
// Emphasis nests to a fixed depth; past it the markers are text.
assert.equal(html(m, "**a *b `c` **d** e* f**").includes("<script"), false);
});
test("markdown: hostile input costs time in proportion to its length", async () => {
const m = await load();
const N = 200000;
const cases = {
"open brackets": "[".repeat(N),
"nested brackets": "[a".repeat(N / 2) + "](https://a.b/)",
"bracket pairs": "[]".repeat(N / 2),
"half links": "[a](".repeat(N / 4),
stars: "*".repeat(N),
"spaced stars": "* a".repeat(N / 3),
"star pairs": "**a ".repeat(N / 4),
"unclosed strong then singles": "**" + "*a".repeat(N / 2),
backticks: "`".repeat(N),
"backtick words": "`a ".repeat(N / 3) ,
"one huge line": "x".repeat(N),
"many lines": "- x\n".repeat(N / 4),
"many fences": "```\n".repeat(N / 4),
"many quotes": "> ".repeat(N / 2),
hashes: "#".repeat(N),
"nested emphasis": "**a *b ".repeat(N / 8) + " b* a**".repeat(N / 8),
};
for (const [name, input] of Object.entries(cases)) {
const started = process.hrtime.bigint();
m.render(input, makeDoc(), allow);
m.toText(input);
const ms = Number(process.hrtime.bigint() - started) / 1e6;
// Linear work on 200 KB is a few milliseconds; a quadratic path is minutes.
assert.ok(ms < 1500, `${name}: ${ms.toFixed(0)} ms`);
}
});
test("markdownToText: the first line that says something, without its markers", async () => {
const m = await load();
assert.equal(m.toText("Browsing keeps working; **publishing** is paused.\n\nMore."), "Browsing keeps working; publishing is paused.");
assert.equal(m.toText("\n\n## Heads *up*\n\nbody"), "Heads up");
assert.equal(m.toText("- first item\n- second"), "first item");
assert.equal(m.toText("3. third"), "third");
assert.equal(m.toText("> quoted `code`"), "quoted code");
assert.equal(m.toText("---\n\nafter the rule"), "after the rule");
assert.equal(m.toText("```\n\nuci show\n```"), "uci show");
assert.equal(m.toText("See [the notes](https://a.b/) now"), "See the notes now");
assert.equal(m.toText("[x](javascript:alert)"), "x");
assert.equal(m.toText("line one\nline two"), "line one");
assert.equal(m.toText("<b>raw</b>"), "<b>raw</b>");
assert.equal(m.toText(" "), "");
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,749 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { srcPath } from "./paths.mjs";
const SRC = srcPath("utils/notices.js");
// Same harness as feed-check-module.test.mjs: the module is pure, so the tests
// run it instead of pattern-matching its source.
async function load() {
const src = await readFile(SRC, "utf8");
const body = src
.replace(/^"use strict";$/m, "")
.replace(/^"require [^"]+";$/gm, "");
return new Function("baseclass", body)({ extend: (obj) => obj });
}
const notice = (over) => ({
id: "n1",
level: "warning",
audience: "all",
title: "Schema 1 is going away",
body: "Update your shares before the sunset date.",
url: "https://themes.eamonxg.fun/notices/n1",
i18n: {},
...over,
});
test("notices.js is pure: baseclass is its only dependency, no DOM, no network, no storage", async () => {
const src = await readFile(SRC, "utf8");
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
assert.deepEqual(requires, ["baseclass"]);
for (const forbidden of ["document", "window", "localStorage", "fetch(", "rpc", "innerHTML"])
assert.ok(!src.includes(forbidden), `notices.js must not touch ${forbidden}`);
});
test("localize prefers the exact language, then its base, then the English defaults", async () => {
const m = await load();
const n = notice({
i18n: {
"zh-cn": { title: "简体标题", body: "简体正文" },
zh: { title: "中文标题", body: "中文正文" },
de: { title: "Titel" },
},
});
assert.deepEqual(m.localize(n, "zh-cn"), { title: "简体标题", body: "简体正文" });
assert.deepEqual(m.localize(n, "zh-tw"), { title: "中文标题", body: "中文正文" });
assert.deepEqual(m.localize(n, "en"), { title: n.title, body: n.body });
assert.deepEqual(m.localize(n, "fr"), { title: n.title, body: n.body });
// Per field: a locale that only translated the title keeps the English body.
assert.deepEqual(m.localize(n, "de"), { title: "Titel", body: n.body });
});
test("localize accepts LuCI's own spelling of a language code", async () => {
const m = await load();
const n = notice({ i18n: { "zh-cn": { title: "简体标题", body: "简体正文" } } });
// dispatcher.lang, and therefore <html lang>, is "zh_cn".
assert.equal(m.localize(n, "zh_cn").title, "简体标题");
assert.equal(m.localize(n, "ZH_CN").title, "简体标题");
for (const lang of [undefined, null, "", 42, {}])
assert.deepEqual(m.localize(n, lang), { title: n.title, body: n.body });
});
test("localize ignores values that are not strings", async () => {
const m = await load();
const n = notice({
i18n: {
"zh-cn": { title: { toString: () => "<b>x</b>" }, body: 7 },
zh: { title: "", body: ["x"] },
},
});
assert.deepEqual(m.localize(n, "zh-cn"), { title: n.title, body: n.body });
assert.deepEqual(m.localize(notice({ i18n: "zh-cn" }), "zh-cn"), {
title: n.title,
body: n.body,
});
assert.deepEqual(m.localize(notice({ i18n: null }), "zh-cn"), { title: n.title, body: n.body });
assert.deepEqual(m.localize(null, "en"), { title: "", body: "" });
assert.deepEqual(m.localize({ title: 1, body: {} }, "en"), { title: "", body: "" });
// A language code that names an Object.prototype member must not resolve to it.
assert.deepEqual(m.localize(n, "constructor"), { title: n.title, body: n.body });
assert.deepEqual(m.localize(n, "__proto__"), { title: n.title, body: n.body });
});
test("safeUrl lets through https URLs and LuCI-relative admin/ paths", async () => {
const m = await load();
assert.equal(m.safeUrl("https://example.com/a?b=1#c"), "https://example.com/a?b=1#c");
assert.equal(m.safeUrl("https://example.com"), "https://example.com");
assert.equal(m.safeUrl("admin/system/aurora/marketplace"), "admin/system/aurora/marketplace");
assert.equal(
m.safeUrl("admin/system/aurora/marketplace?share=1"),
"admin/system/aurora/marketplace?share=1",
);
assert.equal(m.isExternalUrl("https://example.com"), true);
assert.equal(m.isExternalUrl("admin/system"), false);
assert.equal(m.isExternalUrl(""), false);
assert.equal(m.isExternalUrl(null), false);
});
test("safeUrl rejects everything else", async () => {
const m = await load();
const hostile = [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"java\nscript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"http://example.com/",
"HTTPS://example.com/",
"//evil.example/x",
"/admin/system",
"\\\\evil.example\\x",
"https:/example.com",
"https:example.com",
"https://",
"https://user:pass@example.com/",
"https://user@example.com/",
"https://:pass@example.com/",
"https://exa mple.com/",
"https://example.com/\u0000",
"https://example.com/\n",
"https://example.com\\@evil.example/",
"admin/../../etc/passwd",
"admin//evil.example",
"admin/x\\y",
"admin/<script>",
'admin/"onclick="x',
"admin/system aurora",
"admin",
"administrator/x",
"ftp://example.com/",
"",
];
for (const url of hostile) assert.equal(m.safeUrl(url), "", JSON.stringify(url));
for (const url of [null, undefined, 1, {}, [], { toString: () => "https://example.com" }])
assert.equal(m.safeUrl(url), "", String(url));
});
test("sanitize keeps well-formed notices and only the fields the client reads", async () => {
const m = await load();
const out = m.sanitize([
notice({
i18n: { "zh-cn": { title: "标题", body: "正文", html: "<b>x</b>" } },
starts_at: null,
expires_at: "2026-12-01 00:00:00",
__proto__: { polluted: true },
extra: { nested: "junk" },
}),
]);
assert.deepEqual(out, [
{
id: "n1",
level: "warning",
audience: "all",
title: "Schema 1 is going away",
body: "Update your shares before the sunset date.",
url: "https://themes.eamonxg.fun/notices/n1",
i18n: { "zh-cn": { title: "标题", body: "正文" } },
at: "",
},
]);
});
test("sanitize keeps one timestamp: when the notice went live", async () => {
const m = await load();
const at = (over) => m.sanitize([notice(over)])[0].at;
assert.equal(at({ created_at: "2026-09-19 08:00:00" }), "2026-09-19 08:00:00");
assert.equal(
at({ created_at: "2026-09-01 00:00:00", starts_at: "2026-09-19 08:00:00" }),
"2026-09-19 08:00:00",
"a scheduled notice is as old as its start, not as its draft",
);
assert.equal(at({ created_at: "2026-09-19T08:00:00Z" }), "2026-09-19T08:00:00");
// Round trip: what the cache hands back sanitizes to the same thing.
assert.equal(at({ at: "2026-09-19 08:00:00" }), "2026-09-19 08:00:00");
for (const junk of [null, 5, "yesterday", "<b>2026</b>", {}, ["2026-09-19 08:00:00"]])
assert.equal(at({ created_at: junk }), "", String(junk));
assert.equal(at({ created_at: "2026-09-19 08:00:00<script>" }), "2026-09-19 08:00:00");
});
test("sanitize drops malformed entries", async () => {
const m = await load();
const bad = [
null,
undefined,
"n1",
7,
[],
notice({ id: "" }),
notice({ id: "has-dash" }),
notice({ id: 'x"]<img src=x>' }),
notice({ id: "a".repeat(33) }),
notice({ id: 12345 }),
notice({ id: ["n1"] }),
notice({ level: "fatal" }),
notice({ level: undefined }),
notice({ level: ["warning"] }),
notice({ audience: "everyone" }),
notice({ audience: null }),
notice({ title: "" }),
notice({ title: " " }),
notice({ title: "x".repeat(121) }),
notice({ title: 5 }),
notice({ title: { toString: () => "title" } }),
notice({ body: "x".repeat(4001) }),
notice({ body: null }),
notice({ body: ["x"] }),
];
assert.deepEqual(m.sanitize(bad), []);
for (const notList of [null, undefined, {}, "[]", 3, { length: 1, 0: notice() }])
assert.deepEqual(m.sanitize(notList), []);
});
test("sanitize measures length in characters, not UTF-16 units", async () => {
const m = await load();
assert.equal(m.sanitize([notice({ title: "😀".repeat(120) })]).length, 1);
assert.equal(m.sanitize([notice({ title: "😀".repeat(121) })]).length, 0);
assert.equal(m.sanitize([notice({ body: "" })]).length, 1);
assert.equal(m.sanitize([notice({ body: "x".repeat(4000) })]).length, 1);
assert.equal(m.sanitize([notice({ body: "😀".repeat(4000) })]).length, 1);
});
test("sanitize keeps markup as the plain string it is, and tolerates a junk url", async () => {
const m = await load();
const title = '<img src=x onerror="alert(1)">';
const [kept] = m.sanitize([notice({ title, body: "<script>alert(1)</script>", url: 42 })]);
assert.equal(kept.title, title);
assert.equal(kept.body, "<script>alert(1)</script>");
assert.equal(kept.url, "");
// The url is judged where it is rendered, so a hostile one survives sanitize
// and dies in safeUrl.
const [hostile] = m.sanitize([notice({ url: "javascript:alert(1)" })]);
assert.equal(m.safeUrl(hostile.url), "");
});
test("sanitize filters i18n down to valid language keys and string fields", async () => {
const m = await load();
const [kept] = m.sanitize([
notice({
i18n: {
"zh-cn": { title: "标题" },
ZH: { title: "大写键" },
"zh_cn": { title: "下划线键" },
constructor: { title: "x" },
fr: { title: 5, body: { a: 1 } },
de: "Titel",
ja: { title: "x".repeat(121), body: "本文" },
ko: null,
},
}),
]);
assert.deepEqual(kept.i18n, { "zh-cn": { title: "标题" }, ja: { body: "本文" } });
assert.deepEqual(m.sanitize([notice({ i18n: [] })])[0].i18n, {});
assert.deepEqual(m.sanitize([notice({ i18n: "x" })])[0].i18n, {});
assert.deepEqual(m.sanitize([notice({ i18n: undefined })])[0].i18n, {});
});
test("sanitize caps the list and drops repeated ids", async () => {
const m = await load();
const many = Array.from({ length: 500 }, (_, i) => notice({ id: "n" + i }));
const out = m.sanitize(many);
assert.equal(out.length, m.MAX_NOTICES);
assert.equal(m.MAX_NOTICES, 20);
// Newest first on the wire, so the cap keeps the head.
assert.equal(out[0].id, "n0");
assert.equal(out[19].id, "n19");
const dupes = m.sanitize([notice({ title: "first" }), notice({ title: "second" })]);
assert.deepEqual(dupes.map((n) => n.title), ["first"]);
// An id that names an Object.prototype member is still just an id.
assert.equal(m.sanitize([notice({ id: "constructor" })]).length, 1);
// Malformed entries do not count towards the cap.
const padded = [...Array.from({ length: 100 }, () => null), ...many];
assert.equal(m.sanitize(padded).length, 20);
});
test("isDue throttles to one check per twelve hours", async () => {
const m = await load();
const HOUR = 60 * 60 * 1000;
assert.equal(m.POLL_INTERVAL_MS, 12 * HOUR);
const now = Date.UTC(2026, 8, 19, 12);
assert.equal(m.isDue(now - 1, now), false);
assert.equal(m.isDue(now - 11 * HOUR, now), false);
assert.equal(m.isDue(now - 12 * HOUR + 1, now), false);
assert.equal(m.isDue(now - 12 * HOUR, now), true);
assert.equal(m.isDue(now - 48 * HOUR, now), true);
});
test("isDue treats a missing, corrupt or future timestamp as due", async () => {
const m = await load();
const now = Date.UTC(2026, 8, 19, 12);
for (const last of [0, -5, NaN, Infinity, null, undefined, "1700000000000", {}])
assert.equal(m.isDue(last, now), true, String(last));
// A clock that was set back must not silence the check forever.
assert.equal(m.isDue(now + 1000, now), true);
assert.equal(m.isDue(now - 1000, NaN), true);
});
test("creatorAffected reads compat_summary", async () => {
const m = await load();
const summary = (deprecated, unsupported) => ({
compat_summary: { current_schema: 2, deprecated, unsupported },
configs: [],
});
assert.equal(m.creatorAffected(summary(0, 0)), false);
assert.equal(m.creatorAffected(summary(1, 0)), true);
assert.equal(m.creatorAffected(summary(0, 3)), true);
assert.equal(m.creatorAffected(summary("1", "x")), false);
assert.equal(m.creatorAffected(summary(NaN, null)), false);
assert.equal(m.creatorAffected(summary(-1, 0)), false);
});
test("creatorAffected falls back to the per-config compat state", async () => {
const m = await load();
const me = (...states) => ({
configs: states.map((state, i) => ({ id: "c" + i, status: "active", compat: { state } })),
});
assert.equal(m.creatorAffected(me("ok", "ok")), false);
assert.equal(m.creatorAffected(me("ok", "deprecated")), true);
assert.equal(m.creatorAffected(me("unsupported")), true);
// Anything the hub may call it later still means "not fine".
assert.equal(m.creatorAffected(me("retired")), true);
// A share that was taken down has no update button to send its author to.
assert.equal(
m.creatorAffected({ configs: [{ status: "removed", compat: { state: "deprecated" } }] }),
false,
);
});
test("creatorAffected tolerates an old hub that sends none of the new fields", async () => {
const m = await load();
assert.equal(m.creatorAffected({ id: "abc", nickname: "eamon", configs: [{ id: "x" }] }), false);
assert.equal(m.creatorAffected({ id: "abc", nickname: null, configs: [] }), false);
assert.equal(m.creatorAffected({}), false);
for (const junk of [null, undefined, "me", 1, [], { configs: "x" }, { compat_summary: [] }])
assert.equal(m.creatorAffected(junk), false, String(junk));
assert.equal(
m.creatorAffected({ configs: [null, 1, { compat: null }, { compat: "deprecated" }, { compat: { state: 1 } }] }),
false,
);
});
test("compatBadge names the two states a share card has to mention", async () => {
const m = await load();
assert.equal(m.compatBadge({ compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }), "deprecated");
assert.equal(m.compatBadge({ compat: { state: "unsupported" } }), "unsupported");
assert.equal(m.compatBadge({ compat: { state: "ok" } }), null);
assert.equal(m.compatBadge({ compat: { state: "retired" } }), null);
assert.equal(m.compatBadge({ compat: { state: ["deprecated"] } }), null);
assert.equal(m.compatBadge({ compat: "deprecated" }), null);
assert.equal(m.compatBadge({ id: "x" }), null);
assert.equal(m.compatBadge({}), null);
assert.equal(m.compatBadge(null), null);
assert.equal(m.compatBadge(undefined), null);
});
test("sunsetDate shows the day and nothing else the hub may have put there", async () => {
const m = await load();
const at = (sunset_at) => ({ compat: { state: "deprecated", sunset_at } });
assert.equal(m.sunsetDate(at("2026-12-01 00:00:00")), "2026-12-01");
assert.equal(m.sunsetDate(at("2026-12-01T00:00:00Z")), "2026-12-01");
assert.equal(m.sunsetDate(at("2026-12-01")), "2026-12-01");
for (const junk of [
null,
undefined,
"",
"soon",
"2026-12-01<script>",
"2026-12-011",
" 2026-12-01",
"12/01/2026",
20261201,
{ toString: () => "2026-12-01" },
])
assert.equal(m.sunsetDate(at(junk)), "", String(junk));
assert.equal(m.sunsetDate({}), "");
assert.equal(m.sunsetDate(null), "");
});
// ---------------------------------------------------------------------------
// Inbox: derivation, keys, read/done bookkeeping, pruning, counts
const share = (over) => ({
id: "c1",
name: "Nord Night",
downloads: 3,
status: "active",
assets_status: "none",
compat: { state: "ok", sunset_at: null },
...over,
});
const profile = (...configs) => ({ id: "me", nickname: "eamon", configs });
test("present localizes, keeps the body as the Markdown it is, and vets the link", async () => {
const m = await load();
assert.deepEqual(
m.present(notice({ body: "One.\n\n- **two**", i18n: { zh: { title: "标题" } } }), "zh_cn"),
{
title: "标题",
body: "One.\n\n- **two**",
url: "https://themes.eamonxg.fun/notices/n1",
external: true,
},
);
assert.equal(m.present(notice({ i18n: { zh: { body: "**正文**" } } }), "zh-cn").body, "**正文**");
assert.equal(m.present(notice({ url: "javascript:alert(1)" }), "en").url, "");
assert.equal(m.present(notice({ url: "admin/system" }), "en").external, false);
assert.deepEqual(m.present(null, "en"), { title: "", body: "", url: "", external: false });
});
test("compatCounts trusts the hub's summary, counts for itself without one, and finds the first deadline", async () => {
const m = await load();
const me = profile(
share({ id: "a", compat: { state: "deprecated", sunset_at: "2027-01-15 00:00:00" } }),
share({ id: "b", compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }),
share({ id: "c", compat: { state: "unsupported", sunset_at: "2026-01-01 00:00:00" } }),
share({ id: "d", status: "removed", compat: { state: "unsupported" } }),
share({ id: "e" }),
null,
);
assert.deepEqual(m.compatCounts(me), { deprecated: 2, unsupported: 1, sunset: "2026-12-01" });
assert.deepEqual(
m.compatCounts({ ...me, compat_summary: { current_schema: 2, deprecated: 5, unsupported: 0 } }),
{ deprecated: 5, unsupported: 0, sunset: "2026-12-01" },
);
for (const junk of [
null,
undefined,
"me",
[],
{},
{ configs: "x" },
{ compat_summary: { deprecated: "3", unsupported: -1 } },
{ compat_summary: { deprecated: 1.5, unsupported: NaN } },
])
assert.deepEqual(m.compatCounts(junk), { deprecated: 0, unsupported: 0, sunset: "" });
});
test("hasShares is what tells the preload this router belongs to a creator", async () => {
const m = await load();
assert.equal(m.hasShares(profile(share())), true);
assert.equal(m.hasShares(profile(share({ status: "removed" }))), true);
assert.equal(m.hasShares(profile()), false);
assert.equal(m.hasShares(profile({ id: "bad id" }, null, "x")), false);
for (const junk of [null, undefined, {}, { configs: {} }, "me"]) assert.equal(m.hasShares(junk), false);
});
test("derive: one summary item per compat state, keyed by the set of affected shares", async () => {
const m = await load();
const me = profile(
share({ id: "b2", name: "Warm Paper", compat: { state: "deprecated", sunset_at: "2027-01-01 00:00:00" } }),
share({ id: "a1", name: "Nord Night", compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }),
share({ id: "u9", name: "Old One", compat: { state: "unsupported" } }),
share({ id: "ok" }),
);
const items = m.derive(me);
assert.deepEqual(items, [
{
key: "compat:unsupported:" + m.shortHash("u9"),
group: "action",
source: "shares",
kind: "compat",
state: "unsupported",
level: "critical",
names: ["Old One"],
sunset: "",
},
{
key: "compat:deprecated:" + m.shortHash("a1,b2"),
group: "action",
source: "shares",
kind: "compat",
state: "deprecated",
level: "critical",
names: ["Warm Paper", "Nord Night"],
sunset: "2026-12-01",
},
]);
assert.match(items[0].key, /^compat:unsupported:[0-9a-z]{1,7}$/);
// Same set in another order: same key. A share fixed, or a new one affected:
// a new key, so a Done'd summary comes back.
const reordered = profile(me.configs[1], me.configs[0], me.configs[2]);
assert.equal(m.derive(reordered)[1].key, items[1].key);
const oneFixed = profile(me.configs[0], me.configs[2]);
assert.notEqual(m.derive(oneFixed)[1].key, items[1].key);
assert.equal(m.derive(oneFixed)[0].key, items[0].key);
});
test("derive: rejected assets carry the reviewer's words, takedowns stand alone", async () => {
const m = await load();
const reason = 'login background is 640×360 <img src=x onerror="alert(1)">';
const items = m.derive(
profile(
share({ id: "r1", name: "Warm Paper", assets_status: "rejected", assets_reject_reason: reason }),
share({ id: "r2", name: "No Note", assets_status: "rejected" }),
share({ id: "p1", assets_status: "pending" }),
share({ id: "t1", name: "Taken", status: "removed", assets_status: "rejected", compat: { state: "unsupported" } }),
),
);
assert.deepEqual(items, [
{ key: "rejected:r1", group: "action", source: "shares", kind: "rejected", level: "warning", name: "Warm Paper", reason },
{ key: "rejected:r2", group: "action", source: "shares", kind: "rejected", level: "warning", name: "No Note", reason: "" },
{ key: "removed:t1", group: "action", source: "shares", kind: "removed", level: "warning", name: "Taken" },
]);
});
test("derive treats the hub_me reply as untrusted", async () => {
const m = await load();
for (const junk of [null, undefined, "me", 7, [], {}, { configs: "x" }, { configs: [null, 1, "c"] }])
assert.deepEqual(m.derive(junk), []);
const items = m.derive(
profile(
share({ id: 'x"]<b>', status: "removed" }),
share({ id: "a".repeat(33), assets_status: "rejected" }),
share({ id: "ok1", name: { toString: () => "<b>" }, status: "removed" }),
share({ id: "ok2", assets_status: "rejected", assets_reject_reason: "x".repeat(1001) }),
share({ id: "ok3", assets_status: "rejected", assets_reject_reason: ["nope"] }),
),
);
assert.deepEqual(items.map((item) => item.key), ["rejected:ok2", "rejected:ok3", "removed:ok1"]);
assert.equal(items[2].name, "");
assert.equal(items[0].reason, "");
assert.equal(items[1].reason, "");
});
test("inbox: Needs action first, Updates after, creators broadcasts only for affected routers", async () => {
const m = await load();
const snapshot = {
notices: [
notice({ id: "w1" }),
notice({ id: "cr1", audience: "creators" }),
notice({ id: "i1", level: "info", created_at: "2026-09-19 08:00:00" }),
notice({ id: "bad id" }),
],
};
const affected = profile(share({ id: "a1", compat: { state: "deprecated" } }));
const keys = (items) => items.map((item) => item.group + "/" + item.key);
assert.deepEqual(keys(m.inbox(snapshot, affected, null)), [
"action/compat:deprecated:" + m.shortHash("a1"),
"updates/w1",
"updates/cr1",
"updates/i1",
]);
assert.deepEqual(keys(m.inbox(snapshot, profile(share()), null)), ["updates/w1", "updates/i1"]);
assert.deepEqual(keys(m.inbox(snapshot, null, null)), ["updates/w1", "updates/i1"]);
const info = m.inbox(snapshot, null, null)[1];
assert.equal(info.kind, "notice");
assert.equal(info.level, "info");
assert.equal(info.at, "2026-09-19 08:00:00");
assert.equal(info.notice.id, "i1");
for (const junk of [null, undefined, "x", [], {}, { notices: "x" }])
assert.deepEqual(m.inbox(junk, null, null), []);
});
test("inbox: read and done are per key; done hides, and a changed key brings the item back", async () => {
const m = await load();
const snapshot = { notices: [notice({ id: "w1" }), notice({ id: "w2" })] };
const before = profile(share({ id: "a1", compat: { state: "unsupported" } }));
const compatKey = "compat:unsupported:" + m.shortHash("a1");
const items = m.inbox(snapshot, before, { read: ["w1"], done: ["w2", compatKey] });
assert.deepEqual(items.map((item) => [item.key, item.unread]), [["w1", false]]);
assert.equal(m.unreadCount(items), 0);
const after = profile(before.configs[0], share({ id: "b2", compat: { state: "unsupported" } }));
const again = m.inbox(snapshot, after, { read: ["w1"], done: ["w2", compatKey] });
assert.deepEqual(again.map((item) => [item.key, item.unread]), [
["compat:unsupported:" + m.shortHash("a1,b2"), true],
["w1", false],
]);
assert.equal(m.unreadCount(again), 1);
// A corrupt store is an empty store.
const loose = m.inbox(snapshot, null, { read: "w1", done: { 0: "w2" } });
assert.deepEqual(loose.map((item) => [item.key, item.unread]), [
["w1", true],
["w2", true],
]);
assert.equal(m.unreadCount(null), 0);
assert.equal(m.unreadCount([null, { unread: true }, { unread: false }]), 1);
});
test("badgeCount: unread Needs-action plus unread warning/critical broadcasts, never info", async () => {
const m = await load();
const snapshot = {
notices: [
notice({ id: "w1" }),
notice({ id: "c1", level: "critical" }),
notice({ id: "i1", level: "info" }),
notice({ id: "i2", level: "info" }),
],
};
const me = profile(
share({ id: "r1", assets_status: "rejected" }),
share({ id: "t1", status: "removed" }),
);
const none = { read: [], done: [] };
assert.equal(m.badgeCount(snapshot, me, none), 4);
assert.equal(m.badgeCount(snapshot, null, none), 2);
assert.equal(m.badgeCount(snapshot, me, { read: ["w1", "rejected:r1"], done: [] }), 2);
assert.equal(m.badgeCount(snapshot, me, { read: [], done: ["c1", "removed:t1"] }), 2);
// 1.2.x could cache an opt-out; there is none any more, so it counts.
assert.equal(m.badgeCount({ ...snapshot, muted: true }, me, none), 4);
assert.equal(m.badgeCount({ notices: [notice({ level: "info" })] }, null, none), 0);
for (const junk of [null, undefined, "x", []]) assert.equal(m.badgeCount(junk, me, none), 0);
// No feed yet, but a creator's own items still count.
assert.equal(m.badgeCount({}, me, none), 2);
});
test("key lists: add once, remove, mark everything", async () => {
const m = await load();
assert.deepEqual(m.withKey(["a"], "b"), ["a", "b"]);
assert.deepEqual(m.withKey(["a", "b"], "b"), ["a", "b"]);
assert.deepEqual(m.withKey(null, "a"), ["a"]);
assert.deepEqual(m.withKey(["a", 1, null], { toString: () => "x" }), ["a"]);
assert.deepEqual(m.withoutKey(["a", "b", "a"], "a"), ["b"]);
assert.deepEqual(m.withoutKey("a", "a"), []);
assert.deepEqual(m.withKeys(["a"], [{ key: "b" }, { key: "a" }, null, { key: 5 }, { key: "c" }]), ["a", "b", "c"]);
assert.deepEqual(m.withKeys(["a"], null), ["a"]);
const input = ["a"];
m.withKey(input, "b");
assert.deepEqual(input, ["a"], "the stored list is never mutated in place");
});
test("pruneKeys forgets keys whose item is gone -- but only for a source that actually answered", async () => {
const m = await load();
const keys = ["w1", "gone", "rejected:r1", "rejected:old", "compat:deprecated:abc", "w1"];
const active = ["w1", "rejected:r1"];
assert.deepEqual(m.pruneKeys(keys, active, { feed: true, me: true }), ["w1", "rejected:r1"]);
// hub_me failed or was not asked: every derived key is kept, or one offline
// page load would hand back everything the author had marked Done.
assert.deepEqual(m.pruneKeys(keys, active, { feed: true, me: false }), [
"w1",
"rejected:r1",
"rejected:old",
"compat:deprecated:abc",
]);
assert.deepEqual(m.pruneKeys(keys, active, { feed: false, me: true }), ["w1", "gone", "rejected:r1"]);
assert.deepEqual(m.pruneKeys(keys, active, null), ["w1", "gone", "rejected:r1", "rejected:old", "compat:deprecated:abc"]);
assert.deepEqual(m.pruneKeys(["a", 1, null, {}], ["a"], { feed: true, me: true }), ["a"]);
assert.deepEqual(m.pruneKeys(null, null, { feed: true, me: true }), []);
// The router's own item has a source of its own: a failed hub_me says
// nothing about it, and a failed local probe keeps it.
const all = { feed: true, me: true, local: true };
assert.deepEqual(m.pruneKeys(["feed:missing"], [], all), []);
assert.deepEqual(m.pruneKeys(["feed:missing"], [], { ...all, local: false }), ["feed:missing"]);
assert.deepEqual(m.pruneKeys(["feed:missing"], [], { ...all, me: false }), []);
assert.deepEqual(m.pruneKeys(["feed:missing"], ["feed:missing"], all), ["feed:missing"]);
});
test("stampMs reads the hub's UTC timestamps; ageOf picks the coarsest unit that fits", async () => {
const m = await load();
assert.equal(m.stampMs("2026-09-19 08:00:00"), Date.UTC(2026, 8, 19, 8, 0, 0));
assert.equal(m.stampMs("2026-09-19T08:00:00Z"), Date.UTC(2026, 8, 19, 8, 0, 0));
assert.equal(m.stampMs("2026-09-19"), Date.UTC(2026, 8, 19));
for (const junk of ["", "soon", null, undefined, 5, {}, "19/09/2026"]) assert.equal(m.stampMs(junk), null);
const now = Date.UTC(2026, 8, 19, 12);
const MIN = 60 * 1000;
assert.deepEqual(m.ageOf(now - 30 * 1000, now), { value: 0, unit: "minute" });
assert.deepEqual(m.ageOf(now - 5 * MIN, now), { value: -5, unit: "minute" });
assert.deepEqual(m.ageOf(now - 125 * MIN, now), { value: -2, unit: "hour" });
assert.deepEqual(m.ageOf(now - 26 * 60 * MIN, now), { value: -1, unit: "day" });
assert.deepEqual(m.ageOf(now - 15 * 24 * 60 * MIN, now), { value: -2, unit: "week" });
// A notice dated ahead of this browser's clock is "now", not "in 3 hours".
assert.deepEqual(m.ageOf(now + 3 * 60 * MIN, now), { value: 0, unit: "minute" });
assert.equal(m.ageOf(null, now), null);
assert.equal(m.ageOf(now, NaN), null);
});
test("shortHash is stable and short", async () => {
const m = await load();
assert.equal(m.shortHash("a1,b2"), m.shortHash("a1,b2"));
assert.notEqual(m.shortHash("a1,b2"), m.shortHash("a1,b3"));
assert.match(m.shortHash(""), /^[0-9a-z]{1,7}$/);
assert.match(m.shortHash("x".repeat(5000)), /^[0-9a-z]{1,7}$/);
});
test("localState: the feed verdict comes from get_init_data, and not knowing is not 'missing'", async () => {
const m = await load();
const reply = (feed, versions) => ({ active_preset: "default", feed, versions });
const versions = {
theme: { installed_version: "1.3.8-r20260901" },
config: { installed_version: "1.2.0-r20260902", i18n_packages: "zh-cn" },
};
assert.deepEqual(m.localState(reply({ pm: "apk", configured: false, channel: "" }, versions)), {
feedMissing: true,
theme: "1.3.8-r20260901",
app: "1.2.0-r20260902",
});
assert.equal(m.localState(reply({ pm: "opkg", configured: true, channel: "releases" }, versions)).feedMissing, false);
// Studio's banner used the same rule: an unknown package manager says nothing.
for (const unknown of [
null,
undefined,
{},
{ result: 1 },
reply(null, versions),
reply({ pm: "unknown", configured: false }, versions),
reply({ pm: "", configured: false }, versions),
reply({ configured: false }, versions),
reply("apk", versions),
])
assert.equal(m.localState(unknown), null);
assert.deepEqual(m.localState(reply({ pm: "apk", configured: "no" }, { theme: { installed_version: 7 }, config: "x" })), {
feedMissing: true,
theme: "",
app: "",
});
assert.equal(m.localState(reply({ pm: "apk" }, { theme: { installed_version: "v".repeat(65) } })).theme, "");
});
test("the feed item: one Needs-action entry from this router, first in the list, gone once the source exists", async () => {
const m = await load();
const local = { feedMissing: true, theme: "1.3.8", app: "1.2.0" };
assert.deepEqual(m.deriveLocal(local), [
{ key: "feed:missing", group: "action", source: "router", kind: "feed", level: "warning", theme: "1.3.8", app: "1.2.0" },
]);
assert.equal(m.FEED_KEY, "feed:missing");
for (const none of [null, undefined, {}, "x", [], { feedMissing: false }, { feedMissing: "true" }, { feedMissing: 1 }])
assert.deepEqual(m.deriveLocal(none), []);
assert.deepEqual(m.deriveLocal({ feedMissing: true, theme: 5 })[0].theme, "");
const snapshot = { local, notices: [notice({ id: "w1" })] };
const me = profile(share({ id: "r1", assets_status: "rejected" }));
assert.deepEqual(
m.inbox(snapshot, me, null).map((item) => item.source + "/" + item.key),
["router/feed:missing", "shares/rejected:r1", "store/w1"],
);
assert.equal(m.badgeCount(snapshot, null, { read: [], done: [] }), 2);
assert.equal(m.badgeCount(snapshot, null, { read: ["feed:missing"], done: [] }), 1);
assert.equal(m.badgeCount(snapshot, null, { read: [], done: ["feed:missing"] }), 1);
assert.equal(m.badgeCount({ ...snapshot, local: { ...local, feedMissing: false } }, null, null), 1);
});
test("the reviewer's note keeps its own, smaller cap", async () => {
const m = await load();
const reasonOf = (text) =>
m.derive(profile(share({ id: "r1", assets_status: "rejected", assets_reject_reason: text })))[0].reason;
assert.equal(reasonOf("x".repeat(1000)).length, 1000);
assert.equal(reasonOf("x".repeat(1001)), "");
});
@@ -0,0 +1,598 @@
import assert from "node:assert/strict";
import { readFile, stat } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("preload/aurora-notices.js");
const FEED_URL = "https://themes.eamonxg.fun/api/v1/notices?theme=aurora&schema=1";
const HOUR = 60 * 60 * 1000;
const strip = (src) =>
src.replace(/^"use strict";$/m, "").replace(/^"require [^"]+";$/gm, "");
const notice = (over) => ({
id: "n1",
level: "warning",
audience: "all",
title: "Heads up",
body: "Something changed.",
url: "https://themes.eamonxg.fun/notices/n1",
i18n: { "zh-cn": { title: "请注意", body: "有变化。" } },
...over,
});
// LuCI evaluates every resources/preload/*.js before initDOM, on every page.
// This builds the smallest world that lets the real preload, the real
// utils/notices.js and the real utils/hub-api.js run together, and records
// every way out of the page: fetch, ubus, L.require, localStorage.
async function boot(options) {
const opts = options || {};
const log = { requires: [], fetches: [], rpc: [], storageReads: 0, storageWrites: 0, hidden: [] };
let moves;
const store = new Map(Object.entries(opts.storage || {}));
const listeners = [];
const idle = [];
const timeouts = [];
const indicators = [];
const styles = [];
const document = {
documentElement: { lang: "en" },
head: { appendChild: (node) => styles.push(node) },
addEventListener: (type, handler, flags) => listeners.push({ type, handler, flags }),
getElementById: (id) => styles.find((node) => node.attrs.id === id) || null,
querySelector: (selector) =>
indicators.find((node) => selector === `span[data-indicator="${node.id}"]`) || null,
};
moves = log.moves = [];
const window = {
setTimeout: (cb, ms) => timeouts.push({ cb, ms }),
location: { href: "" },
getComputedStyle: () => ({ fontSize: opts.indicatorFontSize ?? "0px" }),
};
if (opts.idle !== false) window.requestIdleCallback = (cb) => idle.push(cb);
const E = (tag, attrs) => ({ tag, attrs: attrs || {}, textContent: "" });
const translate = (msgid) => msgid;
// Same contract as luci-base's ui.showIndicator: one span per id, relabelled
// in place on the second call.
const ui = {
showIndicator: (id, label, handler, style) => {
let node = indicators.find((candidate) => candidate.id === id);
if (!node) {
const classes = new Set();
node = {
id,
attrs: {},
classes,
classList: { add: (name) => classes.add(name) },
setAttribute: (name, value) => (node.attrs[name] = String(value)),
parentNode: { appendChild: (child) => moves.push(child.id) },
};
indicators.push(node);
}
Object.assign(node, { textContent: label, handler, style });
return true;
},
hideIndicator: (id) => {
log.hidden.push(id);
const at = indicators.findIndex((candidate) => candidate.id === id);
if (at !== -1) indicators.splice(at, 1);
},
};
const uci = {
load: (name) => {
log.rpc.push("uci.load:" + name);
return Promise.resolve();
},
get: (config, section, option) => {
const theme = { hub_notices: "0" };
return option === undefined ? theme : theme && theme[option];
},
};
const rpc = {
declare: (spec) => () => {
log.rpc.push(spec.method);
return Promise.resolve(
spec.method === "hub_me"
? opts.me || null
: spec.method === "get_init_data"
? opts.init || null
: null,
);
},
};
const baseclass = { extend: (obj) => obj };
const noticesModule = new Function(
"baseclass",
strip(await readFile(srcPath("utils/notices.js"), "utf8")),
)(baseclass);
const modules = { ui, uci, "utils.notices": noticesModule };
const L = {
env: { sessionid: "session" in opts ? opts.session : "0123456789abcdef" },
url: (path) => "/cgi-bin/luci/" + path,
resolveDefault: (promise, fallback) => Promise.resolve(promise).catch(() => fallback),
require: (name) => {
log.requires.push(name);
if (opts.requireFails === name) return Promise.reject(new Error("HTTP error 404"));
return Promise.resolve(modules[name]);
},
};
const globals = {
L,
fetch: async (url) => {
log.fetches.push(String(url));
if (opts.fetchFails) throw new TypeError("network down");
const status = opts.status || 200;
return {
status,
ok: status >= 200 && status < 300,
json: async () => ({ notices: opts.feed || [] }),
};
},
localStorage: {
getItem: (k) => {
log.storageReads += 1;
return store.has(k) ? store.get(k) : null;
},
setItem: (k, v) => {
log.storageWrites += 1;
store.set(k, String(v));
},
removeItem: (k) => store.delete(k),
},
};
const saved = {};
for (const key of Object.keys(globals)) {
saved[key] = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, {
value: globals[key],
configurable: true,
writable: true,
});
}
const restore = () => {
for (const key of Object.keys(globals)) {
if (saved[key]) Object.defineProperty(globalThis, key, saved[key]);
else delete globalThis[key];
}
};
modules["utils.hub-api"] = new Function(
"baseclass",
"rpc",
"notices",
strip(await readFile(srcPath("utils/hub-api.js"), "utf8")),
)(baseclass, rpc, noticesModule);
// Same signature LuCI's own loader evals a class file into, plus the two
// page globals (E, _) a browser would already have.
const preload = new Function(
"window",
"document",
"L",
"baseclass",
"E",
"_",
strip(await readFile(SRC, "utf8")),
)(window, document, L, baseclass, E, translate);
return {
log,
store,
listeners,
idle,
timeouts,
indicators,
styles,
window,
preload,
restore,
init: () => preload.__init__(),
// Fire luci-loaded, then run whatever was parked for idle time.
async loaded() {
for (const { handler } of listeners.splice(0)) handler();
for (const cb of idle.splice(0)) await cb();
for (const { cb } of timeouts.splice(0)) await cb();
},
};
}
const cached = (value) => JSON.stringify({ timestamp: Date.now() - HOUR, value });
const fresh = (snapshot, extra) => ({
"aurora.hub.noticesChecked": JSON.stringify(Date.now() - HOUR),
"aurora.hub.notices": cached(snapshot),
...(extra || {}),
});
const creator = (...configs) => ({ id: "me", nickname: "eamon", configs });
const run = async (options, check) => {
const world = await boot(options);
try {
world.init();
await world.loaded();
await check(world);
} finally {
world.restore();
}
return world;
};
test("the preload declares baseclass and nothing else", async () => {
const src = await readFile(SRC, "utf8");
const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]);
// Every declared dependency is fetched and evaluated before initDOM, on every
// page of LuCI -- including the ones that have nothing to do with this app.
assert.deepEqual(requires, ["baseclass"]);
assert.match(src, /^return baseclass\.extend\(/m);
assert.ok(!src.includes("innerHTML"), "nothing here may be parsed as markup");
assert.ok(!src.includes("addNotification"), "the global entry point is an indicator, not a banner");
});
test("__init__ only parks a luci-loaded listener", async () => {
const world = await boot({ storage: fresh({ notices: [notice()] }) });
try {
world.init();
assert.equal(world.listeners.length, 1);
assert.equal(world.listeners[0].type, "luci-loaded");
assert.deepEqual(world.listeners[0].flags, { once: true });
assert.deepEqual(world.log.requires, []);
assert.deepEqual(world.log.fetches, []);
assert.deepEqual(world.log.rpc, []);
assert.equal(world.log.storageReads, 0);
assert.equal(world.log.storageWrites, 0);
assert.equal(world.idle.length, 0);
assert.equal(world.indicators.length, 0);
assert.equal(world.styles.length, 0);
// The event itself still does no work: it hands over to idle time.
for (const { handler } of world.listeners) handler();
assert.equal(world.idle.length, 1);
assert.deepEqual(world.log.requires, []);
assert.equal(world.log.storageReads, 0);
} finally {
world.restore();
}
});
test("without a session -- the login page -- nothing is scheduled at all", async () => {
for (const session of [null, undefined, ""]) {
const world = await boot({ session, storage: fresh({ notices: [notice()] }) });
try {
world.init();
assert.equal(world.listeners.length, 0);
await world.loaded();
assert.deepEqual(world.log.requires, []);
assert.equal(world.log.storageReads, 0);
assert.equal(world.indicators.length, 0);
} finally {
world.restore();
}
}
});
test("browsers without requestIdleCallback fall back to a timeout", async () => {
const world = await boot({ idle: false, storage: fresh({ notices: [notice()] }) });
try {
world.init();
for (const { handler } of world.listeners) handler();
assert.equal(world.timeouts.length, 1);
await world.loaded();
assert.equal(world.indicators.length, 1);
} finally {
world.restore();
}
});
test("a check that is not due counts from the caches: no fetch, no ubus", async () => {
await run(
{
storage: fresh(
{
notices: [
notice(),
notice({ id: "c1", level: "critical" }),
notice({ id: "i1", level: "info" }),
notice({ id: "w2" }),
notice({ id: "w3" }),
],
},
{
"aurora.hub.me": cached(creator({ id: "s1", name: "Warm Paper", status: "active", assets_status: "rejected" })),
"aurora.hub.inboxRead": JSON.stringify(["w2"]),
"aurora.hub.inboxDone": JSON.stringify(["w3"]),
},
),
},
(world) => {
assert.deepEqual(world.log.fetches, []);
assert.deepEqual(world.log.rpc, []);
assert.deepEqual(world.log.requires, ["utils.notices", "utils.hub-api", "utils.notices", "utils.hub-api", "ui"]);
assert.equal(world.log.storageWrites, 0);
// n1 + c1 + the rejected share; info never counts, read and done do not either.
const [indicator] = world.indicators;
assert.equal(indicator.id, "aurora-inbox");
assert.equal(indicator.textContent, "Inbox 3");
assert.equal(indicator.attrs["data-count"], "3");
assert.equal(indicator.style, "active");
assert.equal(indicator.title, "Inbox 3");
assert.equal(typeof indicator.handler, "function");
},
);
});
test("nothing unread that matters: no indicator, no injected style", async () => {
const quiet = [
fresh({ notices: [] }),
fresh({ notices: [notice({ level: "info" })] }),
fresh({ notices: [notice()] }, { "aurora.hub.inboxRead": JSON.stringify(["n1"]) }),
fresh({ notices: [notice()] }, { "aurora.hub.inboxDone": JSON.stringify(["n1"]) }),
fresh({ notices: [notice({ audience: "creators" })] }),
fresh("{corrupt"),
{ "aurora.hub.noticesChecked": JSON.stringify(Date.now() - HOUR) },
];
for (const storage of quiet)
await run({ storage }, (world) => {
assert.deepEqual(world.log.fetches, []);
assert.deepEqual(world.log.rpc, []);
assert.equal(world.indicators.length, 0);
assert.equal(world.styles.length, 0);
assert.deepEqual(world.log.hidden, ["aurora-inbox"]);
});
});
test("a creators broadcast counts once the cached profile shows an affected share", async () => {
await run(
{
storage: fresh(
{ notices: [notice({ audience: "creators" })] },
{ "aurora.hub.me": cached(creator({ id: "s1", status: "active", compat: { state: "deprecated" } })) },
),
},
(world) => {
// The broadcast plus the derived "needs an update" summary.
assert.equal(world.indicators[0].attrs["data-count"], "2");
assert.deepEqual(world.log.rpc, []);
},
);
});
test("one indicator and one style per document, however often it is refreshed", async () => {
await run({ storage: fresh({ notices: [notice(), notice({ id: "n2" })] }) }, async (world) => {
await world.preload.update();
await world.preload.update();
assert.equal(world.indicators.length, 1);
assert.equal(world.styles.length, 1);
assert.equal(world.indicators[0].attrs["data-count"], "2");
// The Marketplace marks things read and pokes the same module.
world.store.set("aurora.hub.inboxRead", JSON.stringify(["n1"]));
await world.preload.update();
assert.equal(world.indicators[0].textContent, "Inbox 1");
assert.equal(world.indicators[0].attrs["data-count"], "1");
world.store.set("aurora.hub.inboxRead", JSON.stringify(["n1", "n2"]));
await world.preload.update();
assert.equal(world.indicators.length, 0, "the indicator leaves as soon as the last item is read");
assert.deepEqual(world.log.fetches, []);
assert.deepEqual(world.log.rpc, []);
});
});
test("clicking the indicator opens the Marketplace on its Inbox tab", async () => {
await run({ storage: fresh({ notices: [notice()] }) }, (world) => {
assert.equal(world.preload.INBOX_HASH, "#inbox");
world.indicators[0].handler();
assert.equal(world.window.location.href, "/cgi-bin/luci/admin/system/aurora/marketplace#inbox");
});
});
test("the injected style draws the icon only where the theme hides indicator text", async () => {
await run({ storage: fresh({ notices: [notice()] }) }, (world) => {
const [style] = world.styles;
assert.equal(style.tag, "style");
const css = style.textContent;
const rules = css.split("}").filter((rule) => rule.trim());
assert.equal(rules.length, 3);
for (const rule of rules)
assert.ok(rule.startsWith('#indicators span[data-indicator="aurora-inbox"].aurora-inbox-icon'), rule.slice(0, 80));
assert.doesNotMatch(css, /data-nav-type/);
assert.match(rules[0], /\{position:relative;order:1;/);
assert.match(css, /::before\{-webkit-mask:var\(--aurora-inbox-icon\) center\/cover no-repeat;mask:var\(--aurora-inbox-icon\) center\/cover no-repeat\}/);
assert.match(css, /--aurora-inbox-icon:url\("data:image\/svg\+xml,/);
const [indicator] = world.indicators;
assert.deepEqual([...indicator.classes], ["aurora-inbox-icon"]);
assert.deepEqual(world.log.moves, ["aurora-inbox"]);
// The theme's uci-changes badge: -top-0.5 -right-0.5 min-h-3 min-w-3 px-0.5
// text-[8px] font-bold leading-none rounded-full bg-danger text-on-brand.
const badge = css.slice(css.indexOf("[data-count]::after{"));
for (const declaration of [
"content:attr(data-count)",
"position:absolute",
"top:-2px",
"right:-2px",
"z-index:10",
"min-width:12px",
"min-height:12px",
"padding:0 2px",
"border-radius:99px",
"background:var(--danger)",
"color:var(--on-brand)",
"font-size:8px",
"font-weight:700",
"line-height:1",
])
assert.ok(badge.includes(declaration), declaration);
});
});
test("a theme that shows indicator text keeps LuCI's plain label", async () => {
await run({ storage: fresh({ notices: [notice()] }), indicatorFontSize: "13px" }, (world) => {
const [indicator] = world.indicators;
assert.deepEqual([...indicator.classes], []);
assert.match(indicator.textContent, /^Inbox \d+$/);
});
});
test("a due check fetches the feed once, caches it and shows the count", async () => {
const stale = { "aurora.hub.noticesChecked": JSON.stringify(Date.now() - 13 * HOUR) };
for (const storage of [{}, stale]) {
const before = Date.now();
await run(
{
storage: { ...storage, "aurora.hub.inboxDone": JSON.stringify(["gone", "n9"]) },
feed: [notice(), notice({ id: "n9" }), notice({ id: "bad id" })],
},
(world) => {
assert.deepEqual(world.log.requires.slice(0, 2), ["utils.notices", "utils.hub-api"]);
assert.deepEqual(world.log.rpc, ["get_init_data"]);
assert.deepEqual(world.log.fetches, [FEED_URL]);
assert.equal(world.indicators[0].attrs["data-count"], "1");
const snapshot = JSON.parse(world.store.get("aurora.hub.notices")).value;
assert.deepEqual(snapshot.notices.map((n) => n.id), ["n1", "n9"]);
assert.equal("muted" in snapshot, false);
assert.ok(JSON.parse(world.store.get("aurora.hub.noticesChecked")) >= before);
assert.deepEqual(JSON.parse(world.store.get("aurora.hub.inboxDone")), ["n9"]);
},
);
}
});
test("hub_me runs only when due, and then only for a creators notice or a router known to have shares", async () => {
const rejected = { id: "s1", name: "Warm Paper", status: "active", assets_status: "rejected" };
const reply = { result: 0, data: creator(rejected) };
const cases = [
{ feed: [notice()], rpc: ["get_init_data"], count: "1" },
{ feed: [notice({ audience: "creators" })], rpc: ["get_init_data", "hub_me"], count: "1" },
{
feed: [notice()],
storage: { "aurora.hub.me": cached(creator({ id: "s1", status: "active" })) },
rpc: ["get_init_data", "hub_me"],
count: "2",
},
];
for (const { feed, storage, rpc, count } of cases)
await run({ feed, storage, me: reply }, (world) => {
assert.deepEqual(world.log.rpc, rpc);
assert.equal(world.indicators[0].attrs["data-count"], count);
});
// Not due: a creator's router still asks nothing.
await run(
{ storage: fresh({ notices: [] }, { "aurora.hub.me": cached(creator(rejected)) }), me: reply },
(world) => {
assert.deepEqual(world.log.rpc, []);
assert.equal(world.indicators[0].attrs["data-count"], "1");
},
);
});
test("the missing update source counts, from a probe made only when due and cached with the feed", async () => {
const init = { feed: { pm: "apk", configured: false, channel: "" }, versions: {} };
const world = await run({ init, feed: [] }, (w) => {
assert.deepEqual(w.log.rpc, ["get_init_data"]);
assert.equal(w.indicators[0].attrs["data-count"], "1");
assert.equal(JSON.parse(w.store.get("aurora.hub.notices")).value.local.feedMissing, true);
});
// The pages of the next twelve hours count it from the cache: zero ubus.
await run({ init, storage: Object.fromEntries(world.store) }, (next) => {
assert.deepEqual(next.log.rpc, []);
assert.deepEqual(next.log.fetches, []);
assert.equal(next.indicators[0].attrs["data-count"], "1");
});
await run(
{ init: { feed: { pm: "apk", configured: true, channel: "snapshots" }, versions: {} }, feed: [] },
(w) => assert.equal(w.indicators.length, 0),
);
});
test("the preload never loads the Markdown renderer", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!/markdown/i.test(src));
const hubApi = await readFile(srcPath("utils/hub-api.js"), "utf8");
const noticesSrc = await readFile(srcPath("utils/notices.js"), "utf8");
for (const dependency of [hubApi, noticesSrc]) assert.ok(!/require utils\.markdown/.test(dependency));
await run({ feed: [notice({ body: "**bold** [x](https://a.b/)" })] }, (world) =>
assert.ok(!world.log.requires.some((name) => name.includes("markdown"))),
);
});
test("a hub without the endpoint, a dead network, a broken module: all silent", async () => {
const rejections = [];
const onRejection = (reason) => rejections.push(reason);
process.on("unhandledRejection", onRejection);
try {
for (const options of [
{ status: 404 },
{ status: 500 },
{ fetchFails: true },
{ requireFails: "utils.notices" },
{ requireFails: "utils.hub-api" },
{ requireFails: "ui", feed: [notice()] },
]) {
const world = await boot(options);
try {
world.init();
await assert.doesNotReject(world.loaded());
await assert.doesNotReject(world.preload.update());
assert.equal(world.indicators.length, 0);
} finally {
world.restore();
}
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(rejections, []);
} finally {
process.off("unhandledRejection", onRejection);
}
});
test("a failed poll still shows what the caches already held", async () => {
for (const options of [{ fetchFails: true }, { status: 500 }])
await run(
{
...options,
storage: { "aurora.hub.notices": cached({ notices: [notice()] }) },
},
(world) => assert.equal(world.indicators[0].attrs["data-count"], "1"),
);
});
test("the shipped preload stays small: it is parsed before every page's DOM init", async () => {
// About 1 KB of this is the injected stylesheet -- the inbox glyph as a mask
// data URI plus the badge -- which has to live here rather than in the theme,
// or a new app on an old theme would show a black square.
const BUDGET = 2560;
const { size } = await stat(repo("htdocs/luci-static/resources/preload/aurora-notices.js"));
assert.ok(size < BUDGET, `preload artifact is ${size} bytes; the budget is ${BUDGET}`);
});
test("the count is always on: the preload never reads uci, and a 1.2.x opt-out in the cache is ignored", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!/\buci\b|hub_notices|muted/.test(src), "no opt-out is read or honoured");
// The world below would report hub_notices '0' if asked. Nobody asks.
const stale = fresh({ notices: [notice()], muted: true });
await run({ storage: stale }, (world) => {
assert.equal(world.indicators[0].attrs["data-count"], "1");
assert.ok(!world.log.requires.includes("uci"));
assert.deepEqual(world.log.rpc, []);
});
// Due, with the same stale flag: fetched, shown, and the flag is gone from the cache.
await run({ storage: { "aurora.hub.notices": cached({ notices: [], muted: true }) }, feed: [notice()] }, (world) => {
assert.equal(world.indicators[0].attrs["data-count"], "1");
assert.ok(!world.log.requires.includes("uci"));
assert.ok(!world.log.rpc.some((call) => call.startsWith("uci")));
assert.equal("muted" in JSON.parse(world.store.get("aurora.hub.notices")).value, false);
});
});
@@ -0,0 +1,15 @@
// Test paths resolve from this file, never from process.cwd(): `pnpm test` runs
// with cwd=.dev while most of what these tests assert about lives at the repo
// root. `repo` addresses the repo root; `src` addresses the JS sources, which
// are what a human edits -- assert against src, not against the built artifacts
// under htdocs, unless the artifact itself is the thing under test.
import { fileURLToPath } from "node:url";
export const repo = (relPath) =>
fileURLToPath(new URL(`../../${relPath}`, import.meta.url));
// Deliberately not called `src`: almost every test in here already binds
// `const src = await readFile(...)`, which would shadow the helper and turn a
// path lookup into "src is not a function" halfway down a file.
export const srcPath = (relPath) =>
fileURLToPath(new URL(`../src/resource/${relPath}`, import.meta.url));
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import test from "node:test";
import { repo } from "./paths.mjs";
// msgfmt --check-format only inspects entries flagged `#, c-format`, and
// merge-po.mjs writes no flags -- so running it over these catalogues proves
// nothing at all (verified by mutating a msgstr's %d to %s: msgfmt stayed
// silent). LuCI's translate() does the substitution itself, and a msgstr whose
// placeholders do not match its msgid renders as a broken string on a device
// nobody testing in English will ever look at. So the check lives here.
// .DS_Store 也在这个目录里,所以按"有没有 aurora-config.po"筛,而不是按名字排除。
const LANGS = readdirSync(repo("po"), { withFileTypes: true })
.filter((d) => d.isDirectory() && d.name !== "templates")
.map((d) => d.name)
.sort();
const parse = (text) =>
[...text.matchAll(/msgid "((?:[^"\\]|\\.)*)"\nmsgstr "((?:[^"\\]|\\.)*)"\n/g)]
.map((m) => ({ msgid: m[1], msgstr: m[2] }))
.filter((e) => e.msgid !== "");
// %% is a literal percent, not a placeholder -- strip it before counting.
const placeholders = (s) => (s.replace(/%%/g, "").match(/%[a-z]/g) || []);
test("po: 每个 msgstr 的占位符与 msgid 逐个对应", () => {
const problems = [];
for (const lang of LANGS) {
for (const { msgid, msgstr } of parse(
readFileSync(repo(`po/${lang}/aurora-config.po`), "utf8"),
)) {
if (!msgstr) continue;
const want = placeholders(msgid);
const got = placeholders(msgstr);
// 顺序也要一致:LuCI 的 format() 按位置取参,换个顺序就是把体积填进个数里。
if (want.join(",") !== got.join(",")) {
problems.push(`${lang}: [${want}] vs [${got}] ${msgid.slice(0, 50)}`);
}
// %% 的个数也不能变 —— 少一个,进度条就会把 "50%" 显示成 "50"。
if ((msgid.match(/%%/g) || []).length !== (msgstr.match(/%%/g) || []).length) {
problems.push(`${lang}: %% count ${msgid.slice(0, 50)}`);
}
}
}
assert.deepEqual(problems, []);
});
test("po: 十四个语种一条不缺、一条不空", () => {
const pot = parse(readFileSync(repo("po/templates/aurora-config.pot"), "utf8"));
assert.equal(LANGS.length, 14);
for (const lang of LANGS) {
const entries = parse(readFileSync(repo(`po/${lang}/aurora-config.po`), "utf8"));
assert.equal(entries.length, pot.length, `${lang} 条目数与模板不符`);
const empty = entries.filter((e) => !e.msgstr).map((e) => e.msgid);
// 空 msgstr 会原样吐出英文,看起来像"这条忘了翻",而不是"这条没有译文"。
assert.deepEqual(empty, [], `${lang} 有未翻译条目`);
}
});
@@ -0,0 +1,872 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
const rpcd = readFileSync(
path.join(repoRoot, "root/usr/libexec/rpcd/luci.aurora"),
"utf8",
);
const acl = readFileSync(
path.join(repoRoot, "root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
"utf8",
);
test("rpcd script: hub constants are defined", () => {
assert.match(rpcd, /readonly HUB_BASE="https:\/\/themes\.eamonxg\.fun"/);
assert.match(rpcd, /readonly DEVICE_DIR="\/etc\/aurora"/);
assert.match(rpcd, /readonly HUB_JOB_PATH="\/tmp\/aurora_hub_jobs"/);
});
test("rpcd script: device identity and hub HTTP helpers are defined", () => {
assert.match(rpcd, /ensure_device_identity\(\)\s*\{/);
assert.match(rpcd, /hub_http_get\(\)\s*\{/);
assert.match(rpcd, /hub_http_post\(\)\s*\{/);
});
test("rpcd script: ensure_device_identity generates and locks down device files", () => {
assert.match(rpcd, /DEVICE_DIR\/device\.key/);
assert.match(rpcd, /chmod 600 "\$DEVICE_DIR\/device\.key"/);
assert.match(rpcd, /DEVICE_DIR\/device\.hash/);
assert.match(rpcd, /chmod 644 "\$DEVICE_DIR\/device\.hash"/);
assert.match(
rpcd,
/head -c32 \/dev\/urandom \| hexdump -v -e '\/1 "%02x"'/,
);
assert.match(rpcd, /DEVICE_TOKEN=\$\(cat "\$DEVICE_DIR\/device\.key"\)/);
assert.match(rpcd, /DEVICE_HASH=\$\(cat "\$DEVICE_DIR\/device\.hash"\)/);
});
test("rpcd script: hub_http_get reuses fetch_url", () => {
assert.match(rpcd, /fetch_url "\$out" "\$HUB_BASE\$1"/);
});
test("rpcd script: hub_http_post posts JSON via wget", () => {
assert.match(rpcd, /--post-file="\$2" "\$HUB_BASE\$1"/);
assert.match(rpcd, /Content-Type: application\/json/);
});
// uclient-fetch (what /usr/bin/wget actually is on OpenWrt) treats
// --post-file as "use the POST method with this body", so combining it with
// --method is a contradiction: it prints its usage and exits non-zero, which
// the caller can only surface as hub_unreachable. Update and delete were dead
// on arrival because of this. The flag that pairs with --method is
// --body-file.
test("rpcd script: PUT and DELETE pair --method with --body-file, never --post-file", () => {
assert.match(rpcd, /--method=PUT --body-file="\$2"/);
assert.match(rpcd, /--method=DELETE --body-file="\$2"/);
assert.ok(
!/--method=\w+ --post-file/.test(rpcd),
"--post-file forces POST; pairing it with --method makes uclient-fetch reject the call",
);
});
// --- Task 5: apply job (backup + local validation + assets + rollback) ---
test("rpcd script: job status writer and validator functions are defined", () => {
assert.match(rpcd, /write_hub_job_status\(\)\s*\{/);
assert.match(rpcd, /validate_and_apply_hub_payload\(\)\s*\{/);
assert.match(rpcd, /hub_apply_worker\(\)\s*\{/);
});
test("rpcd script: write_hub_job_status prunes status files older than an hour (/tmp/aurora_hub_jobs is never otherwise cleaned up)", () => {
const body = extractFunctionBody(rpcd, "write_hub_job_status");
assert.match(body, /find\s+"\$HUB_JOB_PATH"\s+-name\s+'\*\.status'\s+-mmin\s+\+60\s+-delete/);
});
test("rpcd script: hub_apply, get_hub_status, hub_restore_backup handlers exist", () => {
assert.ok(rpcd.includes('"hub_apply")'));
assert.ok(rpcd.includes('"get_hub_status")'));
assert.ok(rpcd.includes('"hub_restore_backup")'));
});
test("rpcd script: hub_apply, get_hub_status, hub_restore_backup registered in list branch", () => {
assert.ok(rpcd.includes('json_add_object "hub_apply"'));
assert.ok(rpcd.includes('json_add_object "get_hub_status"'));
assert.ok(rpcd.includes('json_add_object "hub_restore_backup"'));
});
test("rpcd script: get_hub_status guards job_id with the font-job charset pattern", () => {
assert.match(rpcd, /""\|\*\[!A-Za-z0-9_\.\-\]\*\)/);
});
test("rpcd script: worker takes a pre-hub-backup snapshot before touching UCI", () => {
assert.match(rpcd, /pre-hub-backup\.conf/);
assert.match(rpcd, /cp\s+"?\$CONFIG_FILE"?\s+"\$DEVICE_DIR\/pre-hub-backup\.conf"/);
});
test("rpcd script: hub_restore_backup restores the snapshot and resyncs derived state", () => {
assert.match(
rpcd,
/cp\s+"\$DEVICE_DIR\/pre-hub-backup\.conf"\s+"?\$CONFIG_FILE"?/,
);
assert.match(rpcd, /"no_backup"/);
});
test("rpcd script: color validation uses the variable-length hex regex", () => {
assert.match(rpcd, /\[0-9a-f\]\{3,4\}/);
});
test("rpcd script: colors are applied with single-quoted uci batch values", () => {
assert.match(rpcd, /set aurora\.theme\.%s='%s'/);
});
test("rpcd script: uci commit failure reverts the batch and reports error", () => {
assert.match(rpcd, /uci -q revert aurora/);
});
test("rpcd script: hub-applied bookkeeping is written on success", () => {
assert.match(rpcd, /aurora\.theme\.hub_applied/);
assert.match(rpcd, /aurora\.theme\.icon_cache_version/);
});
test("rpcd script: assets are fetched with checksum verification and skip on failure", () => {
assert.match(rpcd, /fetch_verified\s+"\$tmp_path"/);
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
assert.match(workerBody, /apply_hub_asset\s+"\$a_kind"\s+"\$a_sha256"/);
});
test("rpcd script: login_bg asset lands as struct_login_bg", () => {
assert.match(rpcd, /struct_login_bg/);
});
// --- FINAL-REVIEW: fetch_verified used to be pointed straight at the live
// shipped-default image file, so a corrupt/mismatched hub download deleted
// the factory default with nothing to fall back to; and hub_restore_backup
// restored /etc/config/aurora but never the images an apply had overwritten.
// apply_hub_asset now fetches+verifies to a temp path and only mv's onto the
// live target on success, backing it up first. ---
test("rpcd script: apply_hub_asset fetches+verifies to a temp path, never rm's the live target on failure, and mv's only on success", () => {
const body = extractFunctionBody(rpcd, "apply_hub_asset");
assert.match(body, /tmp_path="\$ICON_PATH\/\.hubtmp-\$\$"/);
assert.match(body, /fetch_verified\s+"\$tmp_path"\s+"\$sha256"\s+"\$url"/);
assert.match(body, /mv\s+"\$tmp_path"\s+"\$target_path"/);
assert.ok(!/rm -f "\$target_path"/.test(body), "must never rm the live target file");
});
// 名字现在是 sha 派生的,所以落地通常是**新增**而不是覆盖。两件事因此都要做:
// 覆盖到的备份下来,新增的打 .new 标记 —— 少了后者,回滚会把别人的图永远留在
// overlay 上(字体那边同样的道理,见 restore_pre_hub_fonts)。
test("rpcd script: apply_hub_asset records what a rollback has to undo", () => {
const body = extractFunctionBody(rpcd, "apply_hub_asset");
assert.match(body, /backup_dir="\$DEVICE_DIR\/pre-hub-images"/);
assert.match(body, /cp -p "\$target_path" "\$backup_dir\/\$name"/);
assert.match(body, /: > "\$backup_dir\/\$name\.new"/);
});
test("rpcd script: apply_hub_asset points the matching uci option at the applied filename on success", () => {
const body = extractFunctionBody(rpcd, "apply_hub_asset");
assert.match(rpcd, /hub_asset_uci_option\(\)\s*\{/);
assert.match(body, /uci -q set "aurora\.theme\.\$opt=\$name"/);
assert.match(body, /struct_login_bg="url\('\/luci-static\/aurora\/images\/\$name'\)"/);
});
// 单槽位快照现在住在 backup_if_mine 里,worker 只是调用它 —— 因为清空上一次的
// 图片备份和拍这次的配置快照必须同进同退:只做一半,回滚就会交还一份配置配着
// 另一套图片。
test("rpcd script: the single-slot snapshot clears any prior apply's image backups before taking a new one", () => {
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
assert.match(workerBody, /backup_if_mine/);
const backupBody = extractFunctionBody(rpcd, "backup_if_mine");
assert.match(backupBody, /rm -rf "\$DEVICE_DIR\/pre-hub-images"/);
});
test("rpcd script: hub_restore_backup restores backed-up images alongside /etc/config/aurora", () => {
const branchMatch = rpcd.match(/"hub_restore_backup"\)([\s\S]*?)\n\t;;/);
assert.ok(branchMatch);
assert.match(branchMatch[1], /restore_pre_hub_images/);
const restoreBody = extractFunctionBody(rpcd, "restore_pre_hub_images");
assert.match(restoreBody, /pre-hub-images/);
assert.match(restoreBody, /cp -p "\$f" "\$ICON_PATH\/\$\{f##\*\/\}"/);
assert.match(restoreBody, /rm -rf "\$backup_dir"/);
// 先删本次新引入的,再拷回被覆盖的 —— 顺序是正确性的一部分:一张图可以两者
// 都是(这次覆盖掉的,正是更早一次 apply 引入的)。
const dropIdx = restoreBody.indexOf('rm -f "$ICON_PATH/${base%.new}"');
const copyIdx = restoreBody.indexOf('cp -p "$f" "$ICON_PATH/${f##*/}"');
assert.ok(dropIdx >= 0, "must drop the files this apply introduced");
assert.ok(dropIdx < copyIdx, "dropping must come before copying back");
});
test("rpcd script: worker posts a download count after a successful apply", () => {
assert.match(rpcd, /\/api\/v1\/themes\/aurora\/configs\/.*\/download/);
assert.match(rpcd, /device_hash/);
assert.match(rpcd, /hub_http_post/);
});
test("rpcd script: bad payload is rejected before any uci write (validator returns non-zero)", () => {
assert.match(rpcd, /bad_payload/);
});
// --- FINAL-REVIEW: hub_http_get returns the hub config body FLAT (no "data"
// wrapper -- that envelope is the frontend's, added by hubFetch in
// utils/hub-api.js). hub_apply_worker and hub_my_shares call hub_http_get
// directly, so a `json_select data` there always fails, meaning every
// hub_apply used to report bad_payload unconditionally. ---
test("rpcd script: hub_apply_worker never selects a 'data' wrapper off the raw hub_http_get body", () => {
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
assert.ok(!/json_select\s+data/.test(workerBody), "hub_http_get's own response has no 'data' wrapper to select");
// validate_and_apply_hub_payload now takes the whole flat body directly,
// and does its own json_load/json_select internally instead of the
// caller pre-extracting + re-json_dump'ing a "payload" subtree (json_dump
// always dumps from the jshn root, ignoring any prior json_select, so the
// old payload_json=$(json_dump)-after-select was silently dumping the
// wrong subtree).
assert.match(workerBody, /validate_and_apply_hub_payload\s+"\$body"/);
});
test("rpcd script: validate_and_apply_hub_payload reads the flat body directly (json_load + json_select payload internally, no re-dump-after-select)", () => {
const validateBody = extractFunctionBody(rpcd, "validate_and_apply_hub_payload");
assert.match(validateBody, /json_load\s+"\$body_json"/);
assert.match(validateBody, /json_get_var\s+hub_name\s+"name"/);
assert.match(validateBody, /json_select\s+payload\s+2>\/dev\/null/);
assert.ok(!/json_dump/.test(validateBody), "must navigate the loaded tree in place, never re-dump a subtree");
});
test("rpcd script: hub_apply_worker selects assets at the flat body's root (not under data or payload)", () => {
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
assert.match(workerBody, /json_load\s+"\$body"\s+2>\/dev\/null\s*\n\s*if json_select assets 2>\/dev\/null; then/);
});
// A shared configuration names a font preset this router has very likely never
// downloaded. sync_font_css_from_uci only rewrites the CSS from whatever woff2
// files are already cached, and every font stack ends in a built-in family, so
// the missing webfont never fails loudly -- it silently renders as Lato and the
// applied theme looks like only its colours arrived. Every path that swaps the
// whole config for one from elsewhere has to fetch the files too.
test("rpcd script: config-replacing paths cache the fonts they name, not just rewrite the CSS", () => {
const workerBody = extractFunctionBody(rpcd, "hub_apply_worker");
assert.match(workerBody, /^\s*sync_and_cache_fonts_from_uci\s*$/m);
assert.ok(
!/^\s*sync_font_css_from_uci\s*$/m.test(workerBody),
"hub_apply_worker must not stop at the cache-only resync",
);
// import_config and hub_restore_backup replace the config the same way:
// an imported file comes from another device, and a rollback target's font
// cache may already have been cleaned up by the apply it is undoing.
["import_config", "hub_restore_backup"].forEach((handler) => {
const start = rpcd.indexOf(`"${handler}")`);
assert.ok(start >= 0, `${handler} handler should exist`);
const branch = rpcd.slice(start, start + 1200);
assert.match(
branch,
/sync_and_cache_fonts_from_uci/,
`${handler} must fetch the fonts its new config names`,
);
});
});
test("acl: hub_apply and hub_restore_backup granted under write.ubus, get_hub_status under read.ubus", () => {
const acljson = JSON.parse(acl);
const writeMethods = acljson["luci-app-aurora"].write.ubus["luci.aurora"];
const readMethods = acljson["luci-app-aurora"].read.ubus["luci.aurora"];
assert.ok(writeMethods.includes("hub_apply"));
assert.ok(writeMethods.includes("hub_restore_backup"));
assert.ok(readMethods.includes("get_hub_status"));
});
test("acl: hub job paths are granted", () => {
const acljson = JSON.parse(acl);
const writeFiles = acljson["luci-app-aurora"].write.file;
const readFiles = acljson["luci-app-aurora"].read.file;
assert.ok(Object.keys(writeFiles).some((p) => p.startsWith("/tmp/aurora_hub_jobs")));
assert.ok(Object.keys(readFiles).some((p) => p.startsWith("/tmp/aurora_hub_jobs")));
});
// --- FINAL-REVIEW fix wave ---
test("acl: /etc/aurora is NOT file-ACL granted (rpcd handlers run as root and never need it; granting it would let browser JS read device.key via fs.read)", () => {
const acljson = JSON.parse(acl);
const writeFiles = Object.keys(acljson["luci-app-aurora"].write.file);
const readFiles = Object.keys(acljson["luci-app-aurora"].read.file);
assert.ok(!writeFiles.some((p) => p.startsWith("/etc/aurora")), "write.file must not grant /etc/aurora/*");
assert.ok(!readFiles.some((p) => p.startsWith("/etc/aurora")), "read.file must not grant /etc/aurora/*");
});
test("acl: the fixed /tmp/aurora_hub_share.json path is gone (share/update now use mktemp)", () => {
const acljson = JSON.parse(acl);
const writeFiles = Object.keys(acljson["luci-app-aurora"].write.file);
assert.ok(!writeFiles.includes("/tmp/aurora_hub_share.json"));
assert.ok(!rpcd.includes("/tmp/aurora_hub_share.json"), "script must no longer reference the fixed share-body path");
});
test("rpcd script: the publish calls write their request body via mktemp, not a fixed path", () => {
const beginMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t\t;;/);
const commitMatch = rpcd.match(/"hub_share_commit"\)([\s\S]*?)\n\t\t;;/);
assert.ok(beginMatch && commitMatch);
assert.match(beginMatch[1], /draft_body_tmp=\$\(mktemp\)/);
assert.match(commitMatch[1], /commit_body_tmp=\$\(mktemp\)/);
});
test("sysupgrade: device identity files are preserved across firmware upgrades", () => {
const sysupgradeFile = readFileSync(
path.join(repoRoot, "root/etc/sysupgrade.conf.d/aurora-device"),
"utf8",
);
const lines = sysupgradeFile.trim().split("\n").map((l) => l.trim());
assert.ok(lines.includes("/etc/aurora/device.key"));
assert.ok(lines.includes("/etc/aurora/device.hash"));
});
// --- Security review round 1: embedded-newline bypass + unchecked uci batch ---
test("rpcd script: has_control_char helper rejects embedded control characters (incl. newline)", () => {
assert.match(rpcd, /has_control_char\(\)\s*\{/);
assert.match(rpcd, /\*\[\[:cntrl:\]\]\*/);
});
test("rpcd script: every free-text field is guarded by has_control_char before its regex/case check", () => {
// colors, the three rem fields, struct_font_sans/mono, toolbar url/icon,
// and the server-supplied hub name must all be rejected outright on any
// control character (never silently stripped) before the format regex
// runs -- grep/case anchors are line-based, not whole-string, so a value
// with an embedded newline can smuggle a second uci-batch command past a
// regex/case check alone.
const guardCount = (rpcd.match(/has_control_char\s+"\$\w+"\s*&&/g) || []).length;
assert.ok(
guardCount >= 8,
`expected at least 8 has_control_char guard call sites, found ${guardCount}`,
);
// hub_name is guarded by has_batch_unsafe_char (control chars OR a literal
// quote -- see the round-4 test below), a strict superset of has_control_char.
assert.match(rpcd, /has_batch_unsafe_char\s+"\$hub_name"\s*&&/);
});
test("rpcd script: uci batch exit status is checked (not just the later uci commit)", () => {
// 允许 `>/dev/null`(见 rpcd-toolbar-handoff 那条 stdout 卫生测试):重定向
// 只吞 uci batch 自己的 stdout,管道退出码依然是它的。
assert.match(rpcd, /if\s+!\s*\{[\s\S]*?\}\s*\|\s*uci batch(\s+>\s*\/dev\/null)?;\s*then/);
});
// --- Security review round 4: uci batch always exits 0, and a literal
// single quote (legal per the hub's own struct_font_*/title regexes) breaks
// the single-quoted `set foo='value'` batch line, silently dropping that
// field instead of failing the apply. ---
test("rpcd script: has_batch_unsafe_char rejects control chars and a literal single quote", () => {
assert.match(rpcd, /has_batch_unsafe_char\(\)\s*\{/);
assert.match(rpcd, /has_batch_unsafe_char\(\)\s*\{\s*\n\s*has_control_char "\$1" && return 0\s*\n\s*case "\$1" in \*\\'\*\) return 0 ;; esac/);
});
test("rpcd script: hub_applied name, struct_font_sans/mono, and toolbar title all reject a literal single quote before entering the uci batch", () => {
assert.match(rpcd, /has_batch_unsafe_char\s+"\$hub_name"\s*&&/);
const validateBody = extractFunctionBody(rpcd, "validate_and_apply_hub_payload");
assert.match(validateBody, /case "\$struct_font_sans" in \*\\'\*\) rm -f "\$batch_file"; return 1 ;; esac/);
assert.match(validateBody, /case "\$struct_font_mono" in \*\\'\*\) rm -f "\$batch_file"; return 1 ;; esac/);
assert.match(validateBody, /case "\$trimmed_title" in\s*\n\s*\*\\'\*\) json_select \.\.; rm -f "\$batch_file"; return 1 ;;/);
});
test("rpcd script: build_share_payload and is_valid_font_stack also reject a literal single quote, for consistency with the apply side", () => {
assert.match(rpcd, /is_valid_font_stack\(\)\s*\{[\s\S]*?case "\$v" in \*\\'\*\) return 1 ;; esac/);
const itemBody = extractFunctionBody(rpcd, "toolbar_item_shareable");
assert.match(itemBody, /case "\$trimmed_title" in \*\\'\*\) return 1 ;; esac/);
});
test("rpcd script: require_color_token_keys guards colors validation", () => {
assert.match(rpcd, /require_color_token_keys\s*\|\|\s*return 1/);
});
// --- Security review round 2: the asset sha256 field had the same bypass ---
test("rpcd script: asset sha256 is guarded against control characters before its hex64 check", () => {
// The sha256 field feeds hub_asset_target_name's login_bg filename, so it
// needs the same has_control_char guard as every other free-text field --
// `grep '^[0-9a-fA-F]{64}$'` alone is line-anchored, not whole-string
// anchored, so "64 hex chars\nrm -rf /" would otherwise still satisfy it
// on the first line.
assert.match(
rpcd,
/!\s*has_control_char\s+"\$a_sha256"[\s\S]{0,80}grep -Eq '\^\[0-9a-fA-F\]\{64\}\$'/,
);
});
// --- Task 7: share backend (build payload + share/my_shares/update/delete) ---
test("rpcd script: hub_http_put and hub_http_delete mirror hub_http_post", () => {
assert.match(rpcd, /hub_http_put\(\)\s*\{/);
assert.match(rpcd, /hub_http_delete\(\)\s*\{/);
assert.match(rpcd, /--method=PUT/);
assert.match(rpcd, /--method=DELETE/);
});
test("rpcd script: resolve_font_preset_id reverse-maps struct_font_<slot> via font-presets.conf", () => {
assert.match(rpcd, /resolve_font_preset_id\(\)\s*\{/);
assert.match(
rpcd,
/awk -F'\|' -v s="\$slot" -v k="\$stack" '\$1=="font"&&\$2==s&&\$7==k\s*\{\s*print \$3;\s*exit\s*\}'/,
);
assert.match(rpcd, /echo "default"|printf '%s' "default"/);
});
test("rpcd script: build_share_payload is defined and writes schema/theme", () => {
assert.match(rpcd, /build_share_payload\(\)\s*\{/);
assert.match(rpcd, /json_add_int "schema" 1/);
assert.match(rpcd, /json_add_string "theme" "aurora"/);
});
test("rpcd script: build_share_payload assembles colors/layout/typography from uci", () => {
assert.match(rpcd, /json_add_object "colors"/);
assert.match(rpcd, /json_add_object "layout"/);
assert.match(rpcd, /json_add_object "typography"/);
assert.match(rpcd, /resolve_font_preset_id sans/);
assert.match(rpcd, /resolve_font_preset_id mono/);
});
test("rpcd script: build_share_payload assembles the toolbar from anonymous toolbar_item sections", () => {
assert.ok(
rpcd.includes(
String.raw`uci show aurora 2>/dev/null | sed -n 's/^aurora\.\([^.=]*\)=toolbar_item$/\1/p'`,
),
);
});
test("rpcd script: toolbar url filtering rejects protocol-relative urls but allows http(s)/relative", () => {
const itemBody = extractFunctionBody(rpcd, "toolbar_item_shareable");
assert.match(itemBody, /\/\/\*\)\s*return 1/);
assert.match(itemBody, /http:\/\/\*\|https:\/\/\*\|\/\*\)/);
});
test("rpcd script: build_share_payload skips filenames the theme itself ships", () => {
const body = extractFunctionBody(rpcd, "is_theme_shipped_image");
assert.match(body, /logo\.svg\|favicon\.ico\|app-icon-192x192\.png\|app-icon-512x512\.png\|apple-touch-icon\.png/);
// 默认工具栏那四个也算主题自带:接收方的机器上本来就有,发过去只是浪费,
// 还会让对方用作者那份副本而不是自己已经装好的那份。
assert.match(body, /network\.svg\|overview\.svg\|software\.svg\|system\.svg/);
assert.match(extractFunctionBody(rpcd, "local_image_name"), /is_theme_shipped_image "\$fname" && return 1/);
});
// sha256/size 仍然由路由器算 —— 这是浏览器直传能安全的关键:hub 校验浏览器
// 送上来的字节 hash 等于路由器声明的 hash,所以一个被篡改的页面换不掉这张图。
// base64 那一步没了(见 "emits a filename sidecar" 一测)。
test("rpcd script: build_share_payload computes sha256/size, busybox-safe", () => {
assert.match(rpcd, /sha256sum "\$file" 2>\/dev\/null \| cut -d' ' -f1/);
assert.match(rpcd, /wc -c < "\$file"/);
});
// hub_share/hub_update 是单请求的老路:它们把整张登录背景 base64 塞进一个
// 1.6MB 的 body,而 uclient-fetch 走 TLS 推不动 —— 任何设了登录背景的人都
// 发布不出去。三段式取代了它们,这里钉住"不许回来"。
test("rpcd script: hub_share_begin, hub_share_commit, hub_me, hub_delete handlers exist", () => {
assert.ok(rpcd.includes('"hub_share_begin")'));
assert.ok(rpcd.includes('"hub_share_commit")'));
assert.ok(rpcd.includes('"hub_me")'));
assert.ok(rpcd.includes('"hub_delete")'));
assert.ok(!rpcd.includes('"hub_share")'), "the single-request publish is gone");
assert.ok(!rpcd.includes('"hub_update")'), "the single-request update is gone");
});
test("rpcd script: the hub methods are registered in the list branch", () => {
assert.ok(rpcd.includes('json_add_object "hub_share_begin"'));
assert.ok(rpcd.includes('json_add_object "hub_share_commit"'));
assert.ok(rpcd.includes('json_add_object "hub_me"'));
assert.ok(rpcd.includes('json_add_object "hub_delete"'));
});
test("rpcd script: hub_share_begin validates name/description with has_control_char before use", () => {
const shareBranchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
assert.ok(shareBranchMatch, "hub_share_begin branch should exist");
const body = shareBranchMatch[1];
assert.match(body, /has_control_char\s+"\$name"/);
assert.match(body, /has_control_char\s+"\$description"/);
// The signature is not a publish field any more; hub_set_nickname owns it.
assert.ok(!/\$author/.test(body), "publishing must not handle an author");
});
test("rpcd script: hub_share_begin ensures device identity, builds payload, and opens a draft", () => {
const shareBranchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
assert.ok(shareBranchMatch);
const body = shareBranchMatch[1];
assert.match(body, /ensure_device_identity/);
assert.match(body, /build_share_payload/);
assert.match(body, /device_token/);
assert.match(body, /hub_http_post\s+"\/api\/v1\/themes\/aurora\/configs\/draft"/);
// Nothing is recorded locally: a published id is the hub's to remember, and
// a local copy would survive a key import it no longer belongs to.
assert.ok(!/hub_shares/.test(body), "publishing must not keep a local id list");
});
// The old hub_my_shares walked a local id file and issued one hub request per
// published config on every page load, with an elaborate rule that the file
// must never be rewritten there (a transient failure was indistinguishable
// from a 404, and dropping an id would have been unrecoverable). hub_me
// deletes that whole problem: the hub answers with the config list directly,
// so there is no local record to keep, lose, or wrongly prune.
test("rpcd script: hub_me asks the hub once and keeps no local list", () => {
const branchMatch = rpcd.match(/"hub_me"\)([\s\S]*?)\n\t\t;;/);
assert.ok(branchMatch, "hub_me branch should exist");
const body = branchMatch[1];
assert.match(body, /ensure_device_identity/);
assert.match(body, /device_token/);
assert.match(body, /hub_http_post\s+"\/api\/v1\/me"/);
assert.ok(!/hub_shares/.test(body), "hub_me must not consult a local id list");
assert.ok(
!/while\s+IFS=/.test(body),
"hub_me must be a single request, not a per-id loop",
);
});
// 更新不再有自己的方法:它就是带 target_id 的发布。这条钉住 target_id 的
// 字符集守卫 —— 注意不能写成 ''|*[A-Za-z0-9]*),那个通配只要求"含有"一个
// 字母数字,"ab/../x" 也会过。
test("rpcd script: hub_share_begin guards target_id charset and forwards it", () => {
const branchMatch = rpcd.match(/"hub_share_begin"\)([\s\S]*?)\n\t;;/);
assert.ok(branchMatch);
const body = branchMatch[1];
assert.match(body, /\*\[!A-Za-z0-9\]\*\) echo '\{ "result": 1, "error": "invalid_id" \}'/);
assert.match(body, /"target_id":"%s"/);
assert.match(body, /build_share_payload/);
assert.match(body, /device_token/);
});
test("rpcd script: hub_delete guards id charset and DELETEs with device_token", () => {
const branchMatch = rpcd.match(/"hub_delete"\)([\s\S]*?)\n\t;;/);
assert.ok(branchMatch);
const body = branchMatch[1];
assert.match(body, /case "\$id" in ''\|\*\[!A-Za-z0-9\]\*\)/);
assert.match(body, /hub_http_delete\s+"\/api\/v1\/themes\/aurora\/configs\/\$id"/);
assert.match(body, /device_token/);
// No local list to prune any more -- hub_me re-reads the truth.
assert.ok(!/hub_shares/.test(body), "hub_delete must not maintain a local id list");
});
test("acl: the publish calls are granted under write.ubus; hub_me under read.ubus", () => {
const acljson = JSON.parse(acl);
const writeMethods = acljson["luci-app-aurora"].write.ubus["luci.aurora"];
const readMethods = acljson["luci-app-aurora"].read.ubus["luci.aurora"];
assert.ok(writeMethods.includes("hub_share_begin"));
assert.ok(writeMethods.includes("hub_share_commit"));
assert.ok(writeMethods.includes("hub_delete"));
assert.ok(readMethods.includes("hub_me"));
// 老方法必须一并从 ACL 撤掉,否则一个被删掉的入口还挂在权限表上。
assert.ok(!writeMethods.includes("hub_share"));
assert.ok(!writeMethods.includes("hub_update"));
});
// Note: the fixed /tmp/aurora_hub_share.json path was removed (see the
// "acl: the fixed /tmp/aurora_hub_share.json path is gone" test above) --
// the publish calls write their request bodies via mktemp instead.
// --- Security review round 3: asset path traversal + un-enforced hub schema ---
function extractFunctionBody(source, name) {
const marker = `${name}() {`;
const start = source.indexOf(marker);
assert.ok(start >= 0, `${name}() should be defined`);
let depth = 0;
let i = start + marker.length - 1; // index of the opening brace
for (; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) break;
}
}
return source.slice(start, i + 1);
}
test("rpcd script: local_image_name rejects filenames containing '/' or '..' before ICON_PATH is joined", () => {
const body = extractFunctionBody(rpcd, "local_image_name");
// Guard must appear (and reject) before `$ICON_PATH/$fname` is ever built.
const guardIdx = body.search(/case "\$fname" in \*\/\*\|\*\.\.\*\) return 1 ;; esac/);
const fileIdx = body.indexOf('[ -f "$ICON_PATH/$fname" ]');
assert.ok(guardIdx >= 0, "path-traversal guard on $fname should exist");
assert.ok(fileIdx > guardIdx, "guard must run before ICON_PATH is joined");
// build_share_payload must not have grown a second way to reach ICON_PATH.
const shareBody = extractFunctionBody(rpcd, "build_share_payload");
assert.match(shareBody, /fname=\$\(local_image_name "\$kind"\) \|\| continue/);
});
test("rpcd script: toolbar_item_shareable validates each item against the hub's field limits", () => {
const body = extractFunctionBody(rpcd, "toolbar_item_shareable");
// title: control-char-trimmed then 1-30. strip_control_chars, never
// `tr -d '[:cntrl:]'` -- BusyBox's tr reads that as the literal set
// { [ : c n t r l } and eats those letters out of the title.
assert.match(body, /trimmed_title=\$\(strip_control_chars "\$title"\)/);
assert.match(body, /tlen=\$\{#trimmed_title\}/);
assert.match(body, /\[ "\$tlen" -ge 1 \] && \[ "\$tlen" -le 30 \] \|\| return 1/);
// url: control-char guard, scheme guard, length cap
assert.match(body, /has_control_char "\$url" && return 1/);
assert.match(body, /\[ "\$\{#url\}" -le 200 \] \|\| return 1/);
// icon: control-char guard, length cap, charset
assert.match(body, /has_control_char "\$icon" && return 1/);
assert.match(body, /\[ "\$\{#icon\}" -le 64 \] \|\| return 1/);
assert.match(body, /grep -Eq '\^\[A-Za-z0-9\._-\]\+\$' \|\| return 1/);
// enabled: enum guard
assert.match(body, /case "\$enabled" in 0\|1\) ;; \*\) return 1 ;; esac/);
// 两个消费者,一份规则。分开判就会让一条被打包跳过的快捷方式仍然占掉一个
// 图标槽位号,接收端从那里往后全部错位。
assert.match(extractFunctionBody(rpcd, "build_share_payload"), /toolbar_item_shareable "\$sid" \|\| continue/);
assert.match(extractFunctionBody(rpcd, "nth_custom_toolbar_icon"), /toolbar_item_shareable "\$sid" \|\| continue/);
});
test("rpcd script: build_share_payload falls back to a canonical font stack when struct_font_* fails the hub's regex", () => {
assert.match(rpcd, /is_valid_font_stack\(\)\s*\{/);
assert.match(rpcd, /font_preset_stack\(\)\s*\{/);
const body = extractFunctionBody(rpcd, "build_share_payload");
assert.match(body, /is_valid_font_stack "\$struct_font_sans"/);
assert.match(body, /font_preset_stack sans "\$font_sans_id"/);
assert.match(body, /font_preset_stack sans default/);
assert.match(body, /is_valid_font_stack "\$struct_font_mono"/);
assert.match(body, /font_preset_stack mono "\$font_mono_id"/);
assert.match(body, /font_preset_stack mono default/);
});
test("rpcd script: is_valid_font_stack enforces length, control-char, and charset rules", () => {
assert.match(rpcd, /\[ "\$\{#v\}" -le 200 \] \|\| return 1/);
assert.match(rpcd, /has_control_char "\$v" && return 1/);
});
test("rpcd script: hub_me replaces hub_my_shares", () => {
assert.ok(rpcd.includes('json_add_object "hub_me"'), "hub_me not registered");
assert.ok(!rpcd.includes("hub_my_shares"), "hub_my_shares must be gone");
assert.match(rpcd, /hub_http_post "\/api\/v1\/me"/);
// The local id list is no longer authoritative -- the hub is. Keeping a
// read of it would reintroduce the failure this whole change removes: a
// reflashed router showing an empty "my shares" for work it still owns.
assert.ok(
!rpcd.includes("$DEVICE_DIR/hub_shares"),
"hub_shares must no longer be read or written",
);
});
test("rpcd script: nickname is set through its own write method", () => {
assert.ok(rpcd.includes('"hub_set_nickname")'), "hub_set_nickname handler missing");
assert.match(rpcd, /json_add_string "nickname" "nickname"/);
const branchMatch = rpcd.match(/"hub_set_nickname"\)([\s\S]*?)\n\t\t;;/);
assert.ok(branchMatch, "hub_set_nickname branch should exist");
const body = branchMatch[1];
assert.match(body, /has_control_char\s+"\$nickname"/);
assert.match(body, /hub_http_post\s+"\/api\/v1\/me"/);
// The hub answers 200 even for "that name is taken" -- wget collapses every
// 4xx into the same failure as an unreachable host -- so the body is passed
// through for marketplace.js to turn into a sentence.
assert.match(body, /"result": 0, "data": %s/);
});
test("rpcd script: publishing no longer carries an author", () => {
assert.ok(!rpcd.includes('json_get_var author "author"'), "author still read from the call");
assert.ok(!rpcd.includes("invalid_author"), "stale author validation");
assert.ok(!/"author":"%s"/.test(rpcd), "author still sent to the hub");
});
test("rpcd script: key export/import exist and validate the shape", () => {
assert.ok(rpcd.includes('"hub_export_key")'), "hub_export_key handler missing");
assert.ok(rpcd.includes('"hub_import_key")'), "hub_import_key handler missing");
// Same rule as the hub's TOKEN_PATTERN: exactly 64 lowercase hex.
assert.match(rpcd, /\[ "\$\{#key\}" -eq 64 \]/);
assert.match(rpcd, /\*\[!a-f0-9\]\*\)/);
assert.match(rpcd, /chmod 600 "\$DEVICE_DIR\/device\.key\.tmp"/);
// Both "the key now lives somewhere else" paths record the flag in rpcd:
// marketplace.js is a browse-only view with no uci write path of its own.
assert.ok(
rpcd.split("hub_key_saved").length - 1 >= 2,
"both export and import must record hub_key_saved",
);
});
test("acl: exporting the key is a write capability, never a read one", () => {
const parsed = JSON.parse(acl);
const { read, write } = parsed["luci-app-aurora"];
const readable = read.ubus["luci.aurora"];
const writable = write.ubus["luci.aurora"];
// Handing out device.key hands out account control -- publishing as this
// creator, and deleting their work. Read access must not reach it.
assert.ok(!readable.includes("hub_export_key"), "hub_export_key must not be readable");
assert.ok(writable.includes("hub_export_key"), "hub_export_key missing from write");
assert.ok(writable.includes("hub_import_key"), "hub_import_key missing from write");
});
test("acl: hub_me is readable and the stale hub_my_shares entry is gone", () => {
const parsed = JSON.parse(acl);
const read = parsed["luci-app-aurora"].read.ubus["luci.aurora"];
assert.ok(read.includes("hub_me"), "hub_me missing from read");
assert.ok(!read.includes("hub_my_shares"), "stale hub_my_shares in acl");
});
// --- A share the hub has already dropped is not an unreachable hub ---
//
// Measured on the device against the live hub: DELETE on an id the hub has
// already removed answers 404 -- uclient-fetch exits 8 and prints
// "HTTP error 404" on stderr; a host it cannot reach exits 4 and prints
// "Failed to send request". `-q` silences both lines, and the exit code alone
// lumps every error status together, so the helpers could only ever report
// hub_unreachable -- telling a user the store is down when their share is
// simply gone.
test("rpcd script: hub_http_gone reads the 404 off uclient-fetch's stderr", () => {
const body = extractFunctionBody(rpcd, "hub_http_gone");
assert.match(body, /HTTP error 404/, "the status line is the only 404 signal");
});
for (const fn of ["hub_http_put", "hub_http_delete"]) {
test(`rpcd script: ${fn} tells a share the hub dropped apart from an unreachable hub`, () => {
const body = extractFunctionBody(rpcd, fn);
assert.ok(!/wget -qO/.test(body), "-q swallows the line that names the status");
assert.match(body, /2>"\$err"/, "stderr must be kept, not discarded");
assert.match(body, /hub_http_gone "\$err"/, "the status line must be consulted");
assert.match(body, /rc=2/, "a dropped share needs a return code of its own");
});
}
test("rpcd script: hub_delete answers invalid_id, not hub_unreachable, when the share is gone", () => {
const branchMatch = rpcd.match(/"hub_delete"\)([\s\S]*?)\n\t;;/);
assert.ok(branchMatch);
const body = branchMatch[1];
assert.match(
body,
/\n\t\t\t2\)[\s\S]{0,400}?"error": "invalid_id"/,
"return code 2 must map to invalid_id",
);
assert.match(body, /\*\)[\s\S]{0,200}?"error": "hub_unreachable"/);
});
test("rpcd script: hub_share_commit answers invalid_id, not hub_unreachable, when the draft is gone", () => {
// Sliced to the next branch, not by the `\n\t;;` the other tests use: that
// one is lazy but the nearest single-tab `;;` is the end of the outer case,
// so it swallows the following branch and would go green on its fix alone.
const start = rpcd.indexOf('"hub_share_commit")');
const end = rpcd.indexOf('"hub_me")', start);
assert.ok(start > 0 && end > start, "hub_share_commit branch not found");
const body = rpcd.slice(start, end);
assert.match(
body,
/\n\t\t\t2\)[\s\S]{0,400}?"error": "invalid_id"/,
"return code 2 must map to invalid_id",
);
});
// --- The same 404 blindness on the read path ---
//
// hub_http_get goes through fetch_url, which is shared with asset downloads
// and is -q for their sake. So applying a theme the store had already taken
// down reported hub_unreachable too, and the store page told the user to check
// their connection over a theme that was simply gone.
test("rpcd script: fetch_url stays quiet unless the caller asks to keep stderr", () => {
const body = extractFunctionBody(rpcd, "fetch_url");
assert.match(body, /FETCH_URL_ERR/, "callers need a way to opt into stderr");
// The other caller is the asset download loop; it must not start spraying
// uclient-fetch progress meters into rpcd's stderr.
assert.match(body, /\/dev\/null/, "discarding stderr stays the default");
assert.match(body, /-q/, "quiet stays the default");
});
test("rpcd script: hub_http_get tells a theme the store dropped apart from an unreachable hub", () => {
const body = extractFunctionBody(rpcd, "hub_http_get");
assert.match(body, /FETCH_URL_ERR/, "stderr must be kept for this one call");
assert.match(body, /hub_http_gone "\$err"/, "the status line must be consulted");
assert.match(body, /rc=2/, "a dropped theme needs a return code of its own");
});
test("rpcd script: the apply worker reports invalid_id when the store no longer has the theme", () => {
const body = extractFunctionBody(rpcd, "hub_apply_worker");
assert.match(
body,
/"error" "fetch" "invalid_id"/,
"a 404 on fetch must not be reported as hub_unreachable",
);
assert.match(body, /"error" "fetch" "hub_unreachable"/, "the network case must survive");
});
// §8.2 drifted silently: it still listed hub_list, hub_get and hub_my_shares
// long after they were deleted, and carried an `author` param that hub_share
// and hub_update never took. Nothing checked it, so nothing stopped it. This
// pins the table to the acl -- the one file that decides what is callable.
test("docs: the marketplace method table stays in step with the acl", () => {
const docs = readFileSync(path.join(repoRoot, "docs/DEVELOPMENT.md"), "utf8");
const section = docs.slice(docs.indexOf("### 8.2 "), docs.indexOf("### 8.3 "));
const documented = [...section.matchAll(/^\| `([a-z_]+)/gm)]
.map((m) => m[1])
.sort();
const parsed = JSON.parse(acl)["luci-app-aurora"];
const exposed = [
...parsed.read.ubus["luci.aurora"],
...parsed.write.ubus["luci.aurora"],
]
.filter((m) => /^(hub_|get_hub_)/.test(m))
.sort();
assert.deepEqual(documented, exposed);
});
// 浏览器直传之后,路由器不再需要把 1.2MB 的图 base64 塞进 JSON —— 实测
// uclient-fetch 走 TLS 推不动那么大的 body(512KB 三次挂一次,1MB 必断)。
// 它只需要告诉浏览器每个 kind 对应哪个本机文件。
//
// 第二列是相对 /luci-static/aurora/ 的路径而不是裸文件名:图片在 images/,
// 字体在 fonts/custom/,而 hub_share_begin 只会给这一列加前缀。
test("build_share_payload: emits a path sidecar, not base64", () => {
assert.match(rpcd, /local out_file="\$1" files_file="\$\{1\}\.files"/);
assert.match(rpcd, /printf '%s\\timages\/%s\\n' "\$kind" "\$fname" >> "\$files_file"/);
assert.match(
rpcd,
/printf '%s\\tfonts\/custom\/%s\.woff2\\n' "font_\$slot" "\$base" >> "\$files_file"/,
);
assert.match(rpcd, /json_add_string "src" "\/luci-static\/aurora\/\$a_fname"/);
assert.ok(!/base64 -w0/.test(rpcd), "base64 encoding should be gone");
assert.ok(!/data_b64/.test(rpcd), "data_b64 should be gone from the rpcd script");
});
// 分享一份用了自定义字体的配置,却只发字体栈不发字节 —— 收到的人那台路由器上
// 根本没有那个字体族,浏览器静默回退到栈尾的 Lato:配色布局全对、字体没变,而
// 商店详情页还明明白白写着那个字体名。真机上抓到过这个现场(struct_font_sans
// 指着 "OPPO Sans Regular",aurora-font.css 里根本没有它的 @font-face)。
test("build_share_payload: uploaded custom fonts travel with the config", () => {
assert.match(rpcd, /json_add_string "kind" "font_\$slot"/);
// 名册预设不上传:接收端 sync_and_cache_fonts_from_uci 自己从固定源下载
// 并校验,再传一份既浪费带宽也绕开了那份校验。
assert.match(
rpcd,
/shared_custom_font_base\(\)[\s\S]*?get_font_preset_by_stack "\$slot" "\$stack" >\/dev\/null 2>&1 && return 1/,
);
});
// 界面上说要发哪一份字体,和线路上真正打包哪一份,必须是同一个判断 —— 两处
// 各写一遍迟早漂移成"清单里写着、实际没发"。
test("the share manifest and the packer ask the same function", () => {
const callers = rpcd.match(/shared_custom_font_base "\$slot"/g) || [];
assert.ok(
callers.length >= 2,
"both build_share_payload and hub_local_state should call it",
);
assert.match(rpcd, /json_add_array "shared_fonts"/);
});
// 一律报 hub_unreachable 是"发布失败看起来像商店挂了"的全部原因:-q 把
// uclient-fetch 唯一会说出理由的那一行也吞了。hub_http_put/delete 一直会
// 区分 404,post 只是没跟上。
test("hub_http_post distinguishes a rejection from an outage", () => {
assert.match(rpcd, /hub_http_post\(\)\s*\{[\s\S]*?2>"\$err"/);
assert.match(rpcd, /HUB_HTTP_STATUS=/);
assert.ok(
!/wget -qO "\$out" --timeout=12/.test(rpcd),
"-q swallowed the reason uclient-fetch prints",
);
});
// 三段式发布:路由器建草稿拿票据 -> 浏览器直传字节 -> 路由器提交。
// 中间那段不经过路由器,因为 uclient-fetch 走 TLS 推不动大 body。
test("rpcd exposes the three-step publish", () => {
for (const m of ["hub_share_begin", "hub_share_commit"]) {
assert.match(rpcd, new RegExp(`json_add_object "${m}"`), `${m} missing from list`);
assert.ok(rpcd.includes(`"${m}")`), `${m} missing from call`);
assert.ok(acl.includes(`"${m}"`), `${m} missing from ACL write list`);
}
});
test("hub_share_begin hands out local asset URLs, never the device token", () => {
assert.match(rpcd, /\/luci-static\/aurora\/images\//);
const begin = rpcd.slice(rpcd.indexOf('"hub_share_begin")'));
const body = begin.slice(0, begin.indexOf("\n\t\t;;"));
assert.match(body, /configs\/draft/);
// 拼回复的那一段里不能出现 DEVICE_TOKEN
const reply = body.slice(body.indexOf("json_add_array \"assets\""));
assert.ok(!/DEVICE_TOKEN/.test(reply), "the device token must never reach the response");
});
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
// jshn 只存在于 OpenWrt 上,所以这里不 source 整个脚本,而是把待测函数切出来配桩跑。
// 桩把每次调用打成一行,断言比对的是「发出了什么」,不是「源码长什么样」—— 这样才能
// 抓住管道子 shell 那类陷阱:源码看着对,jshn 的写入却全丢在退出的子进程里。
const STUBS = `
json_add_array() { echo "ARRAY $1"; }
json_add_string() { echo "NAME $2"; }
json_close_array() { echo "/ARRAY"; }
json_add_object() { echo "OBJ $1"; }
json_add_int() { echo "SIZE $1=$2"; }
json_close_object(){ echo "/OBJ"; }
`;
async function runJsonAddIcons(iconPath) {
const src = await readFile(RPCD, "utf8");
const start = src.indexOf("json_add_icons() {");
assert.ok(start !== -1, "json_add_icons not found");
const end = src.indexOf("\n}\n", start) + 3;
const script = `ICON_PATH="${iconPath}"\n${STUBS}\n${src.slice(start, end)}\njson_add_icons\n`;
return execFileSync("sh", ["-c", script], { encoding: "utf8" });
}
function fixture(files) {
const dir = mkdtempSync(join(tmpdir(), "aurora-icons-"));
for (const [name, bytes] of Object.entries(files))
writeFileSync(join(dir, name), "x".repeat(bytes));
return dir;
}
test("json_add_icons emits every file's name and byte size", async () => {
// 文件名里可以有空格:receive_upload 只拒绝空串、含 / 和含 .. 的名字。
const dir = fixture({ "favicon.ico": 3, "app icon.png": 7, "empty.svg": 0 });
try {
const out = await runJsonAddIcons(dir);
const names = [...out.matchAll(/^NAME (.+)$/gm)].map((m) => m[1]).sort();
assert.deepEqual(names, ["app icon.png", "empty.svg", "favicon.ico"]);
const sizes = Object.fromEntries(
[...out.matchAll(/^SIZE (.+)=\s*(\d+)$/gm)].map((m) => [m[1], Number(m[2])]),
);
assert.deepEqual(sizes, { "favicon.ico": 3, "app icon.png": 7, "empty.svg": 0 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("json_add_icons survives an empty directory, a missing one, and a lone file", async () => {
const empty = mkdtempSync(join(tmpdir(), "aurora-icons-"));
const one = fixture({ "a.png": 2 });
const sub = fixture({ "b.png": 4 });
mkdirSync(join(sub, "nested"));
try {
for (const dir of [empty, join(empty, "does-not-exist")]) {
const out = await runJsonAddIcons(dir);
assert.ok(!out.includes("NAME "), `${dir} should list nothing`);
assert.ok(!out.includes("SIZE "), `${dir} should size nothing`);
}
// 单文件时 wc 不打印 total 行 —— 解析必须两种情况都对。
assert.match(await runJsonAddIcons(one), /^SIZE a\.png=\s*2$/m);
// 子目录既不进 icons 数组,也不进 icon_sizes。
const out = await runJsonAddIcons(sub);
assert.ok(!out.includes("nested"), "directories must not be listed as icons");
} finally {
for (const d of [empty, one, sub]) rmSync(d, { recursive: true, force: true });
}
});
test("json_add_icons calls wc once for the whole directory, not once per file", async () => {
const src = await readFile(RPCD, "utf8");
const start = src.indexOf("json_add_icons() {");
const body = src.slice(start, src.indexOf("\n}\n", start));
assert.ok(
!/wc -c < /.test(body),
"per-file `wc -c < file` is one fork per icon on a device where forks are the cost",
);
assert.ok(
!/\|\s*while /.test(body),
"piping into while runs the loop in a subshell and every json_add_* write is lost",
);
});
test("the package manager is detected once per rpcd invocation", async () => {
const src = await readFile(RPCD, "utf8");
assert.ok(
!/\$\(detect_package_manager\)/.test(src),
"command substitution is a fork; callers must read $_pkg_manager instead",
);
assert.match(src, /_pkg_manager=""/, "the memo variable must exist at script scope");
assert.match(
src,
/detect_package_manager\(\) \{\s*\n\s*\[ -n "\$_pkg_manager" \] && return/,
"detect_package_manager must short-circuit on the memo",
);
});
@@ -0,0 +1,193 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";
import { repo } from "./paths.mjs";
// 主界面背景(main_bg)与 login_bg 共用一条资产管线。可切片的纯函数走行为
// 断言(同 rpcd-share-assets 的 slice 手法);与 jshn/网络纠缠的段落退化为
// 文本断言——它们守的是"哪一行必须存在",不是排序或数值。
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
function slice(name) {
const start = rpcd.indexOf(`${name}() {`);
assert.ok(start !== -1, `${name} not found in rpcd script`);
const end = rpcd.indexOf("\n}\n", start) + 3;
return rpcd.slice(start, end);
}
function runSh(script, env = {}) {
return execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
// ---------------------------------------------------------------------------
// is_valid_main_bg_value —— share 与 apply 两端共用的界内校验
// ---------------------------------------------------------------------------
const boundsCases = [
["struct_main_bg_alpha", "67%", true],
["struct_main_bg_alpha", "50%", true],
["struct_main_bg_alpha", "100%", true],
["struct_main_bg_alpha", "49%", false],
["struct_main_bg_alpha", "101%", false],
["struct_main_bg_alpha", "67", false],
["struct_main_bg_blur", "20px", true],
["struct_main_bg_blur", "0px", true],
["struct_main_bg_blur", "40px", true],
["struct_main_bg_blur", "41px", false],
["struct_main_bg_blur", "20%", false],
["struct_main_bg_scrim", "20%", true],
["struct_main_bg_scrim", "0%", true],
["struct_main_bg_scrim", "70%", true],
["struct_main_bg_scrim", "71%", false],
["struct_main_bg_scrim", "-1%", false],
["struct_login_bg_alpha", "100%", true],
["struct_login_bg_alpha", "49%", false],
["struct_login_bg_blur", "0px", true],
["struct_login_bg_blur", "41px", false],
["struct_login_bg_scrim", "0%", true],
["struct_login_bg_scrim", "71%", false],
];
test("is_valid_main_bg_value enforces the hub's bounds on both ends", () => {
for (const [key, value, ok] of boundsCases) {
const out = runSh(
slice("is_valid_main_bg_value") +
`\nis_valid_main_bg_value "${key}" "${value}" && echo OK || echo NO\n`,
).trim();
assert.equal(out, ok ? "OK" : "NO", `${key}=${value}`);
}
});
// ---------------------------------------------------------------------------
// local_image_name —— main_bg 从 struct_main_bg 的 url() 里剥文件名
// ---------------------------------------------------------------------------
test("local_image_name resolves main_bg from its url() wrapper", () => {
const stub = `
is_theme_shipped_image() { return 1; }
uci() {
[ "$3" = "aurora.theme.struct_main_bg" ] && {
printf "url('/luci-static/aurora/images/wall.jpg')\\n"; return 0; }
return 1
}
`;
const out = runSh(
stub +
slice("local_image_name") +
`\nICON_PATH="$TMPDIR_ICONS"\nlocal_image_name main_bg || echo NONE\n`,
{ TMPDIR_ICONS: process.cwd() },
).trim();
// 文件不存在于 ICON_PATH → NONE;但走到存在性检查前必须已剥出文件名
// (路径逃逸值则在更早一步被拒)。这里断言的是"main_bg 不再落到
// 非 bg 的 uci get 分支"——旧代码里它会去读 aurora.theme.main_bg。
assert.equal(out, "NONE");
});
test("local_image_name rejects a traversal value in struct_main_bg", () => {
const stub = `
is_theme_shipped_image() { return 1; }
uci() {
[ "$3" = "aurora.theme.struct_main_bg" ] && {
printf "url('/luci-static/aurora/images/../../etc/aurora/device.key')\\n"; return 0; }
return 1
}
`;
const out = runSh(
stub + slice("local_image_name") + `\nlocal_image_name main_bg || echo NONE\n`,
).trim();
assert.equal(out, "NONE");
});
// ---------------------------------------------------------------------------
// kind 表成员资格(纯函数,行为断言)
// ---------------------------------------------------------------------------
test("hub asset tables carry main_bg like login_bg", () => {
const script =
slice("hub_asset_ext_ok") +
slice("hub_asset_target_name") +
`
hub_asset_ext_ok main_bg jpg && echo EXT_JPG
hub_asset_ext_ok main_bg png && echo EXT_PNG
hub_asset_ext_ok main_bg svg || echo EXT_NO_SVG
hub_asset_target_name main_bg 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef jpg
echo
`;
const out = runSh(script).trim().split("\n");
assert.deepEqual(out, [
"EXT_JPG",
"EXT_PNG",
"EXT_NO_SVG",
"hub-main-bg-0123456789ab.jpg",
]);
});
// ---------------------------------------------------------------------------
// 文本断言:与 jshn/网络纠缠的段落,守"哪一行必须存在"
// ---------------------------------------------------------------------------
test("build_share_payload ships main_bg and the three tunables", () => {
const fn = slice("build_share_payload");
assert.match(fn, /for kind in logo_svg .* login_bg main_bg; do/);
// 三键循环 + 界内才发(越界静默缺席,同字体栈回退姿势)
assert.match(fn, /for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do/);
assert.match(fn, /is_valid_main_bg_value "\$key" "\$value"/);
});
test("validate_and_apply validates the tunables and parks them for the asset step", () => {
const fn = slice("validate_and_apply_hub_payload");
assert.match(fn, /for key in struct_main_bg_alpha struct_main_bg_blur struct_main_bg_scrim struct_login_bg_alpha struct_login_bg_blur struct_login_bg_scrim; do/);
assert.match(fn, /is_valid_main_bg_value "\$key" "\$value" \|\| \{ rm -f "\$batch_file"; return 1; \}/);
assert.match(fn, /HUB_BG_TUNABLES="\$HUB_BG_TUNABLES\$key=\$value/);
});
test("the tunables land only for a background this apply actually replaced", () => {
const fn = slice("apply_hub_bg_tunables");
assert.match(fn, /uci -q set "aurora\.theme\.\$key=\$value"/);
assert.match(fn, /uci -q delete "aurora\.theme\.\$key"/);
assert.match(fn, /struct_main_bg_\*\)\n\s*case "\$written" in \*" main_bg "\*\)/);
assert.match(fn, /struct_login_bg_\*\)\n\s*case "\$written" in \*" login_bg "\*\)/);
});
test("apply_hub_asset writes struct_main_bg's url() and drops the stale lqip", () => {
const fn = slice("apply_hub_asset");
assert.match(fn, /struct_main_bg="url\('\/luci-static\/aurora\/images\/\$name'\)"/);
assert.match(fn, /delete aurora\.theme\.struct_main_bg_lqip/);
// login_bg 同款清理:换图后旧 LQIP 指着换掉前的图,留着会闪错图
assert.match(fn, /delete aurora\.theme\.struct_login_bg_lqip/);
});
test("sweep keeps a live main_bg and collects the hub-main-bg prefix", () => {
const fn = slice("sweep_hub_images");
assert.match(fn, /struct_main_bg/);
assert.match(fn, /hub-main-bg-\*/);
});
test("remove_icon clears a deleted image out of struct_main_bg", () => {
assert.match(rpcd, /current_main_bg=\$\(uci -q get aurora\.theme\.struct_main_bg\)/);
assert.match(rpcd, /delete aurora\.theme\.struct_main_bg\b/);
});
test("the three tunables enter the theme fingerprint; the filename key stays out", () => {
const fn = slice("theme_fingerprint");
assert.match(fn, /struct_main_bg_alpha=/);
assert.match(fn, /struct_main_bg_blur=/);
assert.match(fn, /struct_main_bg_scrim=/);
assert.match(fn, /struct_login_bg_alpha=/);
assert.match(fn, /struct_login_bg_scrim=/);
// struct_main_bg=(文件名)绝不能进指纹——匹配到 alpha/blur/scrim 之外的
// 裸 struct_main_bg= 即失败
assert.doesNotMatch(fn, /struct_main_bg=(?!alpha|blur|scrim)/);
});
test("hub_local_state's image manifest asks about main_bg too", () => {
// 3378 行附近的第二份 kind 列表(发布前 manifest)与打包用同一列表
const lists = rpcd.match(/for kind in logo_svg [^\n]*login_bg[^\n]*; do/g) ?? [];
assert.ok(lists.length >= 2, "expected both kind lists");
for (const line of lists) assert.match(line, /main_bg/);
});
@@ -0,0 +1,378 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { repo } from "./paths.mjs";
const RPCD = repo("root/usr/libexec/rpcd/luci.aurora");
const rpcd = readFileSync(RPCD, "utf8");
const acl = readFileSync(
repo("root/usr/share/rpcd/acl.d/luci-app-aurora.json"),
"utf8",
);
// 把待测函数从脚本里切出来单跑:整份脚本依赖 jshn 和 uci,两者都只存在于
// OpenWrt 上。切片 + 打桩测的是「这个函数拿什么、扔掉什么、按什么顺序」,而
// 不是「源码长什么样」—— 排序和过滤这类逻辑,文本断言根本抓不住。
function slice(name) {
const start = rpcd.indexOf(`${name}() {`);
assert.ok(start !== -1, `${name} not found in rpcd script`);
const end = rpcd.indexOf("\n}\n", start) + 3;
return rpcd.slice(start, end);
}
// md5sum 是 busybox 自带的,macOS 上没有;用 cksum 替身。测的是喂进哈希前的那
// 串东西是否稳定,哈希算法本身不是被测对象。
const HASH_STUB = `md5sum() { cksum; }\n`;
// uci 的最小替身:show 打印固定夹具,get 从夹具里查,set/commit 记到 $UCI_LOG。
const UCI_STUB = `
uci() {
case "$2" in
show) printf '%s' "$UCI_OUT" ;;
get) printf '%s' "$UCI_OUT" | sed -n "s/^$3='\\(.*\\)'\\$/\\1/p" ;;
set) printf 'SET %s\\n' "$3" >> "$UCI_LOG" ;;
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
esac
}
`;
const BASE =
[
"aurora.theme.light_bg='#ffffff'",
"aurora.theme.dark_bg='#101114'",
"aurora.theme.nav_type='sidebar'",
"aurora.theme.struct_font_sans='\"Nunito\", sans-serif'",
"aurora.theme.struct_font_mono='ui-monospace'",
"aurora.theme.struct_spacing='0.3rem'",
"aurora.theme.struct_radius_base='0.875rem'",
"aurora.theme.struct_content_width_centered='92rem'",
"aurora.theme.toolbar_enabled='1'",
].join("\n") + "\n";
function runFingerprint(uciShowOutput) {
const script =
`uci() { printf '%s' "$UCI_OUT"; }\n` +
HASH_STUB +
slice("theme_fingerprint") +
"\ntheme_fingerprint\n";
return execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, UCI_OUT: uciShowOutput },
}).trim();
}
function runShell(body, uciShowOutput) {
const dir = mkdtempSync(join(tmpdir(), "aurora-one-slot-"));
const log = join(dir, "uci.log");
const script =
`UCI_LOG="${log}"\n: > "$UCI_LOG"\n` +
UCI_STUB +
HASH_STUB +
slice("theme_fingerprint") +
slice("normalize_preset_name") +
slice("theme_modified") +
slice("ensure_active_source") +
slice("record_active_source") +
body +
`\ncat "$UCI_LOG"\n`;
try {
return execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, UCI_OUT: uciShowOutput },
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
// ---------------------------------------------------------------------------
// theme_fingerprint
test("theme_fingerprint: uci 输出顺序不影响结果", () => {
const shuffled = BASE.trim().split("\n").reverse().join("\n") + "\n";
assert.equal(runFingerprint(BASE), runFingerprint(shuffled));
});
test("theme_fingerprint: 簿记项与资产项不进指纹", () => {
const noise =
BASE +
[
"aurora.theme.active_source='hub:abc'",
"aurora.theme.active_fingerprint='deadbeef'",
"aurora.theme.active_preset='sage-green'",
"aurora.theme.hub_applied='test'",
"aurora.theme.icon_cache_version='1754400000'",
"aurora.theme.hub_key_saved='1'",
"aurora.theme.logo_svg='logo.svg'",
"aurora.theme.favicon_png='favicon.png'",
"aurora.theme.struct_login_bg='url(/x.png)'",
].join("\n") +
"\n";
assert.equal(runFingerprint(BASE), runFingerprint(noise));
});
test("theme_fingerprint: 外观键任何一项变化都改变指纹", () => {
const base = runFingerprint(BASE);
for (const [from, to] of [
["light_bg='#ffffff'", "light_bg='#fefefe'"],
["dark_bg='#101114'", "dark_bg='#000000'"],
["nav_type='sidebar'", "nav_type='mega-menu'"],
["struct_spacing='0.3rem'", "struct_spacing='0.5rem'"],
["struct_radius_base='0.875rem'", "struct_radius_base='0rem'"],
[
"struct_content_width_centered='92rem'",
"struct_content_width_centered='80rem'",
],
["toolbar_enabled='1'", "toolbar_enabled='0'"],
["struct_font_mono='ui-monospace'", "struct_font_mono='Menlo'"],
]) {
assert.notEqual(
runFingerprint(BASE.replace(from, to)),
base,
`${from} 未改变指纹`,
);
}
});
test("theme_fingerprint: toolbar_item 段不进指纹", () => {
// 用户加一个快捷方式不该被判成「改过主题」—— 预设根本没碰它。
const withItems =
BASE +
"aurora.@toolbar_item[0].title='Luci'\naurora.@toolbar_item[0].url='/x'\n";
assert.equal(runFingerprint(BASE), runFingerprint(withItems));
});
// ---------------------------------------------------------------------------
// theme_modified / ensure_active_source / record_active_source
test("theme_modified: 指纹相符时返回 1(不是我的)", () => {
const uci = BASE + `aurora.theme.active_fingerprint='${runFingerprint(BASE)}'\n`;
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, uci), /THEIRS/);
});
test("theme_modified: 指纹不符时返回 0(是我的)", () => {
const uci = BASE + "aurora.theme.active_fingerprint='0000000000000000'\n";
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, uci), /MINE/);
});
test("theme_modified: 没有记录指纹时判为「我的」", () => {
assert.match(runShell(`theme_modified && echo MINE || echo THEIRS`, BASE), /MINE/);
});
test("ensure_active_source: 已有值时什么都不写", () => {
const uci = BASE + "aurora.theme.active_source='hub:abc123'\n";
assert.doesNotMatch(runShell(`ensure_active_source`, uci), /SET/);
});
test("ensure_active_source: 老配置带 active_preset 时迁成 builtin: 并补指纹", () => {
const uci = BASE + "aurora.theme.active_preset='sage-green'\n";
const out = runShell(`ensure_active_source`, uci);
assert.match(out, /SET aurora\.theme\.active_source=builtin:sage-green/);
assert.match(out, /SET aurora\.theme\.active_fingerprint=\S/);
assert.match(out, /COMMIT/);
});
test("ensure_active_source: classic 归一成 default", () => {
const uci = BASE + "aurora.theme.active_preset='classic'\n";
assert.match(
runShell(`ensure_active_source`, uci),
/SET aurora\.theme\.active_source=builtin:default/,
);
});
test("ensure_active_source: 什么都没有时落到 builtin:default", () => {
assert.match(
runShell(`ensure_active_source`, BASE),
/SET aurora\.theme\.active_source=builtin:default/,
);
});
test("ensure_active_source: 老配置带 hub_applied 时降级为 mine 且清空指纹", () => {
// hub_applied 只存名字不存 id,反查不到商店条目。降级成 mine + 空指纹是正确
// 的:那确实是一份不对应任何卡的配置,它会显示为「我的配置」并持有钩子。
const uci = BASE + "aurora.theme.hub_applied='test'\n";
const out = runShell(`ensure_active_source`, uci);
assert.match(out, /SET aurora\.theme\.active_source=mine/);
assert.match(out, /SET aurora\.theme\.active_fingerprint=\s*$/m);
});
test("record_active_source: 写来源与当前指纹,一次 commit", () => {
const out = runShell(`record_active_source "hub:abc123"`, BASE);
assert.match(out, /SET aurora\.theme\.active_source=hub:abc123/);
assert.match(out, /SET aurora\.theme\.active_fingerprint=\S/);
assert.match(out, /COMMIT/);
});
// ---------------------------------------------------------------------------
// backup_if_mine
function runBackup(uciShowOutput) {
const dir = mkdtempSync(join(tmpdir(), "aurora-backup-"));
const deviceDir = join(dir, "device");
const configFile = join(dir, "aurora.conf");
mkdirSync(deviceDir);
mkdirSync(join(deviceDir, "pre-hub-images"));
writeFileSync(join(deviceDir, "pre-hub-images", "logo.svg"), "old-logo");
writeFileSync(configFile, "config theme 'theme'\n");
const script =
`DEVICE_DIR="${deviceDir}"\nCONFIG_FILE="${configFile}"\n` +
`uci() { case "$2" in show) printf '%s' "$UCI_OUT" ;; get) printf '%s' "$UCI_OUT" | sed -n "s/^$3='\\(.*\\)'\\$/\\1/p" ;; esac; }\n` +
HASH_STUB +
slice("theme_fingerprint") +
slice("theme_modified") +
slice("backup_if_mine") +
`\nbackup_if_mine\necho "TAKEN=$THEME_BACKUP_TAKEN"\n`;
const out = execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, UCI_OUT: uciShowOutput },
});
const result = {
taken: /TAKEN=1/.test(out),
confExists: existsSync(join(deviceDir, "pre-hub-backup.conf")),
imagesExist: existsSync(join(deviceDir, "pre-hub-images", "logo.svg")),
};
rmSync(dir, { recursive: true, force: true });
return result;
}
test("backup_if_mine: 配置是我的时候留底,并清空上一次的图片备份", () => {
const r = runBackup(BASE + "aurora.theme.active_fingerprint='0000'\n");
assert.equal(r.taken, true);
assert.equal(r.confExists, true);
assert.equal(r.imagesExist, false, "旧图片备份必须清掉,否则回滚会拿到上上次的图");
});
test("backup_if_mine: 配置来自商店时不留底,且原封不动保住已有备份", () => {
// 这一条是整个「一个槽位」能成立的关键:在商店里连点几个主题试看,你自己那
// 份配置必须一直留在备份槽里。
const fp = runFingerprint(BASE);
const r = runBackup(BASE + `aurora.theme.active_fingerprint='${fp}'\n`);
assert.equal(r.taken, false);
assert.equal(r.confExists, false);
assert.equal(r.imagesExist, true, "别人的主题不该冲掉你自己的图片备份");
});
// ---------------------------------------------------------------------------
// apply 路径接线
test("rpcd script: 两条 apply 路径都过 backup_if_mine,且旧的无条件 cp 已经消失", () => {
assert.match(rpcd, /hub_apply_worker\(\)[\s\S]*?backup_if_mine/);
assert.match(rpcd, /"apply_theme_preset"\)[\s\S]*?backup_if_mine/);
// 断言范围是 worker 自己:backup_if_mine 内部当然还有那行 cp,它现在是有
// 条件的那一份。
assert.doesNotMatch(
slice("hub_apply_worker"),
/cp "\$CONFIG_FILE" "\$DEVICE_DIR\/pre-hub-backup\.conf"/,
"worker 里的无条件备份必须由 backup_if_mine 取代",
);
});
test("rpcd script: apply_hub_asset 的图片备份受 THEME_BACKUP_TAKEN 闸门约束", () => {
assert.match(slice("apply_hub_asset"), /THEME_BACKUP_TAKEN/);
});
test("rpcd script: 两条 apply 路径都在成功收尾时记录来源", () => {
assert.match(rpcd, /record_active_source "hub:\$id"/);
assert.match(rpcd, /record_active_source "builtin:\$name"/);
});
test("rpcd script: 指纹在资产落盘之后才记录", () => {
// 顺序是正确性的一部分:hub 的资产是在色值那批 uci batch 之后单独 commit 的,
// 指纹若抢在前面算,刚应用完的主题立刻就会读回「已修改」。
const worker = slice("hub_apply_worker");
assert.ok(
worker.indexOf("apply_hub_asset") < worker.indexOf("record_active_source"),
"record_active_source 必须排在资产应用之后",
);
});
// ---------------------------------------------------------------------------
// json_add_config_preview / hub_local_state
const JSHN_STUBS = `
json_add_object() { echo "OBJ $1"; }
json_close_object(){ echo "/OBJ"; }
json_add_string() { echo "STR $1=$2"; }
`;
const PREVIEW_UCI =
"aurora.theme.light_bg='#ffffff'\n" +
"aurora.theme.dark_bg='#101114'\n" +
"aurora.theme.nav_type='sidebar'\n" +
"aurora.theme.struct_spacing='0.3rem'\n" +
"aurora.theme.struct_radius_base='0.875rem'\n" +
"aurora.theme.struct_content_width_centered='92rem'\n" +
"aurora.theme.toolbar_enabled='1'\n" +
"aurora.theme.struct_font_sans='\"Nunito\", sans-serif'\n" +
"aurora.theme.struct_font_mono='ui-monospace'\n";
function runConfigPreview() {
const dir = mkdtempSync(join(tmpdir(), "aurora-preview-"));
const conf = join(dir, "saved.conf");
writeFileSync(conf, "config theme 'theme'\n");
// uci -c <dir> show/get 的替身:show 吐夹具,get 从夹具里查。
const script =
`uci() {
case "$4" in
show) printf '%s' "$UCI_OUT" ;;
get) printf '%s' "$UCI_OUT" | sed -n "s/^$5='\\(.*\\)'\\$/\\1/p" ;;
esac
}\n` +
JSHN_STUBS +
slice("json_add_config_preview") +
`\njson_add_config_preview "${conf}"\n`;
const out = execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, UCI_OUT: PREVIEW_UCI },
});
rmSync(dir, { recursive: true, force: true });
return out;
}
test("json_add_config_preview: 投影出 colors/layout/typography 三段", () => {
const out = runConfigPreview();
assert.match(out, /OBJ colors/);
assert.match(out, /STR light_bg=#ffffff/);
assert.match(out, /STR dark_bg=#101114/);
assert.match(out, /OBJ layout/);
assert.match(out, /STR nav_type=sidebar/);
assert.match(out, /STR struct_radius_base=0\.875rem/);
assert.match(out, /OBJ typography/);
assert.match(out, /STR struct_font_mono=ui-monospace/);
});
test("json_add_config_preview: 带空格的字体栈完整保留", () => {
// 抓的是词分割陷阱:字体栈里有空格,绝不能走 $(...) 展开。
assert.match(
runConfigPreview(),
/STR struct_font_sans="Nunito", sans-serif/,
);
});
test("json_add_config_preview: jshn 写入没有掉进管道子 shell", () => {
// 若颜色循环写成 `... | while read`,循环体在子 shell 里跑,退出后这些
// json_add_string 全部丢失 —— 输出里就只剩空的 OBJ colors / /OBJ。
const out = runConfigPreview();
const block = out.slice(out.indexOf("OBJ colors"), out.indexOf("/OBJ"));
assert.ok(block.includes("STR light_bg"), "颜色写入丢失:循环跑在子 shell 里");
});
test("rpcd script: hub_local_state 已声明且过 ensure_active_source", () => {
assert.match(rpcd, /json_add_object "hub_local_state"; json_close_object/);
assert.match(rpcd, /"hub_local_state"\)[\s\S]*?ensure_active_source/);
assert.match(rpcd, /"hub_local_state"\)[\s\S]*?json_add_boolean "modified"/);
});
test("acl: hub_local_state 在 read 列表里", () => {
assert.match(acl, /"hub_local_state"/);
});
@@ -0,0 +1,602 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { repo } from "./paths.mjs";
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
// 同 rpcd-one-slot:把待测函数切出来单跑。整份脚本依赖 jshn 和 uci,两者都只
// 存在于 OpenWrt 上;而这一批测的全是排序、过滤和落盘这类文本断言抓不住的
// 行为——编号一旦两端算得不一样,接收方的图标就整体错位,而源码看上去毫无问题。
function slice(name) {
const start = rpcd.indexOf(`${name}() {`);
assert.ok(start !== -1, `${name} not found in rpcd script`);
const end = rpcd.indexOf("\n}\n", start) + 3;
return rpcd.slice(start, end);
}
function constant(name) {
const match = rpcd.match(new RegExp(`^readonly ${name}=(.*)$`, "m"));
assert.ok(match, `${name} not found`);
return `readonly ${name}=${match[1]}\n`;
}
// uci 的替身。刻意不用 rpcd-one-slot 那个 sed 版本:section id 长
// `@toolbar_item[0]`,方括号在 sed 的正则里是字符类,那个替身查不到它。
const UCI_STUB = `
uci() {
cmd=""; arg=""
for a in "$@"; do
case "$a" in -*) continue ;; esac
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
done
case "$cmd" in
show) printf '%s\\n' "$UCI_OUT" ;;
get)
v=$(printf '%s\\n' "$UCI_OUT" | grep -F "$arg=" | head -1 | sed "s/^[^=]*='//;s/'\\$//")
[ -n "$v" ] || return 1
printf '%s\\n' "$v"
;;
set) printf 'SET %s\\n' "$arg" >> "$UCI_LOG" ;;
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
esac
}
`;
function runSh(script, env = {}) {
return execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
// ---------------------------------------------------------------------------
// sniff_image_ext —— 字节来自网络,扩展名来自字节
// ---------------------------------------------------------------------------
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
const ICO = Buffer.from([0x00, 0x00, 0x01, 0x00, 1, 0, 0, 0]);
// WebP 的格式名在偏移 8,前面隔着 RIFF 自己的长度字段 —— 只读 8 字节是看不
// 见它的。
const WEBP = Buffer.concat([
Buffer.from("RIFF"), Buffer.from([0x1a, 0, 0, 0]),
Buffer.from("WEBP"), Buffer.from("VP8L"),
]);
function sniff(bytes) {
const dir = mkdtempSync(join(tmpdir(), "aurora-sniff-"));
const file = join(dir, "asset");
writeFileSync(file, bytes);
const out = runSh(
slice("sniff_image_ext") + `\nsniff_image_ext "${file}" || echo NONE\n`,
).trim();
rmSync(dir, { recursive: true, force: true });
return out;
}
test("sniff_image_ext: 按魔数认出五种格式,认不出就失败", () => {
assert.equal(sniff(PNG), "png");
assert.equal(sniff(JPEG), "jpg");
assert.equal(sniff(ICO), "ico");
assert.equal(sniff(WEBP), "webp");
assert.equal(sniff('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), "svg");
assert.equal(sniff('<?xml version="1.0"?><svg></svg>'), "svg");
// BOM + 前导空白都不该挡住 SVG —— 和 hub 的 isSvg 一样的姿态。
assert.equal(sniff("\uFEFF\n <svg></svg>"), "svg");
assert.equal(sniff("not an image at all"), "NONE");
// RIFF 但不是 WEBP —— 一个 wav 不该被当成背景图。
assert.equal(
sniff(Buffer.concat([Buffer.from("RIFF"), Buffer.from([0x1a, 0, 0, 0]), Buffer.from("WAVE")])),
"NONE",
);
});
test("hub_asset_ext_ok: 每种 kind 只收它该收的格式", () => {
const script =
slice("hub_asset_ext_ok") +
`\nfor pair in "$@"; do
k=\${pair%%:*}; e=\${pair##*:}
if hub_asset_ext_ok "$k" "$e"; then echo "$pair OK"; else echo "$pair NO"; fi
done\n`;
const cases = [
"logo_svg:svg", "logo_svg:png",
"favicon_png:png", "favicon_png:svg",
"favicon_ico:ico", "favicon_ico:png",
"pwa_icon_192:png", "pwa_icon_192:jpg",
"login_bg:png", "login_bg:jpg", "login_bg:webp", "login_bg:svg",
"main_bg:webp",
"toolbar_icon_0:png", "toolbar_icon_11:svg", "toolbar_icon_0:ico",
"font_sans:png",
];
const out = runSh(`set -- ${cases.join(" ")}\n${script}`);
const verdict = Object.fromEntries(
out.trim().split("\n").map((line) => line.split(" ")),
);
assert.deepEqual(verdict, {
"logo_svg:svg": "OK", "logo_svg:png": "NO",
"favicon_png:png": "OK", "favicon_png:svg": "NO",
"favicon_ico:ico": "OK", "favicon_ico:png": "NO",
"pwa_icon_192:png": "OK", "pwa_icon_192:jpg": "NO",
// 背景原样存,传什么格式就是什么格式,三种都收。
"login_bg:png": "OK", "login_bg:jpg": "OK", "login_bg:webp": "OK",
"login_bg:svg": "NO", "main_bg:webp": "OK",
"toolbar_icon_0:png": "OK", "toolbar_icon_11:svg": "OK", "toolbar_icon_0:ico": "NO",
// 字体不走这条路,问到它就是调用方分流错了。
"font_sans:png": "NO",
});
});
// ---------------------------------------------------------------------------
// hub_asset_target_name —— 落地名不能再是出厂名
// ---------------------------------------------------------------------------
test("hub_asset_target_name: 全部 sha 派生且带 hub- 前缀,绝不落成出厂名", () => {
const sha = "0123456789abcdef".repeat(4);
const kinds = [
"logo_svg", "favicon_png", "favicon_ico",
"pwa_icon_192", "pwa_icon_512", "login_bg", "toolbar_icon_3",
];
const script =
slice("hub_asset_target_name") +
`\nfor k in ${kinds.join(" ")}; do
case "$k" in logo_svg) e=svg ;; favicon_ico) e=ico ;; *) e=png ;; esac
hub_asset_target_name "$k" "${sha}" "$e"; echo
done
hub_asset_target_name "nonsense" "${sha}" png || echo UNKNOWN_REJECTED\n`;
const names = runSh(script).trim().split("\n");
assert.deepEqual(names, [
"hub-logo-0123456789ab.svg",
"hub-favicon-0123456789ab.png",
"hub-favicon-0123456789ab.ico",
"hub-app192-0123456789ab.png",
"hub-app512-0123456789ab.png",
"hub-login-bg-0123456789ab.png",
"hub-tb-0123456789ab.png",
"UNKNOWN_REJECTED",
]);
// 这才是这次改动的要害:落地名若还是出厂名,一份从商店应用来的配置就再也
// 分享不出它的 logo/favicon/PWA 图标 —— build_share_payload 会把它们当成
// 「用户从没改过这个槽位」而跳过。
const shipped = slice("is_theme_shipped_image");
const check = runSh(
shipped +
`\nfor n in ${names.slice(0, -1).join(" ")}; do
is_theme_shipped_image "$n" && echo "LEAK $n"
done; echo DONE\n`,
).trim();
assert.equal(check, "DONE", "落地名一旦撞上出厂名,二次分享就会静默丢图");
});
// ---------------------------------------------------------------------------
// nth_custom_toolbar_icon —— 两端必须算出同一个编号
// ---------------------------------------------------------------------------
function toolbarFixture(items) {
const lines = [];
items.forEach((item, i) => {
lines.push(`aurora.@toolbar_item[${i}]=toolbar_item`);
for (const [key, value] of Object.entries(item)) {
lines.push(`aurora.@toolbar_item[${i}].${key}='${value}'`);
}
});
return lines.join("\n");
}
function iconOrder(uciOut) {
const script =
constant("MAX_SHARED_TOOLBAR_ICONS") +
UCI_STUB +
slice("has_control_char") +
slice("strip_control_chars") +
slice("is_theme_shipped_image") +
slice("toolbar_icon_custom") +
slice("toolbar_item_shareable") +
slice("nth_custom_toolbar_icon") +
`\ni=0
while [ "$i" -lt 20 ]; do
nth_custom_toolbar_icon "$i" || break
echo
i=$((i + 1))
done\n`;
return runSh(script, { UCI_OUT: uciOut, UCI_LOG: "/dev/null" })
.trim()
.split("\n")
.filter(Boolean);
}
const ok = (extra) => ({ title: "T", url: "/x", enabled: "1", ...extra });
test("nth_custom_toolbar_icon: 按首次出现顺序去重编号", () => {
const order = iconOrder(
toolbarFixture([
ok({ icon: "alpha.png" }),
ok({ icon: "beta.svg" }),
ok({ icon: "alpha.png" }),
ok({ icon: "gamma.png" }),
]),
);
assert.deepEqual(order, ["alpha.png", "beta.svg", "gamma.png"]);
});
test("nth_custom_toolbar_icon: 主题自带的图标不占编号", () => {
// 这四个是 header.ut 默认工具栏用的,每台装了主题的设备上都有 —— 发过去
// 纯属浪费,而更要命的是让它们占掉编号会让接收端的映射整体错位。
const order = iconOrder(
toolbarFixture([
ok({ icon: "overview.svg" }),
ok({ icon: "mine.png" }),
ok({ icon: "system.svg" }),
ok({ icon: "logo.svg" }),
ok({ icon: "other.png" }),
]),
);
assert.deepEqual(order, ["mine.png", "other.png"]);
});
// 这条是整个方案的支点。分享端遍历的是**被接受的**快捷方式,接收端遍历的是
// payload.toolbar(也就是那些被接受的)。一条因为标题超长而被跳过的快捷方式,
// 它的图标若在分享端占掉一格,接收端就从那里往后全部错位——每个人的工具栏都
// 会挂上别人的图。
test("nth_custom_toolbar_icon: 被跳过的快捷方式不占编号", () => {
const order = iconOrder(
toolbarFixture([
ok({ icon: "first.png" }),
// 标题 31 字符:超出 hub 的 1-30,整条不发
{ title: "x".repeat(31), url: "/y", enabled: "1", icon: "skipped-a.png" },
// url 没有 scheme 也不以 / 开头
ok({ url: "nowhere", icon: "skipped-b.png" }),
// 协议相对 url
ok({ url: "//evil.example", icon: "skipped-c.png" }),
// enabled 不是 0/1
ok({ enabled: "yes", icon: "skipped-d.png" }),
// 图标名超出字符集
ok({ icon: "bad name.png" }),
// 标题带单引号
ok({ title: "it's", icon: "skipped-e.png" }),
ok({ icon: "second.png" }),
]),
);
assert.deepEqual(order, ["first.png", "second.png"]);
});
test("nth_custom_toolbar_icon: 编号上限与分享端一致", () => {
const many = [];
for (let i = 0; i < 15; i++) many.push(ok({ icon: `icon${i}.png` }));
const order = iconOrder(toolbarFixture(many));
// 12 条快捷方式的上限先咬住,图标自然也只剩 12 个。
assert.equal(order.length, 12);
assert.equal(order[0], "icon0.png");
assert.equal(order[11], "icon11.png");
});
test("nth_custom_toolbar_icon: 编号只看名字,不看本机有没有那个文件", () => {
// 接收端算同一个编号时,那些文件在它机器上根本还不存在。这个判断一旦掺进
// 「文件在不在、够不够小、格式对不对」,两端就再也算不出同一个数。
const body = slice("nth_custom_toolbar_icon");
assert.ok(
!body.includes("toolbar_icon_uploadable"),
"编号不能依赖只有分享端才答得出的问题",
);
assert.ok(!body.includes("ICON_PATH"), "编号不能碰本机文件");
});
// ---------------------------------------------------------------------------
// image_fits_share
// ---------------------------------------------------------------------------
test("image_fits_share: 边界正好是 hub 的 2 MiB", () => {
const script =
constant("MAX_SHARED_IMAGE") +
slice("image_fits_share") +
`\nfor s in "$@"; do
if image_fits_share "$s"; then echo "$s YES"; else echo "$s NO"; fi
done\n`;
const out = runSh(`set -- 0 1 2097151 2097152 2097153 8388608\n${script}`);
assert.deepEqual(
out.trim().split("\n"),
["0 NO", "1 YES", "2097151 YES", "2097152 YES", "2097153 NO", "8388608 NO"],
);
});
// ---------------------------------------------------------------------------
// restore_pre_hub_images / sweep_hub_images —— sha 名的代价必须被收干净
// ---------------------------------------------------------------------------
function sandbox() {
const dir = mkdtempSync(join(tmpdir(), "aurora-images-"));
const iconPath = join(dir, "images");
const deviceDir = join(dir, "device");
mkdirSync(iconPath);
mkdirSync(join(deviceDir, "pre-hub-images"), { recursive: true });
return { dir, iconPath, deviceDir };
}
test("restore_pre_hub_images: 先删本次新引入的,再拷回被覆盖的", () => {
const { dir, iconPath, deviceDir } = sandbox();
const backup = join(deviceDir, "pre-hub-images");
// 这次 apply 新引入的一张
writeFileSync(join(iconPath, "hub-logo-aaaaaaaaaaaa.svg"), "theirs");
writeFileSync(join(backup, "hub-logo-aaaaaaaaaaaa.svg.new"), "");
// 这次 apply 覆盖掉的一张
writeFileSync(join(iconPath, "hub-tb-bbbbbbbbbbbb.png"), "theirs");
writeFileSync(join(backup, "hub-tb-bbbbbbbbbbbb.png"), "mine");
// 与 hub 无关的一张,一个字节都不该动
writeFileSync(join(iconPath, "my-own.png"), "untouched");
runSh(
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
slice("restore_pre_hub_images") +
"\nrestore_pre_hub_images\n",
);
assert.equal(
existsSync(join(iconPath, "hub-logo-aaaaaaaaaaaa.svg")),
false,
"新引入的必须删掉,否则回滚会把别人的图永远留在 overlay 上",
);
assert.equal(
readFileSync(join(iconPath, "hub-tb-bbbbbbbbbbbb.png"), "utf8"),
"mine",
);
assert.equal(readFileSync(join(iconPath, "my-own.png"), "utf8"), "untouched");
assert.equal(existsSync(backup), false, "备份目录必须清掉");
rmSync(dir, { recursive: true, force: true });
});
function runSweep(files, uciOut, backups = []) {
const { dir, iconPath, deviceDir } = sandbox();
for (const name of files) writeFileSync(join(iconPath, name), "x");
for (const name of backups) writeFileSync(join(deviceDir, "pre-hub-images", name), "x");
runSh(
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
UCI_STUB +
slice("is_hub_asset_name") +
slice("sweep_hub_images") +
"\nsweep_hub_images\n",
{ UCI_OUT: uciOut, UCI_LOG: "/dev/null" },
);
const left = readdirSync(iconPath).sort();
rmSync(dir, { recursive: true, force: true });
return left;
}
test("sweep_hub_images: 留下还被指着的,收走没人要的", () => {
const left = runSweep(
[
"hub-logo-aaaaaaaaaaaa.svg", // 被 logo_svg 指着
"hub-login-bg-bbbbbbbbbbbb.png", // 被 struct_login_bg 指着
"hub-tb-cccccccccccc.png", // 被某条快捷方式指着
"hub-logo-dddddddddddd.svg", // 上一次 apply 的遗留
"hub-app192-eeeeeeeeeeee.png", // 同上
],
[
"aurora.theme.logo_svg='hub-logo-aaaaaaaaaaaa.svg'",
"aurora.theme.struct_login_bg='url('/luci-static/aurora/images/hub-login-bg-bbbbbbbbbbbb.png')'",
"aurora.@toolbar_item[0]=toolbar_item",
"aurora.@toolbar_item[0].icon='hub-tb-cccccccccccc.png'",
].join("\n"),
);
assert.deepEqual(left, [
"hub-login-bg-bbbbbbbbbbbb.png",
"hub-logo-aaaaaaaaaaaa.svg",
"hub-tb-cccccccccccc.png",
]);
});
test("sweep_hub_images: 回滚还要用的不动", () => {
const left = runSweep(
["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png"],
"aurora.theme.nav_type='sidebar'",
["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png.new"],
);
// 一张是备份下来的原图,一张记着"本次新引入"——两张都是 restore 的输入。
assert.deepEqual(left, ["hub-logo-aaaaaaaaaaaa.svg", "hub-tb-bbbbbbbbbbbb.png"]);
});
test("sweep_hub_images: 用户自己上传的 hub-* 文件不算垃圾", () => {
// upload_icon 不限制文件名,一个叫 hub-vacation.png 的自有图标完全合法。
// 只按前缀清理,等于在用户没把它设成任何槽位时把它删掉——纯粹的数据丢失。
const left = runSweep(
[
"hub-vacation.png",
"hub-logo-notahexstring.svg",
"hub-tb-aaaaaaaaaaaa.png", // 这个才是本函数产的形状
],
"aurora.theme.nav_type='sidebar'",
);
assert.deepEqual(left, ["hub-logo-notahexstring.svg", "hub-vacation.png"]);
});
// ---------------------------------------------------------------------------
// 时序
// ---------------------------------------------------------------------------
test("hub_apply_worker: 清理排在所有资产落地之后", () => {
const body = slice("hub_apply_worker");
assert.ok(
body.indexOf("apply_hub_asset") < body.indexOf("sweep_hub_images"),
"抢在资产之前跑,就会把刚下载的图当成垃圾删掉",
);
assert.ok(
body.indexOf("sweep_hub_images") < body.indexOf("record_active_source"),
);
});
test("hub_restore_backup: 回滚之后也要清一次", () => {
const branch = rpcd.match(/"hub_restore_backup"\)([\s\S]*?)\n\t;;/);
assert.ok(branch);
// restore_pre_hub_images 只认得**这一次**备份记下的东西;中间那些没拍快照的
// apply(拿商店主题盖商店主题)留下的图,只有 sweep 按"还有没有槽位指着"
// 才收得干净。
assert.match(branch[1], /sweep_hub_images/);
assert.ok(
branch[1].indexOf("restore_pre_hub_images") < branch[1].indexOf("sweep_hub_images"),
);
});
// ---------------------------------------------------------------------------
// apply_hub_toolbar_icon —— 落地之后要把用它的快捷方式全部指过来
// ---------------------------------------------------------------------------
function applyIcon(kind, uciOut) {
const script =
constant("MAX_SHARED_TOOLBAR_ICONS") +
`ICON_PATH="/nonexistent"\nDEVICE_DIR="/nonexistent"\n` +
UCI_STUB +
// apply_hub_asset 自己有单独的测试;这里替身掉,好让这一条只测"指过来"。
`apply_hub_asset() { APPLIED_ASSET_NAME="hub-tb-aaaaaaaaaaaa.png"; return 0; }\n` +
slice("has_control_char") +
slice("strip_control_chars") +
slice("is_theme_shipped_image") +
slice("toolbar_icon_custom") +
slice("toolbar_item_shareable") +
slice("nth_custom_toolbar_icon") +
slice("apply_hub_toolbar_icon") +
// 真文件,不是 /dev/stdout。uci 替身用 `>> "$UCI_LOG"` 追加,而在 Linux 上
// /dev/stdout 是 /proc/self/fd/1 的软链 —— execFileSync 给的 stdout 是一根
// 管道,打开 pipe:[N] 这个路径会 ENXIO("cannot create /dev/stdout: No such
// device or address"),于是每一条 SET/COMMIT 都写失败。函数照样成功返回,
// 所以断言看到的只剩 RC_OK。macOS 的 /dev/stdout 是真设备、直接 dup fd 1,
// 本地因此全绿而 CI 全红。rpcd-one-slot.test.mjs 一直用的就是临时文件。
`\n: > "$UCI_LOG"\n` +
`if apply_hub_toolbar_icon "${kind}" deadbeef http://x/; then rc=RC_OK; else rc=RC_FAIL; fi\n` +
// 日志先出、返回码后出,顺序和断言里的一致。
`cat "$UCI_LOG"\necho "$rc"\n`;
const dir = mkdtempSync(join(tmpdir(), "aurora-icon-"));
try {
const out = execFileSync("sh", ["-c", script], {
encoding: "utf8",
env: { ...process.env, UCI_OUT: uciOut, UCI_LOG: join(dir, "uci.log") },
});
return out.trim().split("\n").filter(Boolean);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const twoUsers = toolbarFixture([
ok({ icon: "alpha.png" }),
ok({ icon: "beta.png" }),
ok({ icon: "alpha.png" }),
]);
test("apply_hub_toolbar_icon: 每一条用了这个图标的快捷方式都被指过来", () => {
// toolbar_icon_0 = 第一个不同的图标 = alpha.png,被第 0 和第 2 条用着。
const log = applyIcon("toolbar_icon_0", twoUsers);
// 断言里带上 =值:指过来还不够,得指到**刚落地的那个名字**上。
assert.deepEqual(log, [
"SET aurora.@toolbar_item[0].icon=hub-tb-aaaaaaaaaaaa.png",
"SET aurora.@toolbar_item[2].icon=hub-tb-aaaaaaaaaaaa.png",
"COMMIT",
"RC_OK",
]);
});
test("apply_hub_toolbar_icon: 编号对应的是第 k 个不同的图标,不是第 k 条快捷方式", () => {
// 第 1 条快捷方式用 beta.png,而 beta.png 是第 1 个(0-based)不同的图标 ——
// 这两个数字在这份夹具里恰好都是 1,所以再换一份让它们分开:
const log = applyIcon(
"toolbar_icon_1",
toolbarFixture([
ok({ icon: "alpha.png" }),
ok({ icon: "alpha.png" }),
ok({ icon: "alpha.png" }),
ok({ icon: "beta.png" }),
]),
);
assert.deepEqual(log, [
"SET aurora.@toolbar_item[3].icon=hub-tb-aaaaaaaaaaaa.png",
"COMMIT",
"RC_OK",
]);
});
test("apply_hub_toolbar_icon: 没有对应槽位的资产不落地", () => {
// hub 送来一个 toolbar_icon_5,而这份配置只有两个不同的图标。多出来的那份
// 字节没有归属,写下去只会在 ICON_PATH 里躺着。
const log = applyIcon("toolbar_icon_5", twoUsers);
assert.deepEqual(log, ["RC_FAIL"]);
});
test("apply_hub_toolbar_icon: kind 里的编号不是数字就直接拒", () => {
assert.deepEqual(applyIcon("toolbar_icon_", twoUsers), ["RC_FAIL"]);
assert.deepEqual(applyIcon("toolbar_icon_x", twoUsers), ["RC_FAIL"]);
});
// ---------------------------------------------------------------------------
// strip_control_chars —— busybox 的 tr 不认字符类
// ---------------------------------------------------------------------------
// 真机(JDCloud RE-CS-02, busybox)上 `tr -d '[:cntrl:]'` 把参数当成字面集合
// { [ : c n t r l },于是每个分享出去的快捷方式标题都悄悄少掉它的 c/n/t/r/l:
// "Overview" → "Oveview"、"Again" → "Agai",而接收端把这个残缺版写进 uci。
// GNU tr 和 BSD tr 都实现了字符类,所以开发机上永远看不到。
test("strip_control_chars: 只删控制字符,字面的 c/n/t/r/l 一个都不能少", () => {
const script =
slice("strip_control_chars") +
`\nfor s in "$@"; do printf '[%s]\\n' "$(strip_control_chars "$s")"; done\n`;
const out = runSh(
`set -- "Overview" "Again" "Control Center" "[:cntrl:]" ""\n${script}`,
);
assert.deepEqual(out.trim().split("\n"), [
"[Overview]",
"[Again]",
"[Control Center]",
"[[:cntrl:]]",
"[]",
]);
});
test("strip_control_chars: 控制字符确实被删掉", () => {
const script =
slice("strip_control_chars") +
`\nv=$(printf 'a\\tb\\001c\\rd')
r=$(strip_control_chars "$v"); printf '%s/%s\\n' "$r" "\${#r}"
r=$(strip_control_chars "$(printf '\\001\\002')"); printf '%s/%s\\n' "$r" "\${#r}"
r=$(strip_control_chars "$(printf '\\001lead')"); printf '%s/%s\\n' "$r" "\${#r}"\n`;
assert.deepEqual(runSh(script).trim().split("\n"), ["abcd/4", "/0", "lead/4"]);
});
test("rpcd script: 三处标题剥离都走这个函数,没有 tr 的残留", () => {
// 注释里点到 `tr -d '[:cntrl:]'` 是在解释为什么不用它;代码里一处都不能剩。
const code = rpcd
.split("\n")
.filter((line) => !/^\s*#/.test(line))
.join("\n");
assert.ok(
!code.includes("tr -d '[:cntrl:]'"),
"busybox 的 tr 会把标题里的 c/n/t/r/l 删掉",
);
assert.equal(
(code.match(/strip_control_chars /g) || []).length,
// 分享端逐项校验、分享端取标题、应用端 —— 三处
3,
"三处标题剥离都必须走同一个函数",
);
});
// 剥离是按控制字符个数循环的,而应用端的 title 直接来自 hub 响应。
test("rpcd script: 应用端在剥离之前先粗筛长度", () => {
const body = slice("validate_and_apply_hub_payload");
const capIdx = body.indexOf('[ "${#title}" -le 200 ]');
const stripIdx = body.indexOf('strip_control_chars "$title"');
assert.ok(capIdx >= 0, "缺少长度粗筛");
assert.ok(capIdx < stripIdx, "粗筛必须排在剥离之前,否则是一条拒绝服务路径");
});
@@ -0,0 +1,393 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { repo } from "./paths.mjs";
// 应用一个主题 = 穿上它那一套,而不是它那一套外加上一个主题的壁纸和 logo。
// 这一组守的是「交接」那一步:上个主题带进来的资产被清掉,用户自己上传的
// 原封不动留下。归属只看文件名形状,所以判定本身也在这里钉死。
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
function slice(name) {
const start = rpcd.indexOf(`${name}() {`);
assert.ok(start !== -1, `${name} not found in rpcd script`);
const end = rpcd.indexOf("\n}\n", start) + 3;
return rpcd.slice(start, end);
}
// uci 的最小替身,带状态:get 查库,set/delete 改库,commit 记一笔。被测的正是
// 「哪些键留下、哪些键消失」,只记调用日志的无状态桩答不了这个问题。
// 旗标位置不固定:清理走 \`uci -q get\`,而 sweep 走 \`uci show aurora\`。按位置取
// 参数的桩会在后者上静默失灵,给出一份空的 live 名单 —— 那会让 sweep 的断言
// 变成永远成立。所以跳过所有 -* 再取动词,同 rpcd-share-assets 的桩。
const UCI_STUB = `
uci() {
cmd=""; arg=""
for a in "$@"; do
case "$a" in -*) continue ;; esac
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
done
case "$cmd" in
get)
v=$(grep "^\${arg}=" "$UCI_DB" | head -1 | cut -d= -f2-)
[ -n "$v" ] || return 1
printf '%s\\n' "$v"
;;
delete)
grep -v "^\${arg}=" "$UCI_DB" > "$UCI_DB.t"; mv "$UCI_DB.t" "$UCI_DB" ;;
set)
k="\${arg%%=*}"; v="\${arg#*=}"
grep -v "^\${k}=" "$UCI_DB" > "$UCI_DB.t"; mv "$UCI_DB.t" "$UCI_DB"
printf '%s=%s\\n' "$k" "$v" >> "$UCI_DB" ;;
commit) printf 'COMMIT\\n' >> "$UCI_LOG" ;;
esac
return 0
}
`;
// 一次 apply 之后 uci 里剩下什么。fixture 是 "key=value" 行,返回同样的形状。
function runWithUci(fnSlices, call, fixture, env = {}) {
const dir = mkdtempSync(join(tmpdir(), "aurora-handoff-"));
const db = join(dir, "uci.db");
const log = join(dir, "uci.log");
writeFileSync(db, fixture.length ? fixture.join("\n") + "\n" : "");
writeFileSync(log, "");
try {
execFileSync("sh", ["-c", UCI_STUB + fnSlices.join("\n") + "\n" + call], {
encoding: "utf8",
env: { ...process.env, UCI_DB: db, UCI_LOG: log, ...env },
});
return {
uci: readFileSync(db, "utf8").split("\n").filter(Boolean).sort(),
commits: readFileSync(log, "utf8").split("\n").filter(Boolean).length,
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const CLEAR = [slice("is_hub_asset_name"), slice("clear_inherited_assets")];
const bgUrl = (name) => `url('/luci-static/aurora/images/${name}')`;
// ---------------------------------------------------------------------------
// is_hub_asset_name —— 归属判定,清理与回收共用的那一条
// ---------------------------------------------------------------------------
const shapeCases = [
// apply_hub_asset 真会产出的七种前缀
["hub-logo-0123456789ab.svg", true],
["hub-favicon-0123456789ab.png", true],
["hub-app192-0123456789ab.png", true],
["hub-app512-0123456789ab.png", true],
["hub-login-bg-abcdef012345.jpg", true],
["hub-main-bg-abcdef012345.png", true],
["hub-tb-abcdef012345.png", true],
// 用户自己上传的图,哪怕名字以 hub- 开头也不算 —— 误清就是数据丢失
["hub-wallpaper.png", false],
["hub-main-bg.png", false],
["my-photo.jpg", false],
["logo.svg", false],
// 形状必须严丝合缝:12 位、全小写十六进制、三字符扩展名
["hub-main-bg-abcdef01234.png", false],
["hub-main-bg-abcdef0123456.png", false],
["hub-main-bg-ABCDEF012345.png", false],
["hub-main-bg-abcdefg12345.png", false],
["hub-main-bg-abcdef012345.jpeg", false],
["", false],
];
test("is_hub_asset_name only accepts the shape apply_hub_asset produces", () => {
for (const [name, expected] of shapeCases) {
const out = execFileSync(
"sh",
[
"-c",
slice("is_hub_asset_name") +
`\nis_hub_asset_name "${name}" && echo YES || echo NO\n`,
],
{ encoding: "utf8" },
).trim();
assert.equal(out, expected ? "YES" : "NO", `${name || "(empty)"}`);
}
});
test("sweep_hub_images asks is_hub_asset_name rather than re-deriving the shape", () => {
const sweep = slice("sweep_hub_images");
assert.match(sweep, /is_hub_asset_name "\$name" \|\| continue/);
assert.equal(
/\[0-9a-f\]\[0-9a-f\]/.test(sweep),
false,
"the hex shape must live in exactly one place",
);
});
// ---------------------------------------------------------------------------
// clear_inherited_assets —— 上个主题的资产被交还,用户自己的留下
// ---------------------------------------------------------------------------
test("a preset apply drops every image the previous theme brought", () => {
const { uci } = runWithUci(CLEAR, 'clear_inherited_assets ""', [
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
"aurora.theme.favicon_png=hub-favicon-0123456789ab.png",
"aurora.theme.favicon_ico=hub-favicon-0123456789ab.ico",
"aurora.theme.pwa_icon_192=hub-app192-0123456789ab.png",
"aurora.theme.pwa_icon_512=hub-app512-0123456789ab.png",
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
"aurora.theme.struct_main_bg_lqip=data:image/png;base64,AA",
"aurora.theme.struct_main_bg_alpha=90%",
"aurora.theme.struct_main_bg_blur=8px",
"aurora.theme.struct_main_bg_scrim=40%",
`aurora.theme.struct_login_bg=${bgUrl("hub-login-bg-abcdef012345.jpg")}`,
"aurora.theme.struct_login_bg_alpha=60%",
"aurora.theme.nav_type=sidebar",
]);
// 外观本身(nav_type)不归它管,一个字都不能动
assert.deepEqual(uci, ["aurora.theme.nav_type=sidebar"]);
});
test("images the user uploaded survive a theme switch, tunables included", () => {
const fixture = [
"aurora.theme.logo_svg=my-logo.svg",
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
"aurora.theme.struct_main_bg_lqip=data:image/png;base64,AA",
// 用户自己拉过的滑杆:图是他的,亮度也是他的
"aurora.theme.struct_main_bg_alpha=95%",
"aurora.theme.struct_main_bg_blur=4px",
];
const { uci, commits } = runWithUci(CLEAR, 'clear_inherited_assets ""', fixture);
assert.deepEqual(uci, [...fixture].sort());
assert.equal(commits, 0, "nothing changed, so nothing may be committed");
});
test("a slot this apply just wrote is never cleared", () => {
const { uci } = runWithUci(CLEAR, 'clear_inherited_assets "logo_svg main_bg"', [
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
"aurora.theme.struct_main_bg_alpha=90%",
// 这一份 payload 没带登录背景 —— 它还是上一个主题的
`aurora.theme.struct_login_bg=${bgUrl("hub-login-bg-abcdef012345.jpg")}`,
"aurora.theme.struct_login_bg_blur=12px",
]);
assert.deepEqual(uci, [
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
"aurora.theme.struct_main_bg_alpha=90%",
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
].sort());
});
// ---------------------------------------------------------------------------
// clear + sweep 串起来:报上来的那个现象,以及它旁边那条不能误伤的线
// ---------------------------------------------------------------------------
// 预设那条路径的真实两步:先交还槽位,再回收没人指着的文件。分开测各自成立,
// 串起来才回答「屏幕上还有没有那张背景、overlay 上还剩不剩那个文件」。
function runPresetHandoff(files, fixture) {
const dir = mkdtempSync(join(tmpdir(), "aurora-handoff-e2e-"));
const iconPath = join(dir, "images");
const deviceDir = join(dir, "device");
mkdirSync(iconPath, { recursive: true });
mkdirSync(deviceDir, { recursive: true });
const db = join(dir, "uci.db");
writeFileSync(db, fixture.join("\n") + "\n");
writeFileSync(join(dir, "uci.log"), "");
for (const name of files) writeFileSync(join(iconPath, name), "x");
// sweep 读的是 `uci -q show aurora`(为了拿快捷方式的图标),而清理走的是
// get/delete —— 同一个库两种读法。show 由库现场拼出来,否则测的就是两份彼此
// 不同步的状态,sweep 会拿到一份空的 live 名单然后把什么都删掉。
// uci show 的行是 key='value';sweep 的 sed 认的正是那对引号。%c/39 拼引号,
// 免得在 sh -c 里套三层引号。
const stub = UCI_STUB.replace(
' case "$cmd" in',
` case "$cmd" in
show) awk '{ i=index($0,"="); printf "%s=%c%s%c\\n", substr($0,1,i-1), 39, substr($0,i+1), 39 }' "$UCI_DB" ;;`,
);
execFileSync(
"sh",
[
"-c",
`ICON_PATH="${iconPath}"\nDEVICE_DIR="${deviceDir}"\n` +
stub +
slice("is_hub_asset_name") +
slice("clear_inherited_assets") +
slice("sweep_hub_images") +
'\nclear_inherited_assets ""\nsweep_hub_images\n',
],
{
encoding: "utf8",
env: { ...process.env, UCI_DB: db, UCI_LOG: join(dir, "uci.log") },
},
);
const result = {
uci: readFileSync(db, "utf8").split("\n").filter(Boolean).sort(),
files: readdirSync(iconPath).sort(),
};
rmSync(dir, { recursive: true, force: true });
return result;
}
test("the reported bug: a store background does not survive into a preset", () => {
const { uci, files } = runPresetHandoff(
["hub-main-bg-abcdef012345.png"],
[
`aurora.theme.struct_main_bg=${bgUrl("hub-main-bg-abcdef012345.png")}`,
"aurora.theme.struct_main_bg_alpha=90%",
"aurora.theme.struct_main_bg_blur=8px",
"aurora.theme.nav_type=sidebar",
],
);
assert.deepEqual(uci, ["aurora.theme.nav_type=sidebar"]);
assert.deepEqual(files, [], "槽位空了,那张图就是垃圾,不能留在 overlay 上");
});
test("a hub image a shortcut still points at is not swept", () => {
// 快捷方式不归 clear 管(预设对它们没有主张),所以它的图标必须活下来 ——
// 同时这条也证明 sweep 真的读到了 live 名单,而不是拿着一份空名单乱删。
const { files } = runPresetHandoff(
["hub-tb-abcdef012345.png", "hub-logo-0123456789ab.svg"],
[
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
],
);
assert.deepEqual(files, ["hub-tb-abcdef012345.png"]);
});
test("the wallpaper the user uploaded is not collateral damage", () => {
const { uci, files } = runPresetHandoff(
["my-wallpaper.jpg", "hub-logo-0123456789ab.svg"],
[
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
"aurora.theme.struct_main_bg_alpha=95%",
"aurora.theme.logo_svg=hub-logo-0123456789ab.svg",
],
);
assert.deepEqual(uci, [
`aurora.theme.struct_main_bg=${bgUrl("my-wallpaper.jpg")}`,
"aurora.theme.struct_main_bg_alpha=95%",
].sort());
// 用户自己的图留着;上一个主题的 logo 连槽位带文件一起走
assert.deepEqual(files, ["my-wallpaper.jpg"]);
});
// ---------------------------------------------------------------------------
// apply_hub_bg_tunables —— 调参跟着图走
// ---------------------------------------------------------------------------
const TUNABLES = [slice("apply_hub_bg_tunables")];
const payloadTunables = [
"struct_main_bg_alpha=80%",
"struct_main_bg_blur=",
"struct_main_bg_scrim=30%",
"struct_login_bg_alpha=",
"struct_login_bg_blur=",
"struct_login_bg_scrim=",
].join("\n");
test("the landed background takes the author's tunables, missing ones reset", () => {
const { uci } = runWithUci(
TUNABLES,
'HUB_BG_TUNABLES="$FIXTURE_TUNABLES"\napply_hub_bg_tunables "main_bg"',
[
"aurora.theme.struct_main_bg_alpha=95%",
// 作者没调过 blur:该回到 CSS 默认,而不是继承上一个主题的值
"aurora.theme.struct_main_bg_blur=40px",
],
{ FIXTURE_TUNABLES: payloadTunables },
);
assert.deepEqual(uci, [
"aurora.theme.struct_main_bg_alpha=80%",
"aurora.theme.struct_main_bg_scrim=30%",
].sort());
});
test("a theme with no background of its own leaves the user's tunables alone", () => {
const fixture = [
"aurora.theme.struct_main_bg_alpha=95%",
"aurora.theme.struct_login_bg_blur=12px",
];
const { uci, commits } = runWithUci(
TUNABLES,
'HUB_BG_TUNABLES="$FIXTURE_TUNABLES"\napply_hub_bg_tunables ""',
fixture,
{ FIXTURE_TUNABLES: payloadTunables },
);
assert.deepEqual(uci, [...fixture].sort());
assert.equal(commits, 0);
});
// ---------------------------------------------------------------------------
// 两条 apply 路径的接线与顺序
// ---------------------------------------------------------------------------
// 注释里提到某个函数名不等于调用了它 —— 顺序断言只看代码行。
const codeOnly = (text) =>
text
.split("\n")
.filter((line) => !/^\s*#/.test(line))
.join("\n");
test("validate_and_apply_hub_payload parks the tunables instead of batching them", () => {
const fn = slice("validate_and_apply_hub_payload");
assert.match(fn, /HUB_BG_TUNABLES="\$HUB_BG_TUNABLES\$key=\$value/);
assert.equal(
/delete aurora\.theme\.%s\\n" "\$key" >> "\$batch_file"/.test(fn),
false,
"tunables must not be reset before the asset step knows what landed",
);
});
test("hub apply settles the assets before it fingerprints the result", () => {
const worker = codeOnly(slice("hub_apply_worker"));
const tunables = worker.indexOf("apply_hub_bg_tunables");
const clear = worker.indexOf("clear_inherited_assets");
const sweep = worker.indexOf("sweep_hub_images");
const record = worker.indexOf("record_active_source");
assert.ok(tunables !== -1 && clear !== -1);
assert.ok(tunables < clear, "tunables are written before the slots are cleared");
assert.ok(clear < sweep, "clearing frees the slots that sweeping then collects");
assert.ok(sweep < record, "the fingerprint must be taken last");
assert.match(worker, /landed="\$landed\$a_kind "/);
});
test("apply_theme_preset hands back every asset the previous theme brought", () => {
const start = rpcd.indexOf('"apply_theme_preset")');
assert.ok(start !== -1);
const branch = codeOnly(
rpcd.slice(start, rpcd.indexOf('"export_config")', start)),
);
const clear = branch.indexOf('clear_inherited_assets ""');
const sweep = branch.indexOf("sweep_hub_images");
const record = branch.indexOf("record_active_source");
assert.ok(clear !== -1, "a preset carries no assets, so it clears them all");
assert.ok(clear < sweep);
assert.ok(
sweep < record,
"the tunables are in theme_fingerprint -- clearing must precede recording",
);
});
@@ -0,0 +1,298 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { repo } from "./paths.mjs";
// 快捷方式和资产走同一条规矩:应用一个主题就是穿上它那一套,内置和社区没有
// 分别 —— 主题带了就应用,没带就是没有。这一组守的是「内置预设也带快捷方式」
// 和「指不到文件的图标不留在配置里」。
const rpcd = readFileSync(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8");
const PRESETS = ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"];
function slice(name) {
const start = rpcd.indexOf(`${name}() {`);
assert.ok(start !== -1, `${name} not found in rpcd script`);
const end = rpcd.indexOf("\n}\n", start) + 3;
return rpcd.slice(start, end);
}
const DEPS =
slice("has_control_char") + slice("has_batch_unsafe_char") + slice("load_preset_toolbar");
function loadToolbar(templateText) {
const dir = mkdtempSync(join(tmpdir(), "aurora-tb-"));
const path = join(dir, "x.template");
writeFileSync(path, templateText);
try {
const out = execFileSync(
"sh",
[
"-c",
DEPS +
`\nif load_preset_toolbar "${path}"; then\n` +
` printf 'OK\\n'; printf '%s\\n' "$PRESET_TOOLBAR"\n` +
`else printf 'FAIL\\n'; fi\n`,
],
{ encoding: "utf8" },
).split("\n");
const ok = out[0] === "OK";
return {
ok,
rows: ok ? out.slice(1).filter(Boolean).map((l) => l.split("\t")) : [],
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const block = (title, url, icon, enabled) =>
[
"config toolbar_item",
`\toption title '${title}'`,
`\toption url '${url}'`,
...(icon === null ? [] : [`\toption icon '${icon}'`]),
`\toption enabled '${enabled}'`,
"",
].join("\n");
const wrap = (...blocks) =>
["config aurora 'theme'", "\toption nav_type 'sidebar'", "", ...blocks].join("\n");
// ---------------------------------------------------------------------------
// 每个内置预设都带着自己的快捷方式
// ---------------------------------------------------------------------------
test("every built-in template carries the same four shortcuts", () => {
let reference = null;
for (const preset of PRESETS) {
const file = repo(`root/usr/share/aurora/${preset}.template`);
const { ok, rows } = loadToolbar(readFileSync(file, "utf8"));
assert.ok(ok, `${preset}: template rejected by load_preset_toolbar`);
assert.equal(rows.length, 4, `${preset}: expected 4 shortcuts`);
// 图标必须是主题自带的那几张,否则接收端 drop_missing_toolbar_icons 会把
// 它清掉,预设就成了一排没有图标的按钮。
for (const [, , , icon] of rows) {
assert.match(icon, /^(overview|system|software|network)\.svg$/, `${preset}: ${icon}`);
}
if (reference === null) reference = JSON.stringify(rows);
else assert.equal(JSON.stringify(rows), reference, `${preset} drifted from default`);
}
});
test("a template with no toolbar_item parses as 'no shortcuts', not as an error", () => {
const { ok, rows } = loadToolbar(wrap());
assert.equal(ok, true, "没有快捷方式是一个合法的主题,不是一份坏模板");
assert.deepEqual(rows, []);
});
test("shortcut fields survive parsing intact", () => {
const { ok, rows } = loadToolbar(
wrap(
block("My Site", "https://example.com/a b", "my-icon.png", "1"),
block("No Icon", "/cgi-bin/luci/admin", null, "0"),
),
);
assert.ok(ok);
// icon 排在最后且唯一可空:TAB 是 IFS 空白类,空的中间字段会让 read 把后面
// 的字段整体左移(enabled 被读进 icon)。这条断言就是钉住那个顺序的。
assert.deepEqual(rows, [
["My Site", "https://example.com/a b", "1", "my-icon.png"],
["No Icon", "/cgi-bin/luci/admin", "0", ""],
]);
});
// ---------------------------------------------------------------------------
// 校验:与 hub 那一份同一套界
// ---------------------------------------------------------------------------
const rejected = [
["title 带单引号(会撑破 uci batch 的引号)", block("It's", "/x", "a.png", "1")],
["title 超过 30 字", block("x".repeat(31), "/x", "a.png", "1")],
["title 为空", block("", "/x", "a.png", "1")],
["url 没有 scheme 也不以 / 开头", block("T", "example.com", "a.png", "1")],
["url 是协议相对地址", block("T", "//example.com", "a.png", "1")],
["url 超过 200 字", block("T", "/" + "x".repeat(200), "a.png", "1")],
["enabled 不是 0/1", block("T", "/x", "a.png", "2")],
["enabled 缺席", "config toolbar_item\n\toption title 'T'\n\toption url '/x'\n"],
["icon 含非法字符", block("T", "/x", "a b.png", "1")],
["icon 带路径穿越", block("T", "/x", "../../etc/passwd", "1")],
];
test("load_preset_toolbar rejects what the hub payload would reject", () => {
for (const [why, bad] of rejected) {
const { ok } = loadToolbar(wrap(bad));
assert.equal(ok, false, `应当拒绝:${why}`);
}
});
test("more than 12 shortcuts is rejected, same cap as the hub", () => {
const many = Array.from({ length: 13 }, (_, i) => block(`T${i}`, `/x${i}`, null, "1"));
assert.equal(loadToolbar(wrap(...many)).ok, false);
const twelve = Array.from({ length: 12 }, (_, i) => block(`T${i}`, `/x${i}`, null, "1"));
assert.equal(loadToolbar(wrap(...twelve)).ok, true);
});
// ---------------------------------------------------------------------------
// drop_missing_toolbar_icons:指不到文件的图标不许留在配置里
// ---------------------------------------------------------------------------
const UCI_STUB = `
uci() {
cmd=""; arg=""
for a in "$@"; do
case "$a" in -*) continue ;; esac
if [ -z "$cmd" ]; then cmd="$a"; else arg="$a"; break; fi
done
case "$cmd" in
# 真 uci show 的两种行:段头 aurora.<sid>=<type> 不带引号,选项
# aurora.<sid>.<opt>='<value>' 带引号。段头按点数区分(1 个点 = 段头)。
# 一律加引号的桩会让 drop_missing 的 sed 一个 toolbar_item 都找不到,
# 断言于是永远成立。
show) awk '{ i=index($0,"="); k=substr($0,1,i-1); v=substr($0,i+1);
n=gsub(/\\./,".",k)
if (n <= 1) printf "%s=%s\\n", k, v
else printf "%s=%c%s%c\\n", k, 39, v, 39 }' "$UCI_DB" ;;
# 按字面前缀比,不能用 grep:uci 的匿名段 id 长成 @toolbar_item[0],里面的
# [0] 会被 grep 当成字符类,于是任何带匿名段的键都查不到、也删不掉。
get)
awk -v k="$arg" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); f = 1; exit }
END { if (!f) exit 1 }' "$UCI_DB" || return 1
;;
delete)
awk -v k="$arg" 'index($0, k "=") != 1' "$UCI_DB" > "$UCI_DB.t"
mv "$UCI_DB.t" "$UCI_DB" ;;
commit) : ;;
esac
return 0
}
`;
function runDrop(files, fixture, { iconDir = true } = {}) {
const dir = mkdtempSync(join(tmpdir(), "aurora-drop-"));
const iconPath = join(dir, "images");
if (iconDir) {
execFileSync("mkdir", ["-p", iconPath]);
for (const name of files) writeFileSync(join(iconPath, name), "x");
}
const db = join(dir, "uci.db");
writeFileSync(db, fixture.join("\n") + "\n");
try {
execFileSync(
"sh",
[
"-c",
`ICON_PATH="${iconPath}"\n` +
UCI_STUB +
slice("drop_missing_toolbar_icons") +
"\ndrop_missing_toolbar_icons\n",
],
{ encoding: "utf8", env: { ...process.env, UCI_DB: db } },
);
return readFileSync(db, "utf8").split("\n").filter(Boolean).sort();
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("an icon whose bytes never landed is cleared, not left as a dead name", () => {
const uci = runDrop(
["hub-tb-abcdef012345.png", "overview.svg"],
[
"aurora.@toolbar_item[0]=toolbar_item",
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
"aurora.@toolbar_item[1]=toolbar_item",
// 作者机器上的文件名,本机没有这张图
"aurora.@toolbar_item[1].icon=authors-icon.png",
"aurora.@toolbar_item[2]=toolbar_item",
"aurora.@toolbar_item[2].icon=overview.svg",
],
);
assert.deepEqual(uci, [
"aurora.@toolbar_item[0]=toolbar_item",
"aurora.@toolbar_item[0].icon=hub-tb-abcdef012345.png",
"aurora.@toolbar_item[1]=toolbar_item",
"aurora.@toolbar_item[2]=toolbar_item",
"aurora.@toolbar_item[2].icon=overview.svg",
].sort());
});
test("a path-traversal icon name is cleared without being stat'ed", () => {
const uci = runDrop(
[],
[
"aurora.@toolbar_item[0]=toolbar_item",
"aurora.@toolbar_item[0].icon=../../etc/passwd",
],
);
assert.deepEqual(uci, ["aurora.@toolbar_item[0]=toolbar_item"]);
});
test("no image directory at all means the theme package is missing, not that every icon is dead", () => {
const fixture = [
"aurora.@toolbar_item[0]=toolbar_item",
"aurora.@toolbar_item[0].icon=overview.svg",
];
assert.deepEqual(runDrop([], fixture, { iconDir: false }), [...fixture].sort());
});
// ---------------------------------------------------------------------------
// 接线
// ---------------------------------------------------------------------------
const codeOnly = (text) =>
text.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n");
test("apply_theme_preset replaces the shortcut sections the way hub apply does", () => {
const start = rpcd.indexOf('"apply_theme_preset")');
const branch = codeOnly(rpcd.slice(start, rpcd.indexOf('"export_config")', start)));
assert.match(branch, /load_preset_toolbar "\$template"/);
assert.match(branch, /delete aurora\.@toolbar_item\[0\]/);
assert.match(branch, /add aurora toolbar_item/);
assert.ok(
branch.indexOf("delete aurora.@toolbar_item[0]") <
branch.indexOf("add aurora toolbar_item"),
"先删光再重建,否则预设的快捷方式会叠在上一个主题的后面",
);
});
// `uci batch` 会把 `add` 新建的匿名 section 名(cfg0686e6…)打到 stdout 上。
// apply_theme_preset 是同步 handler,它的 stdout 就是 ubus 的应答体 —— 那几行
// 名字排在 `{ "result": 0 }` 前面,rpcd 解析 JSON 失败,回 INVALID_ARGUMENT,
// 前端于是报「应用失败:未知」,而配置其实已经 commit 了。
test("every uci batch swallows its stdout, or the section names land in the JSON reply", () => {
const lines = rpcd.split("\n");
const offenders = lines
.map((line, i) => ({ line, no: i + 1 }))
.filter(({ line }) => /\|\s*uci batch\b/.test(line) && !/^\s*#/.test(line))
.filter(({ line }) => !/\|\s*uci batch\s+[^|]*>\s*\/dev\/null/.test(line));
assert.deepEqual(
offenders.map(({ no }) => no),
[],
`uci batch 未吞掉 stdout(行 ${offenders.map((o) => o.no).join(", ")})`,
);
});
test("hub apply drops dead icons after the icon assets have landed", () => {
const worker = codeOnly(slice("hub_apply_worker"));
assert.ok(
worker.indexOf("apply_hub_toolbar_icon") < worker.indexOf("drop_missing_toolbar_icons"),
"抢在资产步前面跑,会把还没落地的图标全清掉",
);
assert.ok(
worker.indexOf("drop_missing_toolbar_icons") < worker.indexOf("record_active_source"),
);
});
test("the sharing side still ships every icon name, so both ends number alike", () => {
// 分享端一旦因为「传不了」省掉某个名字,接收端 nth_custom_toolbar_icon 数出
// 来的序号就整体左移,后面的图标全部张冠李戴。兜底只能在接收端。
const fn = slice("build_share_payload");
assert.match(fn, /\[ -n "\$icon" \] && json_add_string "icon" "\$icon"/);
});
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = repo("root/usr/libexec/rpcd/luci.aurora");
test("rpcd defines shared MAX_UPLOAD and receive_upload()", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /readonly MAX_UPLOAD=8388608/);
assert.ok(!src.includes("FONT_MAX_UPLOAD"), "FONT_MAX_UPLOAD must be renamed");
assert.match(src, /^receive_upload\(\)/m);
});
test("upload_font and upload_icon route through receive_upload", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /receive_upload "\$FONT_TMP_UPLOAD" font \|\| exit 0/);
assert.match(
src,
/receive_upload "\$TMP_UPLOAD_PATH" image "\$filename" \|\| exit 0/,
);
});
test("image allowlist covers favicon .ico and every advertised format", async () => {
const src = await readFile(SRC, "utf8");
// The gate and the picker offer the same set, and it is the set the hub
// will take: no format you can put on the router is one you cannot share.
assert.match(src, /jpg\|jpeg\|png\|webp\|svg\|ico/);
assert.ok(!/\bavif\b/.test(src), "avif is not a format any asset slot can use");
});
test("list endpoints expose byte sizes", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /json_add_int "size" "\$csize"/);
assert.match(src, /json_add_object "icon_sizes"/);
});
test("both upload mv sites are guarded and clean tmp on failure", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /if ! mv "\$FONT_TMP_UPLOAD"/);
assert.match(src, /if mv "\$TMP_UPLOAD_PATH"/);
const storeErrors = src.match(/Failed to store file/g) || [];
assert.equal(storeErrors.length, 2, "font and icon mv failure paths");
});
test("remove_icon clears every uci slot that named the deleted file", async () => {
const src = await readFile(SRC, "utf8");
const start = src.indexOf('"remove_icon")');
const end = src.indexOf('"write_pwa_manifest")');
assert.ok(start !== -1 && end > start, "remove_icon handler missing");
const body = src.slice(start, end);
// 删掉的图片可能同时是 logo/favicon/PWA 图标/iOS 主屏图标/工具栏快捷方式的
// 图标 —— 只清一部分就会让 uci 指向一个已经不存在的文件:主题 404,分享
// 清单还在承诺它。
assert.match(
body,
/for icon_slot in logo_svg favicon_png favicon_ico pwa_icon_192 pwa_icon_512 pwa_apple_touch; do/,
"every theme-level image slot must be cleared, not just the login background",
);
assert.match(body, /uci -q delete "aurora\.theme\.\$icon_slot"/);
assert.match(body, /uci -q delete aurora\.theme\.struct_login_bg$/m);
// 工具栏项的 icon 也指向同一批上传图标,而且 build_share_payload 会把它
// 当普通字符串发到 hub —— 悬空的名字会被每个应用者继承。
assert.match(
body,
/sed -n 's\/\^aurora\\\.\\\(\[\^\.=\]\*\\\)=toolbar_item\$\/\\1\/p'/,
"anonymous toolbar_item sections must be enumerated the way build_share_payload does",
);
assert.match(
body,
/uci -q get "aurora\.\$toolbar_sid\.icon"/,
"each toolbar shortcut's icon must be checked against the deleted file",
);
assert.match(body, /uci -q delete "aurora\.\$toolbar_sid\.icon"/);
assert.ok(
!/\[[0-9]+\]\.icon/.test(body),
"toolbar sections are anonymous -- never address them by index",
);
// 既有的 .. 防护必须留着
assert.ok(
body.includes('dirname "$filename" | grep -q "\\.\\."'),
"the .. traversal guard must survive",
);
// 仍然只提交一次,且只在真的改了东西时提交
const commits = body.match(/uci -q commit aurora/g) || [];
assert.equal(commits.length, 1, "exactly one commit for the whole removal");
assert.match(
body,
/\[ "\$icon_slot_cleared" = 1 \] && uci -q commit aurora/,
"the commit must be conditional on something having changed",
);
// BusyBox ash:没有数组、没有 [[、函数外没有 local
assert.ok(!body.includes("[["), "no bash [[ in remove_icon");
assert.ok(!/^\s*local /m.test(body), "no local outside a function");
});
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { srcPath } from "./paths.mjs";
const SRC = srcPath("view/aurora/studio.js");
// 背景组件的版式 CSS 曾寄养在 luci-theme-aurora 的按需补丁
// patches/admin-system-aurora.css 里。装在别的主题下(shadcn 有同样的补丁
// 机制、没有这张表)补丁根本不加载,.bg-preview 丢掉 position/height/overflow,
// 内部八层 absolute 逃到初始包含块糊满全页。app 自己的组件版式归 app,
// 这几条断言就是不让它再漂回主题。
test("the background component ships its own layout, not a theme patch", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const ensureBgCardStyles = /);
assert.match(src, /id: "aurora-bg-card-styles"/);
// 渲染路径上必须真的调用,否则注入函数只是死代码
assert.match(src, /ensureBgCardStyles\(\);/);
for (const cls of [
".bg-duo",
".bg-preview",
".bg-srow",
".bg-pane",
".bg-card-head",
"[data-bg-target]",
]) {
assert.ok(src.includes(cls + " {") || src.includes(cls + ","), `${cls} 缺版式规则`);
}
});
// 组件立在这一条上:预览的图层全是 absolute,它必须是那个定位、限高、裁剪的祖先。
test("the preview stays the positioned, sized, clipping ancestor", async () => {
const src = await readFile(SRC, "utf8");
const rule = src.match(/\.bg-preview \{[^}]*\}/);
assert.ok(rule, ".bg-preview rule missing");
for (const decl of ["position: relative", "overflow: hidden", "height: 130px"]) {
assert.ok(rule[0].includes(decl), `.bg-preview must declare ${decl}`);
}
});
// 主题词汇表不通用:shadcn 定义了 --surface/--bg/--brand,却没有
// --hairline/--text-subtle/--text-muted/--control-bg。裸 var() 会整条声明作废,
// 边框和次要文字就这么消失的。别名一律走 fallback 链,组件内联样式只认别名。
test("every theme value the component reads has a fallback chain", async () => {
const src = await readFile(SRC, "utf8");
const aliases = [
"--bgp-line",
"--bgp-subtle",
"--bgp-muted",
"--bgp-control",
"--bgp-surface",
"--bgp-bg",
"--bgp-text",
"--bgp-brand",
"--bgp-radius",
];
for (const alias of aliases) {
const decl = src.match(
new RegExp(`${alias}: var\\(--[a-z-]+, var\\(--[a-z-]+, [^;]+\\)\\);`),
);
assert.ok(decl, `${alias} must resolve through a two-step fallback chain`);
}
// 组件自己的样式只准引用别名 —— 注入表和 buildBgPreview 的内联样式两处。
// 切片刻意只圈这两段:页面别处(字体管理器、资产库)仍直接用 Aurora token,
// 那是另一码事,不该被这条断言连坐。
const region = (from, to) =>
src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from)));
const component = [
region("const ensureBgCardStyles", "const enhanceColorTokenGroups"),
region("const buildBgPreview", "const addBackgroundOption"),
].join("\n");
const bare = component.match(
/var\(--(?:hairline|text-subtle|text-muted|control-bg|surface|bg|text|brand)\)/g,
);
assert.equal(
bare,
null,
`bare theme tokens left in the component: ${bare?.join(", ")}`,
);
});
@@ -0,0 +1,385 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("view/aurora/studio.js");
test("layout: the workbench shell is gone", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!src.includes("aurora-wb-"), "no aurora-wb-* class may remain");
assert.ok(!src.includes("buildWorkbench"), "buildWorkbench must be deleted");
assert.ok(!src.includes("WB_CSS"), "WB_CSS must be deleted");
assert.ok(
!src.includes("refreshPreview"),
"the preview canvas hook must be deleted",
);
assert.ok(
!/require utils\.theme-preview/.test(src),
"studio.js must stop requiring theme-preview (marketplace.js still uses the file)",
);
});
// LuCI already names this page in the tab strip directly above the content
// (menu.d: "Design Studio"). form.Map renders its own title as an <h2> right
// under that strip, so handing it one printed the same two words twice, a
// hand's width apart. The tab is the one that has to stay: it is how you get
// to the other page.
test("layout: the tab strip names the page, so the map carries no title", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /new form\.Map\("aurora"\)/, "form.Map must take no title");
assert.ok(
!/new form\.Map\("aurora",\s*_\(/.test(src),
"no title string may be handed to form.Map",
);
// description is a separate slot in form.js and still renders on its own,
// which is what keeps the version chips and the export/import/reset row.
assert.match(src, /m\.description = headerBar/);
});
test("layout: handleReset restages colors instead of reloading the page", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(
!/handleReset[\s\S]{0,400}window\.location\.reload/.test(src),
"handleReset must not reload the page",
);
assert.match(src, /this\.super\("handleReset", \[ev\]\)/);
assert.match(src, /this\.colorEditor\?\.schedule\("light"\)/);
assert.match(src, /this\.colorEditor\?\.schedule\("dark"\)/);
});
test("layout: the header has no second Marketplace entry and no preset dropdown", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(
!src.includes("Browse more themes in the Marketplace"),
"tabmenu already links the store; a header button is a duplicate entry",
);
assert.ok(
!src.includes("callApplyThemePreset"),
"presets are the store's job now",
);
assert.match(src, /buildConfigToolbarNode/);
assert.match(src, /id: "theme-version"/);
assert.match(src, /id: "config-version"/);
assert.match(
src,
/const versionArea = E\(/,
"the version area must be a named node — the update capsule appends to it",
);
});
test("colors: source tokens hang straight off the mode sub-tab", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(
!src.includes("_base_colors"),
"the Source Color Tokens SectionValue wrapper must be gone",
);
assert.ok(
!src.includes("Source Color Tokens"),
"its title string goes with it",
);
assert.match(
src,
/option: \(\.\.\.args\) => section\.taboption\(mode, \.\.\.args\)/,
"source colors are added through a taboption adapter",
);
});
test("colors: derived tokens keep exactly one wrapper, without a title", async () => {
const src = await readFile(SRC, "utf8");
const block = src.slice(
src.indexOf("const createColorSections ="),
src.indexOf("const colorGroupFor ="),
);
const wrappers = (block.match(/form\.SectionValue/g) || []).length;
assert.equal(wrappers, 1, "only the derived group may keep a wrapper");
assert.ok(
!block.includes("Derived Color Tokens"),
"the wrapper renders no title -- the fold's summary carries the label",
);
});
test("colors: the format help moved to the sub-tab description", async () => {
const src = await readFile(SRC, "utf8");
assert.match(
src,
/colorSubsection\.tab\("light", _\("Light Mode"\), COLOR_TAB_HINT\)/,
);
assert.match(
src,
/colorSubsection\.tab\("dark", _\("Dark Mode"\), COLOR_TAB_HINT\)/,
);
assert.match(src, /const COLOR_TAB_HINT =/);
});
test("colors: token groups are borderless heading + rule, not boxes", async () => {
const src = await readFile(SRC, "utf8");
// The group itself declares only a margin -- no box around it.
const box = /\.aurora-token-group,\n\.aurora-derived-fold \{([^}]*)\}/.exec(src);
assert.ok(box, "the two folds should share one box rule");
assert.ok(!/border/.test(box[1]), "the group box border is gone");
assert.ok(!/border-radius/.test(box[1]), "the group box radius is gone");
// The rule lives on the summary instead, shared by both folds.
const summary = /\.aurora-token-group > summary,\n\.aurora-derived-fold > summary \{([^}]*)\}/.exec(src);
assert.ok(summary, "both summaries should share one rule");
assert.match(summary[1], /border-bottom:/, "the summary carries the dividing rule");
});
test("colors: derived tokens collapse into a single fold", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const enhanceDerivedFold = \(root\) => \{/);
assert.match(src, /class: "aurora-derived-fold"/);
assert.ok(
!/aurora-derived-fold"[\s\S]{0,120}open: ""/.test(src),
"the derived fold must start collapsed",
);
assert.match(
src,
/enhanceColorTokenGroups\(mapNode\);\s*\n\s*enhanceDerivedFold\(mapNode\);/,
"the fold runs after the groups are built",
);
});
test("nav: the style picker stays a ListValue so depends() keeps working", async () => {
const src = await readFile(SRC, "utf8");
const option = src.slice(
src.indexOf('"nav_type",\n _("Navigation Style")'),
src.indexOf('"struct_spacing"'),
);
assert.match(option, /so\.widget = "radio"/, "radio widget, not a bare DOM control");
assert.match(
option,
/so\.renderWidget = renderNavChoiceWidget/,
"the option only points at the decorator",
);
const widget = src.slice(
src.indexOf("const renderNavChoiceWidget ="),
src.indexOf("const renderColorField ="),
);
assert.match(
widget,
/form\.ListValue\.prototype\.renderWidget\.apply\(this, arguments\)/,
"the parent widget must still produce the inputs -- change/depends ride on it",
);
});
test("nav: the wireframe goes before the input, never between input/label/text", async () => {
const src = await readFile(SRC, "utf8");
// ui.Select's radio markup is span.cbi-radio > [input, label, span(text)],
// and the caption span's click handler walks previousElementSibling twice to
// reach the input. Splitting that chain silently kills click-to-select.
assert.match(src, /insertBefore\(drawing, input\)/);
assert.ok(
!/closest\("label"\)/.test(src),
"there is no wrapping label in LuCI's radio markup",
);
});
test("nav: three wireframes, no images, no network", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const NAV_CHOICE_WIREFRAMES = \{/);
const block = src.slice(
src.indexOf("const NAV_CHOICE_WIREFRAMES = {"),
src.indexOf("const ensureNavChoiceStyles"),
);
["mega-menu", "dropdown", "sidebar"].forEach((key) => {
assert.ok(
new RegExp(`"${key}":|${key}:`).test(block),
`missing wireframe for ${key}`,
);
});
assert.ok(!/<img|url\(|fetch\(/.test(block), "wireframes must be pure CSS");
});
test("nav: content width still depends on nav_type and is retained", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /so\.retain = true;/);
assert.match(src, /so\.depends\("nav_type", "mega-menu"\);/);
assert.match(src, /so\.depends\("nav_type", "dropdown"\);/);
});
test("layout: the three outer tabs are back", async () => {
const src = await readFile(SRC, "utf8");
// The workbench replaced these with an accordion. Restoring the map without
// restoring them stacks every section into one endless page -- which no
// source assertion caught, only a screenshot did.
assert.match(src, /s\.tab\("colors", _\("Colors"\)\)/);
assert.match(src, /s\.tab\("layout_typography", _\("Layout & Typography"\)\)/);
assert.match(src, /s\.tab\("icons_branding", _\("Branding & Shortcuts"\)\)/);
["colorSection", "structureSection", "fontSection", "assetSection",
"logoSection", "toolbarSection"].forEach((name) => {
assert.match(
src,
new RegExp(`const ${name} = s\\.taboption\\(`),
`${name} must hang off a tab, not the bare section`,
);
});
assert.ok(
!/= s\.option\(\s*\n\s*form\.SectionValue/.test(src),
"no top-level SectionValue may bypass the tabs",
);
});
test("the two engine scripts download in parallel and still execute in order", async () => {
const source = await readFile(srcPath("view/aurora/studio.js"), "utf8");
assert.match(
source,
/script\.async = false/,
"a dynamically inserted script defaults to async; without this the two " +
"engine files race and tokens.global.js can evaluate before Color exists",
);
assert.ok(
!/await loadGlobalScript\("utils\/color\.global\.js"\);\s*\n\s*if \(typeof AuroraTokens/.test(
source,
),
"awaiting the first script before requesting the second costs a second round trip",
);
});
// 发布的起点在工作台,不在商店。导出/导入/重置都是对"整套配置"动手,分享是
// 同一族的事 —— 导出是存给自己,分享是发给别人。商店那边三处发布入口因此
// 全部删掉(见 marketplace-view.test.mjs)。
test("studio: publishing starts here, next to export/import", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /_\("Share to the store"\)/);
// 意图用 URL 参数传,不用 sessionStorage:刷新、回退、收藏行为都可预测。
assert.match(src, /L\.url\("admin\/system\/aurora\/marketplace"\)/);
assert.match(src, /"\?share=1"/);
// One primary action; export, import and reset sit behind "More".
assert.match(
src,
/shareButton,\s*buildMoreMenu\(\[exportItem, importItem, E\("li", \{ role: "separator", class: "sep" \}\), resetItem\]\),/,
);
});
// ---------------------------------------------------------------------------
// Header: a quiet version line, one primary action, the rest behind "More"
const headerBlock = (src) =>
src.slice(src.indexOf(" const versionEntry ="), src.indexOf(" m.description = headerBar;"));
test("header: each package is its muted name, then its full installed version in the theme's green .label", async () => {
const src = await readFile(SRC, "utf8");
const head = headerBlock(src);
assert.match(head, /E\("span", attrs, \[\s*E\("b", \{\}, label\),/);
// Current is the theme's own success colour, and the version is shown whole.
assert.match(head, /\{ class: installed \? "label success" : "label" \},\s*installed \|\| _\("Unknown"\),/);
assert.ok(!src.includes("shortVersion"));
assert.match(head, /versionEntry\(\{ id: "theme-version" \}, _\("Theme"\), installedVersions\?\.theme\?\.installed_version\)/);
assert.match(head, /versionEntry\(\{ id: "config-version" \}, _\("Config"\), installedVersions\?\.config\?\.installed_version\)/);
assert.match(head, /const versionArea = E\("div", \{ class: "aurora-studio-versions" \}/);
// Nothing about an update exists until the manifest says so.
for (const later of ['"arrow"', '"up"', 'E("a"', '"label warning"']) assert.ok(!head.includes(later), later);
// The old separate pill and the "Theme: " prefixes are gone for good.
for (const gone of ['_("Update available %s")', "`v${", '_("Theme: ")', '_("Config: ")'])
assert.ok(!src.includes(gone), gone);
});
test("header: a newer build turns that package's label to warning and hangs → link after it; the other stays green", async () => {
const src = await readFile(SRC, "utf8");
const block = src.slice(src.indexOf("const showUpdateCapsule ="), src.indexOf('const s = m.section(form.NamedSection'));
assert.match(block, /\["luci-theme-aurora", "theme-version", installedVersions\?\.theme\?\.installed_version\],/);
assert.match(block, /\["luci-app-aurora-config", "config-version", installedVersions\?\.config\?\.installed_version\],/);
assert.match(block, /packages\.forEach\(\(\[pkg, id, installed\]\) => \{/);
assert.match(block, /feedCheck\.findManifestVersion\(\s*manifest,\s*channel,\s*format,\s*pkg,\s*\)/);
// Versions that cannot be ordered say nothing at all: the label stays green, no arrow, no link.
assert.match(block, /if \(!feedCheck\.isNewer\(installed, available\)\) return;/);
assert.match(block, /const entry = versionArea\.querySelector\("#" \+ id\);/);
assert.match(
block,
/entry\.classList\.add\("up"\);\s*entry\.querySelector\("\.label"\)\.className = "label warning";\s*entry\.appendChild\(E\("span", \{ class: "arrow", "aria-hidden": "true" \}, "→"\)\);/,
);
assert.match(block, /E\("a", \{ href: L\.url\(packagePagePath\) \}, \[label\]\)/);
assert.match(block, /const label = _\("%s available"\)\.format\(available\);/);
assert.match(src, /const packagePagePath = feedCheck\.pickPackageManagerPath\(menuTree\);/);
assert.ok(!block.includes("innerHTML"));
// It stays on this line: nothing here feeds the inbox or the header count.
for (const inboxHook of ["inboxIndicator", "hubApi", "noticesCache", "setNoticesLocal"])
assert.ok(!src.includes(inboxHook), inboxHook);
});
test("header: the row's colours are the theme's .label classes; the new rules only space and align", async () => {
const src = await readFile(SRC, "utf8");
const css = src.slice(src.indexOf("const ensureToolbarStyles"), src.indexOf("const ensureBgCardStyles"));
const rowRules = [...css.matchAll(/^(\.aurora-studio-versions[^{]*) \{([^}]*)\}/gm)];
const label = rowRules.find((m) => m[1] === ".aurora-studio-versions .label");
assert.ok(label, "the label only gets spacing and numerals");
assert.deepEqual(
label[2].trim().split(/;\s*/).filter(Boolean).map((d) => d.split(":")[0].trim()).sort(),
["font-variant-numeric", "margin-left"],
);
const arrow = rowRules.find((m) => m[1] === ".aurora-studio-versions .arrow");
assert.ok(arrow && !/color|background/.test(arrow[2]), "the arrow inherits the muted row colour");
// No colour of our own on any label: success and warning are the theme's.
assert.ok(!/\.label\.(success|warning)|\.label[^{]*\{[^}]*(color|background)/.test(css));
assert.match(css, /\.aurora-studio-versions \{\s*color: var\(--text-subtle,/);
assert.match(css, /\.aurora-studio-versions b \{\s*color: var\(--text-muted,/);
});
test("header: Share is the one button; Export, Import and Reset are menu items with their handlers intact", async () => {
const src = await readFile(SRC, "utf8");
const bar = src.slice(src.indexOf("const buildConfigToolbarNode ="), src.indexOf("const buildMoreMenu ="));
assert.equal((bar.match(/E\(\s*"button",\s*\{\s*class: "cbi-button/g) || []).length, 1);
assert.match(bar, /class: "cbi-button cbi-button-action",\s*click: \(\) => \{\s*window\.location\.href =/);
for (const [name, label] of [
["exportItem", "Export configuration"],
["importItem", "Import configuration"],
["resetItem", "Reset everything to defaults"],
]) {
const item = bar.slice(bar.indexOf(`const ${name} = E(`));
assert.match(item, /^const \w+ = E\(\s*"li",\s*\{\s*role: "menuitem",\s*tabindex: "-1",/);
assert.ok(bar.includes(`_("${label}")`), label);
}
assert.match(bar, /const resetItem = E\(\s*"li",\s*\{\s*role: "menuitem",\s*tabindex: "-1",\s*class: "danger",/);
// Same flows as before: only the entry points moved.
assert.match(bar, /L\.resolveDefault\(callExportConfig\(\), null\)/);
assert.match(bar, /\.uploadFile\(CONFIG_IMPORT_PATH, btn\)/);
assert.match(bar, /ui\.showModal\(_\("Import Aurora Configuration\?"\)/);
assert.match(bar, /L\.resolveDefault\(\s*callImportConfig\(\),/);
assert.match(bar, /ui\.showModal\(_\("Reset All Aurora Settings"\)/);
assert.match(bar, /L\.resolveDefault\(callResetDefaults\(\), \{\}\)/);
// No version text in the menu.
assert.ok(!/installed_version|shortVersion/.test(bar));
});
test("header: the More menu is LuCI's dropdown shell, made operable as a menu", async () => {
const src = await readFile(SRC, "utf8");
const menu = src.slice(src.indexOf("const buildMoreMenu ="), src.indexOf("const FONT_DEFAULT_STACKS") > src.indexOf("const buildMoreMenu =") ? src.indexOf("const FONT_DEFAULT_STACKS") : src.indexOf("const versionEntry ="));
assert.match(menu, /class: "dropdown", role: "menu", id: "aurora-studio-more-menu", tabindex: "-1"/);
assert.match(menu, /class: "cbi-dropdown cbi-button aurora-studio-more",/);
for (const attr of ['role: "button"', 'tabindex: "0"', '"aria-label": _("More")', '"aria-haspopup": "menu"', '"aria-expanded": "false"', '"aria-controls": menu.id'])
assert.ok(menu.includes(attr), attr);
// [open] is what every theme's .cbi-dropdown > ul.dropdown keys its popover on.
assert.match(menu, /toggle\.setAttribute\("open", ""\);\s*toggle\.setAttribute\("aria-expanded", "true"\);/);
assert.match(menu, /toggle\.removeAttribute\("open"\);\s*toggle\.setAttribute\("aria-expanded", "false"\);/);
assert.match(menu, /entries\(\)\[0\]\.focus\(\);/);
assert.match(menu, /if \(ev\.key === "Escape" && isOpen\) close\(true\);/);
assert.match(menu, /if \(refocus\) toggle\.focus\(\);/);
assert.match(menu, /\["ArrowDown", "ArrowUp", "Home", "End"\]\.includes\(ev\.key\)/);
assert.match(menu, /else if \(ev\.key === "Tab"\) close\(false\);/);
assert.match(menu, /if \(!toggle\.contains\(ev\.target\)\) close\(false\);/);
assert.match(menu, /document\.addEventListener\("click", onOutside\);/);
assert.match(menu, /document\.removeEventListener\("click", onOutside\);/);
assert.match(menu, /if \(menu\.contains\(ev\.target\)\) close\(false\);/);
});
test("header: the new rules cover only what the theme's dropdown and buttons cannot say", async () => {
const src = await readFile(SRC, "utf8");
const css = src.slice(src.indexOf("const ensureToolbarStyles"), src.indexOf("const ensureBgCardStyles"));
// The popover surface, its items and their hover are the theme's.
for (const themeOwned of ["surface-overlay", "box-shadow", "backdrop", "border-radius: 1", ":hover {\n background"])
assert.ok(!css.includes(themeOwned), themeOwned);
assert.match(css, /\.cbi-dropdown\.aurora-studio-more > ul\.dropdown \{\s*left: auto;\s*min-width: 0;\s*right: 0;\s*top: calc\(100% \+ 6px\);\s*width: min\(280px, calc\(100vw - 32px\)\);/);
assert.match(css, /li\.danger \{\s*color: var\(--danger,/);
const phone = css.slice(css.indexOf("@media (max-width: 600px) {"));
assert.match(phone, /\.aurora-studio-acts \{\s*flex: 1 1 100%;/);
assert.match(phone, /\.aurora-studio-acts > \.cbi-button:not\(\.aurora-studio-more\) \{\s*flex: 1;\s*min-height: 42px;/);
assert.match(phone, /\.cbi-dropdown\.aurora-studio-more \{\s*height: 42px;\s*width: 42px;/);
assert.match(phone, /@media \(max-width: 600px\), \(hover: none\) \{[\s\S]*li\[role="menuitem"\] \{\s*min-height: 42px;/);
});
test("the closed more-menu list takes no space", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /\.cbi-dropdown\.aurora-studio-more:not\(\[open\]\) > ul\.dropdown \{\s*display: none;\s*\}/);
});
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { srcPath } from "./paths.mjs";
const SRC = srcPath("view/aurora/studio.js");
// 登录背景与主界面背景共用一份前端管线:同一个 helper、同一段 LQIP 自动
// 生成。断言的是"只有一份实现"——第二份手抄的 LQIP 逻辑就是下一处 drift。
test("one background helper serves both the login and the main page", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /const addBackgroundOption = /);
assert.match(src, /key: "struct_login_bg"/);
assert.match(src, /key: "struct_main_bg"/);
assert.match(src, /lqipKey: "struct_main_bg_lqip"/);
// 更名后不许残留旧名(定义或调用都算 drift)
assert.ok(!src.includes("toLoginBgUrl"), "toLoginBgUrl must be renamed to toBgUrl");
assert.match(src, /const toBgUrl = /);
});
test("the three tunables write unit-suffixed values and default by absence", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /\["struct_main_bg_alpha", [^\]]*"%", 50, 100, "67"\]/);
assert.match(src, /\["struct_main_bg_blur", [^\]]*"px", 0, 40, "20"\]/);
assert.match(src, /\["struct_main_bg_scrim", [^\]]*"%", 0, 70, "20"\]/);
});
test("the LQIP auto-fill loop covers both background pairs", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /\["struct_login_bg", "struct_login_bg_lqip"/);
assert.match(src, /\["struct_main_bg", "struct_main_bg_lqip"/);
});
// ── B 组件:背景分区并入品牌 tab 且排最前,选图 + 预览 + 滑杆共用一份实现 ──
test("backgrounds top the branding tab instead of owning a separate one", async () => {
const src = await readFile(SRC, "utf8");
// 独立 tab 已撤:上传在资产库、选择在背景区,分开两个 tab 就是断流程
assert.doesNotMatch(src, /s\.tab\("backgrounds"/);
assert.match(src, /_\("Page Backgrounds"\)/);
// 分区顺序:资产库置顶统一管理 → 背景 → 品牌
const libAt = src.indexOf('"_asset_library"');
const bgAt = src.indexOf('"_background_settings"');
const brandAt = src.indexOf('"_branding_settings"');
assert.ok(
libAt > 0 && libAt < bgAt && bgAt < brandAt,
"order must be asset library, backgrounds, branding",
);
// 两个背景都挂在分区 subsection 上
assert.equal(src.match(/addBackgroundOption\(bgSubsection/g)?.length, 2);
// 站点品牌分区不再描述登录背景
assert.doesNotMatch(src, /and the login background/);
});
test("upload is embedded in the component, tagged with its owning key", async () => {
const src = await readFile(SRC, "utf8");
// 按钮已砍(资产库统一上传),拖拽静默入口保留
assert.doesNotMatch(src, /_\("Upload image"\)/);
assert.match(src, /addEventListener\("drop"/);
assert.match(src, /callUploadIcon\(file\.name\)/);
// pending 带归属键:主背景的上传不再误落到登录背景上
assert.match(src, /aurora\.pending_bg_key/);
assert.match(src, /pendingKey === key/);
// 上传入口只归主背景;登录背景纯选择,上传统一走置顶的资产库
assert.match(src, /key: "struct_main_bg"[\s\S]{0,400}withUpload: true/);
assert.doesNotMatch(src, /key: "struct_login_bg"[\s\S]{0,300}withUpload/);
});
test("the shared component previews live and drives tunables with sliders", async () => {
const src = await readFile(SRC, "utf8");
// 滑杆而不是裸数字输入;隐藏字段仍是三个 struct 键(保存管线不变)
assert.match(src, /type: "range"/);
assert.match(src, /tunables/);
assert.match(src, /\["struct_main_bg_alpha", [^\]]*"%", 50, 100, "67"\]/);
// 预览读选图的 url() 并随滑杆联动
assert.match(src, /buildBgPreview|bg-preview/);
});
test("design A: two picker cards, one shared slider pane, no tab", async () => {
const src = await readFile(SRC, "utf8");
// 双卡常驻可见,tab/分段已撤——不许再隐藏另一组背景的状态
assert.doesNotMatch(src, /data-bg-switcher/);
assert.match(src, /class: "bg-duo"/);
assert.match(src, /class: "bg-card-head"/);
// 滑杆装进各自 pane,渲染后移入共享面板行,跟随选中的卡;初始停在主背景
assert.match(src, /"data-bg-pane": key/);
assert.match(src, /class: "bg-pane"/);
assert.match(src, /showBg\("struct_main_bg"\)/);
// 卡内下拉的点击不冒泡成选卡
assert.match(src, /e\.stopPropagation\(\)/);
// 拖回默认值 = 写空(不写键),所以不需要"恢复默认"按钮
assert.match(src, /=== \+def \? ""/);
assert.doesNotMatch(src, /_\("Reset to defaults"\)/);
});
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const themePath = srcPath("view/aurora/studio.js");
async function themeSource() {
return readFile(themePath, "utf8");
}
function sourceBlock(source, start, end) {
const startIndex = source.indexOf(start);
assert.notEqual(startIndex, -1, `${start} exists`);
const endIndex = source.indexOf(end, startIndex);
assert.notEqual(endIndex, -1, `${end} exists after ${start}`);
return source.slice(startIndex, endIndex);
}
test("color editor validates only the edited color field", async () => {
const source = await themeSource();
const editorBlock = sourceBlock(
source,
"const createColorEditor =",
"const renderColorField =",
);
assert.match(editorBlock, /const updateField = \(mode, key, result, options = \{\}\)/);
assert.match(editorBlock, /const shouldValidate = options\.validateKeys\?\.has\(key\) \?\? false/);
assert.match(editorBlock, /if \(shouldValidate\) triggerValidation\(field\)/);
assert.match(editorBlock, /const affectedKeysFor = \(key\) =>/);
assert.match(editorBlock, /schedule\(mode, token\.key, \{ validate: true \}\)/);
});
test("native color picker does not bubble LuCI change events", async () => {
const source = await themeSource();
const pickerBlock = sourceBlock(
source,
'picker.addEventListener("change"',
"editor.register",
);
assert.match(pickerBlock, /input\.value = picker\.value/);
assert.match(pickerBlock, /input\.dispatchEvent\(new Event\("input"\)\)/);
assert.doesNotMatch(pickerBlock, /new Event\("change"/);
assert.doesNotMatch(pickerBlock, /bubbles:\s*true/);
});
test("pending color validation keeps the last stable validity", async () => {
const source = await themeSource();
const validateBlock = sourceBlock(
source,
"const validate = (mode, key, value) =>",
"const attach =",
);
assert.match(
validateBlock,
/if \(state\.pending\)\s*return state\.valid \|\| state\.error == null \? true : state\.error/,
);
});
test("derived color fields can be cleared from UCI", async () => {
const source = await themeSource();
const addColorInputsBlock = sourceBlock(
source,
"const addColorInputs =",
"const createColorSections =",
);
assert.doesNotMatch(addColorInputsBlock, /if\s*\(\s*token\.derived\s*\)\s*return/);
assert.doesNotMatch(addColorInputsBlock, /if\s*\(\s*token\.derived\s*\)\s*\{[\s\S]*?return;[\s\S]*?\}/);
assert.match(
addColorInputsBlock,
/if\s*\(\s*trimmed\s*\)[\s\S]*uci\.set[\s\S]*else[\s\S]*uci\.unset/,
);
assert.match(addColorInputsBlock, /option\.remove = \(sectionId\) => \{[\s\S]*uci\.unset/);
});
test("saving waits for pending color resolution before writing UCI", async () => {
const source = await themeSource();
const editorBlock = sourceBlock(
source,
"const createColorEditor =",
"const renderColorField =",
);
const saveBlock = sourceBlock(
source,
"const runSavePipeline = function",
"return view.extend",
);
assert.match(editorBlock, /const flush = \(\) => \{/);
assert.match(editorBlock, /flush,/);
assert.match(saveBlock, /this\.colorEditor\?\.flush\?\.\(\)/);
assert.match(saveBlock, /persistDerivedTokens\(this\.colorEditor\)/);
});
test("token groups only collapse via the chevron, not stray header clicks", async () => {
const source = await themeSource();
const enhanceBlock = sourceBlock(
source,
"const enhanceColorTokenGroups =",
"const createRangeControlRenderer =",
);
// A dedicated chevron button drives the open state explicitly, reusing the
// theme's navigation chevron glyph.
assert.match(enhanceBlock, /class: "aurora-token-group-toggle navigation-group-toggle"/);
assert.match(enhanceBlock, /const setGroupOpen = \(open\) =>/);
assert.match(
enhanceBlock,
/toggle\.addEventListener\("click", \(ev\) => \{[\s\S]*ev\.preventDefault\(\)[\s\S]*ev\.stopPropagation\(\)[\s\S]*setGroupOpen\(!expectedOpen\)/,
);
// Any non-chevron summary click is cancelled so the native picker dismiss
// cannot collapse the group.
assert.match(
enhanceBlock,
/summary\.addEventListener\("click", \(ev\) => \{[\s\S]*aurora-token-group-toggle[\s\S]*ev\.preventDefault\(\)/,
);
// A backstop reverts any collapse that still slips through.
assert.match(
enhanceBlock,
/details\.addEventListener\("toggle", \(\) => \{[\s\S]*details\.open = expectedOpen/,
);
});
test("preset selector moved to the Marketplace; toolbar keeps the config actions", async () => {
const source = await themeSource();
// The dropdown and its apply flow now live in marketplace.js's Built-in group.
assert.ok(!source.includes("buildPresetOptions"), "preset dropdown should be gone");
assert.ok(!source.includes("apply_theme_preset"), "preset apply rpc should be gone from theme.js");
const toolbarBlock = sourceBlock(
source,
"const buildConfigToolbarNode =",
"const versionArea =",
);
// No store link either: the tabmenu already carries that entrance, so a
// header button would be a second door to the same page.
assert.ok(
!/aurora\/gallery|"gallery"/.test(toolbarBlock),
"the store entrance belongs to the tabmenu, not the toolbar",
);
assert.match(toolbarBlock, /exportItem/, "config actions must survive the preset removal");
assert.match(toolbarBlock, /resetItem/, "reset must survive too");
});
test("derivation falls back to preset colors when a source field is untouched", async () => {
const source = await themeSource();
const block = sourceBlock(source, "const sourceValueFor =", "const isDerivedOverride =");
// uci stores nothing until a colour is edited, so the field is empty and the
// preset shows as a placeholder. Deriving from input.value alone reported
// "Unable to generate the automatic derived value." on all 21 derived tokens
// for every default install.
assert.match(block, /presetColors\?\.\[colorOptionName\(mode, key\)\]/);
assert.match(
block,
/const value = sourceValueFor\(mode, key\);/,
"automaticForMode must go through the fallback, not valueFor",
);
});
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { repo, srcPath } from "./paths.mjs";
const SRC = srcPath("utils/theme-preview.js");
// buildMini/logoImage 是 DOM 构造器,所以这些测试用一份 LuCI 全局(E 和
// document)的替身把它们真跑起来,而不是对着源码做正则。替身只实现模块用到
// 的那几样:children、style、addEventListener、appendChild、replaceChild。
function makeNode(tag, attrs) {
return {
tag,
attrs: attrs || {},
style: {},
children: [],
parentNode: null,
listeners: {},
addEventListener(type, fn) {
(this.listeners[type] = this.listeners[type] || []).push(fn);
},
setAttribute(name, value) {
this.attrs[name] = value;
},
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
},
replaceChild(next, prev) {
const i = this.children.indexOf(prev);
assert.notEqual(i, -1, "replaceChild called with a node that is not a child");
this.children[i] = next;
next.parentNode = this;
prev.parentNode = null;
return prev;
},
};
}
const E = (tag, attrs, children) => {
const node = makeNode(tag, attrs);
const list =
children === undefined || children === null
? []
: Array.isArray(children)
? children
: [children];
list.forEach((child) => node.appendChild(child));
return node;
};
const documentStub = {
createTextNode: (text) => ({ tag: "#text", text, parentNode: null, children: [] }),
};
async function load() {
const src = await readFile(SRC, "utf8");
const body = src
.replace(/^"use strict";$/m, "")
.replace(/^"require [^"]+";$/gm, "");
return new Function("baseclass", "E", "document", body)(
{ extend: (obj) => obj },
E,
documentStub,
);
}
function walk(node, out) {
out = out || [];
out.push(node);
(node.children || []).forEach((child) => walk(child, out));
return out;
}
const findAll = (node, tag) => walk(node).filter((n) => n.tag === tag);
const PAL = { bg: "#101010", surface: "#202020", text: "#303030", brand: "#40a0f0" };
const LOGO = "https://themes.example/assets/6zcxcg07/logo_svg";
test("theme-preview draws all three nav_type shapes", async () => {
const src = await readFile(SRC, "utf8");
assert.match(src, /nav === "sidebar"/, "missing sidebar branch");
assert.match(src, /nav === "mega-menu"/, "missing mega-menu branch");
// dropdown 走的是「其它一律顶栏」的默认分支,所以只断言注释写清了这件事
assert.match(src, /dropdown/, "nav_type contract not documented");
});
test("theme-preview keeps geometry static and only colors variable", async () => {
const src = await readFile(SRC, "utf8");
assert.ok(!src.includes(".innerHTML"), "no innerHTML in preview module");
// 每一处颜色赋值都必须先过 safeHex,不能把原始值拼进 style 字符串
assert.match(src, /const safeHex = \(value, fallback\) =>/);
assert.match(src, /HEX_RE\.test/);
});
test("theme-preview mega-menu panel shifts the content area down", async () => {
const src = await readFile(SRC, "utf8");
// 顶栏 16% + 面板 13% = 29%,内容区起点必须让开,不能还用顶栏的 24%
assert.match(src, /37%/, "mega-menu content offset missing");
});
test("buildMini without a logo draws no image at all", async () => {
const m = await load();
assert.equal(findAll(m.buildMini(PAL, { nav: "dropdown" }), "img").length, 0);
assert.equal(findAll(m.buildMini(PAL, { nav: "sidebar", logo: "" }), "img").length, 0);
});
test("buildMini puts the logo where the brand square goes, in every nav shape", async () => {
const m = await load();
["dropdown", "mega-menu", "sidebar"].forEach((nav) => {
const imgs = findAll(m.buildMini(PAL, { nav: nav, logo: LOGO }), "img");
assert.equal(imgs.length, 1, `expected exactly one logo image for nav=${nav}`);
assert.equal(imgs[0].attrs.src, LOGO);
// 装饰性图像:旁边的清单小块已经把它叫出名字了
assert.equal(imgs[0].attrs.alt, "");
});
});
test("a logo that fails to load falls back to the brand square in the same slot", async () => {
const m = await load();
const root = m.buildMini(PAL, { nav: "dropdown", logo: LOGO });
const img = findAll(root, "img")[0];
const bar = img.parentNode;
const slot = bar.children.indexOf(img);
assert.ok(img.listeners.error && img.listeners.error.length, "missing error handler");
img.listeners.error.forEach((fn) => fn());
const replacement = bar.children[slot];
assert.notEqual(replacement, img, "the broken image is still in the tree");
assert.equal(findAll(root, "img").length, 0, "no broken image may survive");
// 同一个槽位、同一块品牌色、同样的尺寸 —— 不跳版,也不会再发一次请求
assert.equal(replacement.style.background, "#40a0f0");
assert.equal(bar.children.length, 4);
});
test("buildDuo forwards the logo to both the light and the dark mini", async () => {
const m = await load();
const duo = m.buildDuo({ light: PAL, dark: PAL }, { nav: "sidebar", logo: LOGO });
assert.equal(findAll(duo, "img").length, 2);
});
test("logoImage hands back the fallback untouched when there is no url", async () => {
const m = await load();
const fallback = documentStub.createTextNode("◆");
assert.equal(m.logoImage("", fallback, "width:15px;"), fallback);
assert.equal(m.logoImage(null, fallback, "width:15px;"), fallback);
});
test("logoImage loads hub SVG only through an img src, never as markup", async () => {
const src = await readFile(SRC, "utf8");
// hub 的 logo 是用户上传的 SVG。放在 <img> 里它跑不了脚本,放在 DOM 里别的
// 地方就能。这是硬规矩,所以直接断言。
//
// 扫的是代码不是散文:模块顶上的安全注释本身就点了这几个 API 的名字,
// 拿原文去扫会被自己的注释绊倒。
const code = src.replace(/^\s*\/\/.*$/gm, "");
assert.ok(!code.includes(".innerHTML"), "no innerHTML anywhere in this module");
assert.ok(!/insertAdjacentHTML|DOMParser|createContextualFragment/.test(code));
assert.match(code, /E\("img", \{\s*src:/, "the logo must be loaded as an img src");
});
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import test from "node:test";
import vm from "node:vm";
import { repo, srcPath } from "./paths.mjs";
async function loadAuroraTokens() {
const sandbox = { console };
vm.createContext(sandbox);
vm.runInContext(
await readFile(srcPath("utils/color.global.js"), "utf8"),
sandbox,
);
vm.runInContext(
await readFile(srcPath("utils/tokens.global.js"), "utf8"),
sandbox,
);
return sandbox.AuroraTokens;
}
function extractMetadataKeys(source, mapName) {
const match = source.match(
new RegExp(`const ${mapName} = \\{([\\s\\S]*?)\\n\\};`),
);
assert.ok(match, `${mapName} is declared`);
return Array.from(match[1].matchAll(/^ (\w+): \{/gm)).map(([, key]) => key);
}
// The token tables are joined at runtime from the AuroraTokens registry and
// the metadata maps (buildColorTokenTables throws on mismatch); this catches
// the drift at test time instead of page-load time.
test("theme metadata covers exactly the tokens in the shared token engine", async () => {
const [auroraTokens, themeSource] = await Promise.all([
loadAuroraTokens(),
readFile(srcPath("view/aurora/studio.js"), "utf8"),
]);
assert.deepEqual(
extractMetadataKeys(themeSource, "COLOR_TOKEN_METADATA").sort(),
Array.from(auroraTokens.INPUTS).sort(),
);
assert.deepEqual(
extractMetadataKeys(themeSource, "DERIVED_COLOR_TOKEN_METADATA").sort(),
Array.from(auroraTokens.DERIVED_KEYS).sort(),
);
});
test("theme editor writes runtime colors in browser-compatible hex form", async () => {
const source = await readFile(
srcPath("view/aurora/studio.js"),
"utf8",
);
assert.match(
source,
/const toRuntimeColor = \(value\) =>[\s\S]*?toString\(\{ format: "hex" \}\)/,
"runtime color serializer outputs hex or hex8",
);
assert.match(
source,
/picker\.addEventListener\("change"[\s\S]*?input\.value = picker\.value/,
"native color picker keeps the user-facing value as hex",
);
assert.match(
source,
/document\.documentElement\.style\.setProperty\(\s*property,\s*toRuntimeColor\(/,
"live preview serializes custom properties before setting them",
);
assert.match(
source,
/uci\.set\(\s*"aurora",\s*"theme",\s*`\$\{mode\}_\$\{key\}`,\s*toRuntimeColor\(/,
"derived token snapshots are serialized before writing UCI",
);
});
test("color sections describe accepted input formats", async () => {
const source = await readFile(
srcPath("view/aurora/studio.js"),
"utf8",
);
assert.match(source, /#hex, rgb\(\), hsl\(\), lab\(\), and oklch\(\)/);
assert.match(source, /The picker fills hex/);
});
test("preset templates store runtime-compatible hex colors", async () => {
const presetDir = repo("root/usr/share/aurora");
const files = (await readdir(presetDir)).filter((name) =>
name.endsWith(".template"),
);
assert.ok(files.length > 0, "preset templates exist");
assert.ok(files.includes("default.template"), "default preset template exists");
assert.ok(!files.includes("classic.template"), "classic preset template is renamed");
for (const file of files) {
const source = await readFile(resolve(presetDir, file), "utf8");
const colorLines = source
.split("\n")
.filter((line) => /^\s*option\s+(light|dark)_/.test(line));
assert.equal(colorLines.length, 62, `${file} has 31 light + 31 dark colors`);
for (const line of colorLines) {
assert.match(
line,
/^\s*option\s+(light|dark)_[a-z_]+\s+'#[0-9a-f]{3,4}([0-9a-f]{3,4})?'$/,
`${file}: ${line}`,
);
}
}
});
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import vm from "node:vm";
import { repo, srcPath } from "./paths.mjs";
const RES = srcPath("utils");
// Load the vendored browser engine (color.global.js -> tokens.global.js) the
// config UI runs at runtime. Returns { AuroraTokens, Color }.
async function loadConfigEngine() {
const sandbox = { console };
vm.createContext(sandbox);
vm.runInContext(await readFile(resolve(RES, "color.global.js"), "utf8"), sandbox);
vm.runInContext(await readFile(resolve(RES, "tokens.global.js"), "utf8"), sandbox);
return { AuroraTokens: sandbox.AuroraTokens, Color: sandbox.Color };
}
const hexOf = (Color, value) =>
new Color(value).to("srgb").toString({ format: "hex" }).toLowerCase();
// Parse `option <mode>_<key> '<value>'` lines into { light: {...}, dark: {...} }.
function parseTemplate(source) {
const out = { light: {}, dark: {} };
for (const line of source.split("\n")) {
const m = line.match(/^\s*option\s+(light|dark)_([a-z_]+)\s+'([^']+)'/);
if (m) out[m[1]][m[2]] = m[3];
}
return out;
}
// Guards the bug fixed in gen-presets.mjs: the config UI loads the 10 inputs as
// hex and recomputes derived tokens from them to decide "automatic vs override"
// (syncDerivedInitialState in view/aurora/studio.js). If a template seeds a
// derived value that disagrees with that recompute by even one 8-bit step, a
// clean preset misreads it as a manual override and shows a value in the box.
// Every stored derived value MUST equal the engine's recompute from the same
// hex inputs.
test("preset templates seed derived tokens consistent with the engine recompute", async () => {
const { AuroraTokens, Color } = await loadConfigEngine();
const presetDir = repo("root/usr/share/aurora");
const files = (await readdir(presetDir)).filter((name) =>
name.endsWith(".template"),
);
assert.ok(files.length > 0, "preset templates exist");
for (const file of files) {
const stored = parseTemplate(await readFile(resolve(presetDir, file), "utf8"));
for (const mode of ["light", "dark"]) {
const inputs = {};
for (const key of AuroraTokens.INPUTS) {
assert.ok(
stored[mode][key],
`${file}: missing input ${mode}_${key}`,
);
inputs[key] = stored[mode][key];
}
const resolved = AuroraTokens.resolve(mode, inputs);
for (const key of AuroraTokens.DERIVED_KEYS) {
assert.equal(
hexOf(Color, stored[mode][key]),
hexOf(Color, resolved[key]),
`${file}: ${mode}_${key} seed must match recompute from hex inputs`,
);
}
}
}
});
// The rpcd script validates presets/imports against the key list shipped in
// color-tokens.conf. That file is generated from the same registry the browser
// engine embeds, but nothing at runtime rechecks it -- this does. Runs in
// standalone CI (no sibling repo needed).
test("color-tokens.conf lists exactly the engine's tokens, inputs first", async () => {
const { AuroraTokens } = await loadConfigEngine();
const conf = await readFile(
repo("root/usr/share/aurora/color-tokens.conf"),
"utf8",
);
const keys = conf
.split("\n")
.filter((line) => line && !line.startsWith("#"));
assert.deepEqual(
keys,
Array.from(AuroraTokens.INPUTS).concat(
Array.from(AuroraTokens.DERIVED_KEYS),
),
);
});
// theme.js loads tokens.global.js with ?v=TOKENS_ENGINE_VERSION so browsers
// refetch the engine whenever it changes, instead of pairing a fresh theme.js
// with an HTTP-cached engine from a previous release ("stale color token
// metadata" errors). The stamp is written by sync-tokens.mjs; verify it
// matches the version baked into the vendored engine's header.
test("theme.js cache-busts the token engine with the vendored version", async () => {
const theme = await readFile(
srcPath("view/aurora/studio.js"),
"utf8",
);
const engine = await readFile(resolve(RES, "tokens.global.js"), "utf8");
const stamped = theme.match(/const TOKENS_ENGINE_VERSION = "([^"]+)";/)?.[1];
const vendored = engine.match(/@eamonxg\/luci-theme-tokens v(\S+)/)?.[1];
assert.ok(stamped, "theme.js declares TOKENS_ENGINE_VERSION");
assert.ok(vendored, "tokens.global.js header carries its version");
assert.equal(stamped, vendored);
});
// tokens.global.js and color-tokens.conf are vendored from the
// @eamonxg/luci-theme-tokens package at the version pinned in package.json.
// Rerun sync-tokens in --check mode and fail on drift, so neither a stale
// vendor nor a hand-edit can land unnoticed. Falls back to building from the
// sibling luci-theme-tokens checkout when the registry is unreachable
// (sync-tokens does this internally); skips only on transient network/server
// failures -- a 4xx (e.g. 404 for an unpublished pin) means the pin itself is
// broken and must FAIL, not skip.
test("vendored token artifacts are in sync with the pinned package", async (t) => {
try {
await promisify(execFile)(process.execPath, [
repo(".dev/scripts/sync-tokens.mjs"),
"--check",
]);
} catch (error) {
const message = String(error?.stderr ?? error);
if (/HTTP 5\d\d|fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT/.test(message)) {
t.skip("registry unreachable and no sibling luci-theme-tokens checkout");
return;
}
throw error;
}
});
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more