Sync 2026-06-10 21:12:18

This commit is contained in:
github-actions[bot]
2026-06-10 21:12:19 +08:00
parent 15bd7dfdef
commit 2151d80d61
24 changed files with 3801 additions and 2848 deletions
+45
View File
@@ -0,0 +1,45 @@
include $(TOPDIR)/rules.mk
PKG_NAME:= deepbot
PKG_VERSION:= 1.0.0
PKG_RELEASE:=1
include $(INCLUDE_DIR)/package.mk
define Package/deepbot
SECTION:= utils
CATEGORY:= Utilities
TITLE:= DeepSeek AI Telegram Bot
DEPENDS:= +lua +luasocket +lua-cjson +lsqlite3 +curl
PKGARCH:= all
endef
define Package/deepbot/description
Telegram bot powered by DeepSeek AI.
Supports private and group chats, conversation history,
daily token limits, SOCKS5/HTTP proxy.
Lua implementation from
https://github.com/Bimaoitsuki/deepseek-telegram-bot-
endef
define Build/Compile
endef
define Package/deepbot/conffiles
/etc/config/deepbot
endef
define Package/deepbot/install
$(INSTALL_DIR) $(1)/usr/bin \
$(1)/etc/init.d \
$(1)/etc/config \
$(1)/usr/share/deepbot
$(INSTALL_BIN) ./files/usr/bin/deepbot.lua \
$(1)/usr/bin/deepbot.lua
$(INSTALL_BIN) ./files/etc/init.d/deepbot \
$(1)/etc/init.d/deepbot
$(INSTALL_DATA) ./files/etc/config/deepbot \
$(1)/etc/config/deepbot
endef
$(eval $(call BuildPackage,deepbot))
+10
View File
@@ -0,0 +1,10 @@
config deepbot 'main'
option token 'YOUR_BOT_TOKEN' # Telegrab BOT token (from @BotFather)
option deepseek_key 'YOUR_DEEPSEEK_KEY' # DeepSeek API key
option deepseek_url 'https://api.deepseek.com/v1/chat/completions' # DeepSeek API URL
option proxy '' # Proxy server (proxy_type://host:port[@username:passwd] see man curl)
option database '/usr/share/deepbot/chat_history.db' # SQLite3 database path
option token_limit '10000' # Token linit per day
option rate_limit '5' # Max replies per user in one min
option group_mention_only '1' # Reply queris in public chats
option debug '0' # Verbose log (stdout)
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
PROG="/usr/bin/deepbot.lua"
start_service() {
local token deepseek_key deepseek_url proxy database \
token_limit rate_limit group_mention_only debug
config_load deepbot
config_get token main token ''
config_get deepseek_key main deepseek_key ''
config_get deepseek_url main deepseek_url 'https://api.deepseek.com/v1/chat/completions'
config_get proxy main proxy ''
config_get database main database '/usr/share/deepbot/chat_history.db'
config_get token_limit main token_limit '10000'
config_get rate_limit main rate_limit '5'
config_get group_mention_only main group_mention_only '1'
config_get debug main debug '0'
procd_open_instance
procd_set_param command lua "$PROG"
procd_set_param env \
TELEGRAM_BOT_TOKEN="$token" \
DEEPSEEK_API_KEY="$deepseek_key" \
DEEPSEEK_API_URL="$deepseek_url" \
PROXY_URL="$proxy" \
DEBUG="$debug"
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn 30 5 0
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "deepbot"
}
+559
View File
@@ -0,0 +1,559 @@
#!/usr/bin/env lua
-- DeepSeek Telegram Bot (Lua 5.1.5)
-- Config: /etc/config/deepbot
-- Depends: curl lua luasocket lua-cjson lsqlite3 uci
local socket = require("socket")
local json = require("cjson")
local sqlite3 = require("lsqlite3")
-- ===== UCI КОНФИГ =====
local function uci_get(key)
local f = io.popen("uci -q get deepbot." .. key .. " 2>/dev/null")
if not f then return nil end
local v = f:read("*l")
f:close()
return (v and v ~= "") and v or nil
end
local function uci_get_bool(key, default)
local v = uci_get(key)
if v == nil then return default end
return v == "1" or v == "true" or v == "yes"
end
local function uci_get_int(key, default)
local v = uci_get(key)
return v and tonumber(v) or default
end
-- ===== Config =====
-- Prio: envilopment variables > UCI > default value
local TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
or uci_get("main.token")
or "YOUR_BOT_TOKEN"
local DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
or uci_get("main.deepseek_key")
or "YOUR_DEEPSEEK_KEY"
local DEEPSEEK_API_URL = os.getenv("DEEPSEEK_API_URL")
or uci_get("main.deepseek_url")
or "https://api.deepseek.com/v1/chat/completions"
local PROXY_URL = os.getenv("PROXY_URL")
or uci_get("main.proxy")
or ""
local DATABASE_NAME = uci_get("main.database")
or "/usr/share/deepbot/chat_history.db"
local TOKEN_LIMIT_PER_DAY = uci_get_int("main.token_limit", 10000)
local RATE_LIMIT = uci_get_int("main.rate_limit", 5)
local DEBUG = os.getenv("DEBUG") == "1"
or uci_get_bool("main.debug", false)
-- reply in chant only ask or @bot call
local GROUP_MENTION_ONLY = uci_get_bool("main.group_mention_only", true)
local TG_API_BASE = "https://api.telegram.org/bot" .. TELEGRAM_BOT_TOKEN
-- bot_username defined after getMe
local BOT_USERNAME = ""
-- ===== LOG =====
local function log(level, msg)
io.stderr:write(string.format("[%s] [%s] %s\n",
os.date("%Y-%m-%d %H:%M:%S"), level, tostring(msg)))
io.stderr:flush()
end
local function info(msg) log("INFO", msg) end
local function err(msg) log("ERROR", msg) end
local function dbg(msg) if DEBUG then log("DEBUG", msg) end end
-- ===== CURL =====
local function curl_post(url, headers, body)
local header_args = ""
for k, v in pairs(headers) do
header_args = header_args .. string.format(" -H '%s: %s'", k, v)
end
local proxy_arg = (PROXY_URL ~= "") and (" -x '" .. PROXY_URL .. "'") or ""
local pid_f = io.popen("echo $$")
local pid = pid_f and pid_f:read("*l") or "0"
if pid_f then pid_f:close() end
local tmpfile = string.format("/tmp/deepbot_%s_%s.json", pid, tostring(os.time()))
local f = io.open(tmpfile, "w")
if not f then
err("curl_post: cannot write tmpfile " .. tmpfile)
return nil
end
f:write(body)
f:flush()
f:close()
local cmd = string.format(
"curl -s -m 30 -X POST%s%s --data-binary @'%s' '%s'",
header_args, proxy_arg, tmpfile, url
)
dbg("curl_post CMD: " .. cmd)
local pipe = io.popen(cmd)
if not pipe then
os.remove(tmpfile)
err("curl_post: popen failed")
return nil
end
local resp = pipe:read("*a")
pipe:close()
os.remove(tmpfile)
dbg("curl_post RESP (" .. #resp .. " bytes): " .. resp:sub(1, 300))
return resp
end
-- ===== TELEGRAM API =====
local function tg_call(method, params)
local body = params and json.encode(params) or "{}"
dbg("tg_call " .. method .. " body: " .. body:sub(1, 200))
local resp = curl_post(
TG_API_BASE .. "/" .. method,
{ ["Content-Type"] = "application/json" },
body
)
if not resp or resp == "" then
err("tg_call " .. method .. ": empty response")
return nil
end
local ok, data = pcall(json.decode, resp)
if not ok then
err("tg_call " .. method .. " JSON error: " .. tostring(data))
err("raw: " .. resp:sub(1, 300))
return nil
end
if not data.ok then
err("tg_call " .. method .. " API error: " ..
tostring(data.description or json.encode(data)))
end
return data
end
-- ===== DATABASE =====
local db
local function init_database()
os.execute("mkdir -p /usr/share/deepbot")
db = sqlite3.open(DATABASE_NAME)
local rc = db:exec([[
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
tokens INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_user_id ON conversations (user_id);
CREATE INDEX IF NOT EXISTS idx_timestamp ON conversations (timestamp);
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date DATE NOT NULL,
tokens_used INTEGER DEFAULT 0,
UNIQUE(user_id, date)
);
CREATE INDEX IF NOT EXISTS idx_token_usage ON token_usage (user_id, date);
]])
if rc ~= sqlite3.OK then
err("DB init error: " .. tostring(db:errmsg()))
else
info("Database: " .. DATABASE_NAME)
end
end
local function get_user_messages(user_id, limit)
limit = limit or 10
local messages = {}
local stmt = db:prepare(
"SELECT role, content FROM conversations WHERE user_id=? ORDER BY timestamp DESC LIMIT ?"
)
stmt:bind_values(user_id, limit)
for row in stmt:nrows() do
table.insert(messages, 1, { role = row.role, content = row.content })
end
stmt:finalize()
return messages
end
local function save_message(user_id, role, content, tokens)
tokens = tokens or 0
local stmt = db:prepare(
"INSERT INTO conversations (user_id, role, content, tokens) VALUES (?,?,?,?)"
)
stmt:bind_values(user_id, role, content, tokens)
stmt:step()
stmt:finalize()
local today = os.date("%Y-%m-%d")
db:exec(string.format(
"INSERT OR IGNORE INTO token_usage (user_id, date, tokens_used) VALUES (%d,'%s',0)",
user_id, today
))
db:exec(string.format(
"UPDATE token_usage SET tokens_used=tokens_used+%d WHERE user_id=%d AND date='%s'",
tokens, user_id, today
))
end
local function clear_user_conversation(user_id)
db:exec(string.format("DELETE FROM conversations WHERE user_id=%d", user_id))
end
local function get_daily_token_usage(user_id)
local today = os.date("%Y-%m-%d")
local stmt = db:prepare(
"SELECT tokens_used FROM token_usage WHERE user_id=? AND date=?"
)
stmt:bind_values(user_id, today)
local result = 0
for row in stmt:nrows() do result = row.tokens_used end
stmt:finalize()
return result
end
-- ===== utils =====
local function estimate_tokens(text)
return math.max(1, math.floor(#text / 4))
end
local function sanitize_text(text)
if #text > 4000 then text = text:sub(1, 4000) .. "" end
return text
end
-- ===== RATE LIMITER =====
local rate_table = {}
local function check_rate(user_id)
local now = socket.gettime()
local ts = rate_table[user_id] or {}
local fresh = {}
for _, t in ipairs(ts) do
if now - t < 60 then fresh[#fresh+1] = t end
end
if #fresh >= RATE_LIMIT then
rate_table[user_id] = fresh
return false
end
fresh[#fresh+1] = now
rate_table[user_id] = fresh
return true
end
-- ===== LRU CACHE =====
local response_cache = {}
local cache_order = {}
local CACHE_MAX = 200
local function cache_get(key) return response_cache[key] end
local function cache_set(key, val)
if not response_cache[key] then
table.insert(cache_order, key)
if #cache_order > CACHE_MAX then
response_cache[table.remove(cache_order, 1)] = nil
end
end
response_cache[key] = val
end
-- ===== CHAT: reply filter =====
-- reply cleared text
local function group_filter(msg)
local chat_type = msg.chat.type -- "private","group","supergroup","channel"
-- private msg reply always
if chat_type == "private" then
return true, msg.text
end
-- in chat — if GROUP_MENTION_ONLY disabled
if not GROUP_MENTION_ONLY then
return true, msg.text
end
local text = msg.text or ""
-- 1. Reply
if msg.reply_to_message and msg.reply_to_message.from then
if msg.reply_to_message.from.username == BOT_USERNAME then
dbg("group_filter: reply to bot")
return true, text
end
end
-- 2. ask bot @BotUsername (any case)
if BOT_USERNAME ~= "" then
local mention = "@" .. BOT_USERNAME
local lower_text = text:lower()
local lower_mention = mention:lower()
if lower_text:find(lower_mention, 1, true) then
-- clean quote
local clean = text:gsub("(?i)" .. mention, "")
-- lua (?i) not support, make case-insensitive
clean = lower_text:gsub(lower_mention, "")
clean = clean:match("^%s*(.-)%s*$") -- trim
if clean == "" then clean = text end -- if empty — keep
dbg("group_filter: mention found, clean text: " .. clean)
return true, clean
end
end
dbg("group_filter: not addressed to bot, skipping")
return false, nil
end
-- ===== DEEPSEEK API =====
local function call_deepseek(user_id, prompt)
local cache_key = tostring(user_id) .. "\0" .. prompt
if cache_get(cache_key) then
info("Cache hit user=" .. user_id)
return cache_get(cache_key)
end
local daily_usage = get_daily_token_usage(user_id)
if daily_usage >= TOKEN_LIMIT_PER_DAY then
return nil, "Daily token limit reached"
end
local conversation = get_user_messages(user_id, 10)
if #conversation == 0 then
local sys = "You are a helpful AI assistant for Telegram users."
table.insert(conversation, { role = "system", content = sys })
save_message(user_id, "system", sys)
end
local total_est = estimate_tokens(prompt) + 100
for _, m in ipairs(conversation) do
total_est = total_est + estimate_tokens(m.content)
end
if daily_usage + total_est > TOKEN_LIMIT_PER_DAY then
return nil, "This request would exceed daily token limit"
end
local messages = {}
for _, m in ipairs(conversation) do messages[#messages+1] = m end
messages[#messages+1] = { role = "user", content = prompt }
local payload = json.encode({
model = "deepseek-chat",
messages = messages,
temperature = 0.5,
})
local resp_body = curl_post(
DEEPSEEK_API_URL,
{
["Authorization"] = "Bearer " .. DEEPSEEK_API_KEY,
["Content-Type"] = "application/json",
},
payload
)
if not resp_body or resp_body == "" then
return nil, "Empty response from DeepSeek API"
end
local ok, data = pcall(json.decode, resp_body)
if not ok then
err("DeepSeek JSON error: " .. tostring(data))
err("Raw: " .. resp_body:sub(1, 300))
return nil, "JSON parse error"
end
if data.choices then
local ai_reply = data.choices[1].message.content
local usage = data.usage or {}
local reply_tokens = usage.completion_tokens or estimate_tokens(ai_reply)
save_message(user_id, "assistant", ai_reply, reply_tokens)
if #prompt < 100 then cache_set(cache_key, data) end
info(string.format("DeepSeek OK user=%d tokens=%d", user_id, reply_tokens))
return data
elseif data.error then
local emsg = data.error.message or json.encode(data.error)
err("DeepSeek API error: " .. tostring(emsg))
return nil, tostring(emsg)
else
err("DeepSeek unknown response: " .. resp_body:sub(1, 300))
return nil, "Unknown API response"
end
end
-- ===== Send =====
local function send_message(chat_id, text, reply_to)
local payload = {
chat_id = chat_id,
text = sanitize_text(text),
parse_mode = "Markdown",
disable_web_page_preview = true,
}
if reply_to then payload.reply_to_message_id = reply_to end
local res = tg_call("sendMessage", payload)
if not res or not res.ok then
-- retry without Markdown
payload.parse_mode = nil
res = tg_call("sendMessage", payload)
end
return res
end
-- ===== HANDLERS =====
local function handle_start(msg)
local user_id = msg.from.id
info("handle_start user=" .. user_id)
clear_user_conversation(user_id)
save_message(user_id, "system", "You are a helpful AI assistant for Telegram users.")
send_message(msg.chat.id,
"🤖 *DeepSeek AI Bot*\n\n"
.. "Conversation history is saved locally.\n"
.. "/clear — start a new conversation\n"
.. "/history — show recent messages\n"
.. "/tokens — daily token usage",
msg.message_id
)
end
local function handle_clear(msg)
info("handle_clear user=" .. msg.from.id)
clear_user_conversation(msg.from.id)
send_message(msg.chat.id, "🔄 *Conversation reset*", msg.message_id)
end
local function handle_history(msg)
local messages = get_user_messages(msg.from.id, 20)
if #messages == 0 then
send_message(msg.chat.id, "📜 *Conversation history is empty*", msg.message_id)
return
end
local lines = { "📜 *Recent Conversation:*\n" }
local start = math.max(1, #messages - 4)
for i = start, #messages do
local m = messages[i]
local role = (m.role == "user") and "You" or "AI"
local text = m.content:sub(1, 200)
if #m.content > 200 then text = text .. "" end
lines[#lines+1] = string.format("*%s:* %s\n", role, text)
end
send_message(msg.chat.id, table.concat(lines, "\n"), msg.message_id)
end
local function handle_tokens(msg)
local user_id = msg.from.id
local daily_usage = get_daily_token_usage(user_id)
local pct = string.format("%.1f", daily_usage / TOKEN_LIMIT_PER_DAY * 100)
send_message(msg.chat.id,
string.format(
"🧮 *Daily Token Usage:*\n\n"
.. "• Used today: %d\n"
.. "• Daily limit: %d\n"
.. "• Percentage: %s%%\n\n"
.. "Stats reset at midnight UTC.",
daily_usage, TOKEN_LIMIT_PER_DAY, pct
),
msg.message_id
)
end
local function handle_text(msg, clean_text)
local user_id = msg.from.id
local chat_id = msg.chat.id
local text = clean_text or msg.text
info(string.format("handle_text user=%d chat=%d text=%q",
user_id, chat_id, text:sub(1, 80)))
if not check_rate(user_id) then
send_message(chat_id, "⏳ *Too many requests!* Please wait 1 minute.", msg.message_id)
return
end
tg_call("sendChatAction", { chat_id = chat_id, action = "typing" })
local data, api_err = call_deepseek(user_id, text)
if api_err then
err("DeepSeek error user=" .. user_id .. ": " .. api_err)
send_message(chat_id, "❌ *Error:* " .. api_err, msg.message_id)
return
end
local answer = data.choices[1].message.content
send_message(chat_id, answer, msg.message_id)
end
-- ===== manager =====
local function dispatch(msg)
if not msg then dbg("dispatch: nil message") return end
if not msg.text then dbg("dispatch: no text") return end
dbg(string.format("dispatch: chat_type=%s text=%q",
tostring(msg.chat and msg.chat.type), msg.text:sub(1, 80)))
local text = msg.text
-- commands run any
if text == "/start" or text:find("^/start@") then handle_start(msg)
elseif text == "/clear" or text:find("^/clear@") then handle_clear(msg)
elseif text == "/history" or text:find("^/history@") then handle_history(msg)
elseif text == "/tokens" or text:find("^/tokens@") then handle_tokens(msg)
elseif text:sub(1,1) ~= "/" then
-- for standart msg — check public filter
local should_reply, clean_text = group_filter(msg)
if should_reply then
handle_text(msg, clean_text)
end
end
end
-- ===== LONG POLLING =====
local function run()
info("Lua version: " .. _VERSION)
info("Token configured: " .. (TELEGRAM_BOT_TOKEN ~= "YOUR_BOT_TOKEN" and "YES" or "NO"))
info("DeepSeek key configured: " .. (DEEPSEEK_API_KEY ~= "YOUR_DEEPSEEK_KEY" and "YES" or "NO"))
info("Debug mode: " .. (DEBUG and "ON" or "OFF"))
info("Proxy: " .. (PROXY_URL ~= "" and PROXY_URL or "none"))
info("Group mention only: " .. (GROUP_MENTION_ONLY and "ON" or "OFF"))
local test = io.popen("curl --version 2>&1 | head -1")
if test then info("curl: " .. (test:read("*l") or "?")); test:close() end
info("Checking token via getMe...")
local me = tg_call("getMe", {})
if me and me.ok then
BOT_USERNAME = me.result.username or ""
info("Bot: @" .. BOT_USERNAME)
else
err("getMe failed — проверь TELEGRAM_BOT_TOKEN!")
os.exit(1)
end
init_database()
info("Bot started. Long polling...")
local offset = 0
while true do
dbg("getUpdates offset=" .. offset)
local res = tg_call("getUpdates", {
offset = offset,
timeout = 30,
allowed_updates = { "message" },
})
if res and res.ok and res.result then
local count = #res.result
if count > 0 then info("getUpdates: " .. count .. " update(s)") end
for _, update in ipairs(res.result) do
offset = update.update_id + 1
dbg("update_id=" .. update.update_id)
local ok, e = pcall(dispatch, update.message)
if not ok then err("dispatch error: " .. tostring(e)) end
end
else
err("getUpdates failed, sleeping 5s...")
if res then err("Response: " .. json.encode(res)) end
socket.sleep(5)
end
end
end
run()
@@ -118,7 +118,7 @@ const glossary = {
prefmt: '%s_nodedomain',
field: 'proxy-server-nameserver-policy',
},
node: {
node: { // outbound
prefmt: 'node_%s',
field: 'proxies',
},
@@ -207,13 +207,21 @@ const outbound_type = [
const preset_outbound = {
full: [
['DIRECT'], // built-in Outbound
['REJECT'], // built-in Outbound
['REJECT-DROP'], // built-in Outbound
['PASS'], // built-in Outbound
['PASS-RULE'], // built-in Outbound
['COMPATIBLE'], // built-in Outbound
['GLOBAL'] // built-in Proxy Group
],
proxy: [ // built-in Outbound
['DIRECT'],
['REJECT'],
['REJECT-DROP'],
['PASS'],
['PASS-RULE'],
['COMPATIBLE'],
['GLOBAL']
['COMPATIBLE']
],
direct: [
['', _('null')],
@@ -295,6 +295,7 @@ function renderListeners(s, uciconfig, isClient) {
]
o = s.taboption('field_general', hm.GenValue, 'sudoku_key', _('Key'),
_('The ED25519 master public key or UUID generated by Sudoku.'));
o.password = true;
o.hm_options = {
type: sudoku_keytypes[0][0],
callback: function(result) {
@@ -13,7 +13,7 @@ const parseProxyGroupYaml = hm.parseYaml.extend({
if (!cfg.type)
return null;
// key mapping // 2026/06/06
// key mapping // 2026/06/10
let config = hm.removeBlankAttrs({
id: this.id,
label: this.label,
@@ -1083,12 +1083,12 @@ return view.extend({
so.default = so.disabled;
so.editable = true;
so = ss.taboption('field_general', form.ListValue, 'empty_fallback', _('Empty fallback'));
so.default = 'COMPATIBLE';
hm.preset_outbound.full.forEach((res) => {
so = ss.taboption('field_general', form.Value, 'empty_fallback', _('Empty fallback'));
so.default = ''; // COMPATIBLE
hm.preset_outbound.proxy.forEach((res) => {
so.value.apply(so, res);
})
so.load = L.bind(hm.loadProxyGroupLabel, so, hm.preset_outbound.full);
so.load = L.bind(hm.loadNodeLabel, so, hm.preset_outbound.proxy);
so.modalonly = true;
/* Override fields */
@@ -385,6 +385,24 @@ return view.extend({
so = ss.option(form.DummyValue, '_china_list_version', _('China list version'));
so.cfgvalue = function() { return renderResVersion.call(this, null, 'china_list') };
so = ss.option(form.Value, 'github_token', _('GitHub token'));
so.password = true;
so.renderWidget = function(/* ... */) {
let node = form.Value.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
class: 'cbi-button cbi-button-apply',
title: _('Save'),
click: ui.createHandlerFn(this, () => {
return this.map.save(null, true).then(() => {
ui.changes.apply(true);
});
}, this.option)
}, [ _('Save') ]));
return node;
}
/* Overview END */
/* General START */
@@ -14,6 +14,16 @@ document.querySelector('head').appendChild(E('link', {
'href': L.resource('view/fchomo/node.css')
}));
const age_encryption = {
keypairs: {
types: [
['age-x25519', _('age-x25519')],
['age-mlkem768-x25519', _('age-mlkem768-x25519')],
['age-convert', _('Derive from priv-key')]
]
}
};
const CBIBubblesValue = form.DummyValue.extend({
__name__: 'CBI.BubblesValue',
@@ -94,7 +104,7 @@ const parseProviderYaml = hm.parseYaml.extend({
if (!cfg.type)
return null;
// key mapping // 2026/01/17
// key mapping // 2026/06/06
let config = hm.removeBlankAttrs({
id: this.id,
label: this.label,
@@ -107,6 +117,7 @@ const parseProviderYaml = hm.parseYaml.extend({
size_limit: cfg["size-limit"],
interval: cfg.interval,
proxy: cfg.proxy ? hm.preset_outbound.full.map(([key, label]) => key).includes(cfg.proxy) ? cfg.proxy : this.calcID(hm.glossary["proxy_group"].field, cfg.proxy) : null,
age_private_key: cfg["age-secret-key"],
header: cfg.header ? JSON.stringify(cfg.header, null, 2) : null, // string: object
/* Health fields */
health_enable: this.bool2str(this.jq(cfg, "health-check.enable")), // bool
@@ -1519,6 +1530,7 @@ return view.extend({
' interval: 3600\n' +
' proxy: DIRECT\n' +
' size-limit: 0\n' +
' age-secret-key: AGE-SECRET-KEY-1ZTQLLN0A4U3ZTT3DCZKYN0CGZEZQLWX2DFTXUWMT4ZHR0N2UG6LSW9NT0N\n' +
' header:\n' +
' User-Agent:\n' +
' - "mihomo/1.18.3"\n' +
@@ -1526,6 +1538,8 @@ return view.extend({
" - 'application/vnd.github.v3.raw'\n" +
' Authorization:\n' +
" - 'token 1231231'\n" +
' X-Age-Public-Key:\n' +
" - 'age1xh86kh9v23vattr58yedspm3f57sxvnswu9krr6ns438amekx5gsd09uma'\n" +
' health-check:\n' +
' enable: true\n' +
' interval: 600\n' +
@@ -1701,9 +1715,74 @@ return view.extend({
//so.editable = true;
so.depends('type', 'http');
so = ss.taboption('field_general', hm.GenValue, 'age_private_key', _('age private key'));
so.password = true;
so.hm_options = {
type: age_encryption.keypairs.types[0][0],
params: '',
callback: function(result) {
const section_id = this.section.section;
let header = {};
try {
header = JSON.parse(this.section.formvalue(section_id, 'header').trim());
} catch {}
header['X-Age-Public-Key'] = [result.public_key].filter(Boolean);
return [
[this.option, this.hm_options.params || result.private_key],
['age_public_key', result.public_key],
['header', JSON.stringify(header, null, 2)]
]
}
}
so.renderWidget = function(section_id, option_index, cfgvalue) {
let node = form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
const cbid = this.cbid(section_id) + '._keytype_select';
const selected = this.hm_options.type;
let selectEl = E('select', {
id: cbid,
class: 'cbi-input-select',
style: 'width: 10em',
});
age_encryption.keypairs.types.forEach(([k, v]) => {
selectEl.appendChild(E('option', {
'value': k,
'selected': (k === selected) ? '' : null
}, [ v ]));
});
node.appendChild(E('div', { 'class': 'control-group' }, [
selectEl,
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hm_options.type = document.getElementById(cbid).value;
if (this.hm_options.type === 'age-convert')
this.hm_options.params = this.formvalue(section_id);
else
this.hm_options.params = '';
return hm.handleGenKey.call(this, this.hm_options);
})
}, [ _('Generate') ])
]));
return node;
}
so.depends('type', 'http');
so.modalonly = true;
so = ss.taboption('field_general', hm.CopyValue, 'age_public_key', _('age public key'));
so.depends('type', 'http');
so.modalonly = true;
so = ss.taboption('field_general', hm.TextValue, 'header', _('HTTP header'),
_('Custom HTTP header.'));
so.placeholder = '{\n "User-Agent": [\n "mihomo/1.18.3"\n ],\n "Accept": [\n //"application/vnd.github.v3.raw"\n ],\n "Authorization": [\n //"token 1231231"\n ]\n}';
so.placeholder = '{\n "User-Agent": [\n "mihomo/1.18.3"\n ],\n "Accept": [\n //"application/vnd.github.v3.raw"\n ],\n "Authorization": [\n //"token 1231231"\n ]\n "X-Age-Public-Key": [\n //"age1xh86kh9v23vattr58yedspm3f57sxvnswu9krr6ns438amekx5gsd09uma"\n ]\n}';
so.validate = hm.validateJson;
so.depends('type', 'http');
so.modalonly = true;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
. /usr/share/libubox/jshn.sh
CONF="fchomo"
GITHUB_TOKEN="$(uci -q get $CONF.resources.github_token)"
RESOURCES_DIR="/etc/$CONF/resources"
VER_PATH="/etc/$CONF/resources.json"
@@ -40,6 +41,7 @@ check_dashboard_update() {
local dashrepoid="$(echo -n "$dashrepo" | sed 's|\W|_|g' | tr 'A-Z' 'a-z')"
local lock="$RUN_DIR/update_resources-$dashtype.lock"
local wget="wget --tries=1 --timeout=10 -q"
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
exec 200>"$lock"
if ! flock -n 200 &> "/dev/null"; then
@@ -47,9 +49,9 @@ check_dashboard_update() {
return 2
fi
local dash_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/releases/latest" | jsonfilter -e "@.tag_name" 2>/dev/null)"
local dash_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$dashrepo/releases/latest" | jsonfilter -qe "@.tag_name" 2>/dev/null)"
[ -n "$dash_ver" ] || {
dash_ver="$($wget -O- "https://api.github.com/repos/$dashrepo/tags" | jsonfilter -e "@[*].name" | head -n1)"
dash_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$dashrepo/tags" | jsonfilter -qe "@[*].name" | head -n1)"
}
if [ -z "$dash_ver" ]; then
log "[$(to_upper "$dashtype")] [$dashrepo] Failed to get the latest version, please retry later."
@@ -65,7 +67,7 @@ check_dashboard_update() {
log "[$(to_upper "$dashtype")] [$dashrepo] Local version: $local_dash_ver, latest version: $dash_ver."
fi
if ! $wget "https://codeload.github.com/$dashrepo/tar.gz/refs/heads/gh-pages" -O "$RUN_DIR/$dashtype.tgz" || ! tar -tzf "$RUN_DIR/$dashtype.tgz" >/dev/null; then
if ! $wget "${github_token:--q}" "https://codeload.github.com/$dashrepo/tar.gz/refs/heads/gh-pages" -O "$RUN_DIR/$dashtype.tgz" || ! tar -tzf "$RUN_DIR/$dashtype.tgz" >/dev/null; then
rm -f "$RUN_DIR/$dashtype.tgz"
log "[$(to_upper "$dashtype")] [$dashrepo] Update failed."
return 1
@@ -90,6 +92,7 @@ check_geodata_update() {
local georepo="$2"
local lock="$RUN_DIR/update_resources-$geotype.lock"
local wget="wget --tries=1 --timeout=10 -q"
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
exec 200>"$lock"
if ! flock -n 200 &> "/dev/null"; then
@@ -97,7 +100,7 @@ check_geodata_update() {
return 2
fi
local geodata_ver="$($wget -O- "https://api.github.com/repos/$georepo/releases/latest" | jsonfilter -e "@.tag_name")"
local geodata_ver="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$georepo/releases/latest" | jsonfilter -qe "@.tag_name")"
if [ -z "$geodata_ver" ]; then
log "[$(to_upper "$geotype")] Failed to get the latest version, please retry later."
return 1
@@ -113,8 +116,8 @@ check_geodata_update() {
fi
local geodata_hash
$wget "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat" -O "$RUN_DIR/$geotype.dat"
geodata_hash="$($wget -O- "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat.sha256sum" | awk '{print $1}')"
$wget "${github_token:--q}" "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat" -O "$RUN_DIR/$geotype.dat"
geodata_hash="$($wget "${github_token:--q}" -O- "https://github.com/$georepo/releases/download/$geodata_ver/$geotype.dat.sha256sum" | awk '{print $1}')"
if ! echo -e "$geodata_hash $RUN_DIR/$geotype.dat" | sha256sum -s -c -; then
rm -f "$RUN_DIR/$geotype.dat"
log "[$(to_upper "$geotype")] Update failed."
@@ -139,6 +142,7 @@ check_list_update() {
local listname="$4"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local wget="wget --tries=1 --timeout=10 -q"
[ -z "$GITHUB_TOKEN" ] || local github_token="--header=Authorization: Bearer $GITHUB_TOKEN"
exec 200>"$lock"
if ! flock -n 200 &> "/dev/null"; then
@@ -146,9 +150,9 @@ check_list_update() {
return 2
fi
local list_info="$($wget -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname")"
local list_sha="$(echo -e "$list_info" | jsonfilter -e "@[0].sha")"
local list_ver="$(echo -e "$list_info" | jsonfilter -e "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d ' \n-')"
local list_info="$($wget "${github_token:--q}" -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname")"
local list_sha="$(echo -e "$list_info" | jsonfilter -qe "@[0].sha")"
local list_ver="$(echo -e "$list_info" | jsonfilter -qe "@[0].commit.message" | grep -Eo "[0-9-]+" | tr -d ' \n-')"
if [ -z "$list_sha" ] || [ -z "$list_ver" ]; then
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
return 1
@@ -121,18 +121,7 @@ function parse_filter(cfg) {
return cfg;
}
function get_proxynode(cfg) {
if (isEmpty(cfg))
return null;
const label = uci.get(uciconf, cfg, 'label');
if (isEmpty(label))
die(sprintf("%s's label is missing, please check your configuration.", cfg));
else
return label;
}
function get_proxygroup(cfg) {
function get_proxy(cfg) {
if (isEmpty(cfg))
return null;
@@ -164,7 +153,7 @@ function get_nameserver(cfg, detour) {
});
} else
push(servers, replace(dnsservers[k]?.address || '', /#detour=([^&]+)/, (m, c1) => {
return '#' + urlencode(get_proxygroup(detour || c1));
return '#' + urlencode(get_proxy(detour || c1));
}));
}
@@ -177,7 +166,7 @@ function parse_entry(cfg) {
let rule = json(cfg);
if (rule.detour)
rule.detour = get_proxygroup(rule.detour);
rule.detour = get_proxy(rule.detour);
function _payloadStrategy(payload) {
// LOGIC_TYPE,((payload1),(payload2))
@@ -225,7 +214,7 @@ uci.foreach(uciconf, ucichain, (cfg) => {
return;
dialerproxy[identifier] = {
detour: get_proxygroup(cfg.chain_tail_group) || get_proxynode(cfg.chain_tail)
detour: get_proxy(cfg.chain_tail_group) || get_proxy(cfg.chain_tail)
};
});
@@ -349,7 +338,7 @@ uci.foreach(uciconf, uciinbd, (cfg) => {
if (cfg.enabled === '0')
return;
push(config.listeners, parseListener(cfg, true, get_proxygroup(cfg.proxy)));
push(config.listeners, parseListener(cfg, true, get_proxy(cfg.proxy)));
});
/* Tun settings */
if (match(proxy_mode, /tun/))
@@ -738,14 +727,14 @@ uci.foreach(uciconf, ucipgrp, (cfg) => {
name: cfg.label,
type: cfg.type,
proxies: [
...map(cfg.groups || [], cfg => get_proxygroup(cfg)),
...map(cfg.proxies || [], cfg => get_proxynode(cfg))
...map(cfg.groups || [], cfg => get_proxy(cfg)),
...map(cfg.proxies || [], cfg => get_proxy(cfg))
],
use: cfg.use,
"include-all": strToBool(cfg.include_all),
"include-all-proxies": strToBool(cfg.include_all_proxies),
"include-all-providers": strToBool(cfg.include_all_providers),
"empty-fallback": cfg.empty_fallback ? get_proxygroup(cfg.empty_fallback) : null,
"empty-fallback": cfg.empty_fallback ? get_proxy(cfg.empty_fallback) : null,
// Url-test fields
tolerance: (cfg.type === 'url-test') ? strToInt(cfg.tolerance) ?? 150 : null,
// Load-balance fields
@@ -786,7 +775,8 @@ uci.foreach(uciconf, uciprov, (cfg) => {
url: cfg.url,
"size-limit": bytesizeToByte(cfg.size_limit) || null,
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) ?? 86400 : null,
proxy: get_proxygroup(cfg.proxy),
proxy: get_proxy(cfg.proxy),
"age-secret-key": cfg.age_private_key,
header: cfg.header ? json(cfg.header) : null,
/* Health fields */
"health-check": cfg.health_enable === '0' ? {enable: false} : {
@@ -843,7 +833,7 @@ uci.foreach(uciconf, ucirule, (cfg) => {
"path-in-bundle": cfg.path_in_bundle,
"size-limit": bytesizeToByte(cfg.size_limit) || null,
interval: (cfg.type === 'http') ? durationToSecond(cfg.interval) ?? 259200 : null,
proxy: get_proxygroup(cfg.proxy),
proxy: get_proxy(cfg.proxy),
header: cfg.header ? json(cfg.header) : null
})
};
@@ -42,8 +42,8 @@
"path": "fchomo/inbound"
}
},
"admin/services/fchomo/node": {
"title": "Node",
"admin/services/fchomo/outbound": {
"title": "Outbound",
"order": 30,
"action": {
"type": "view",
@@ -2,9 +2,9 @@
'use strict';
import { access, lsdir, lstat, popen, readfile, writefile } from 'fs';
import { access, lsdir, lstat, mkstemp, popen, readfile, writefile } from 'fs';
import {
shellQuote, isBinary, yqRead, yqReadFile,
shellQuote, isBinary, executeCommand, yqRead, yqReadFile,
HM_DIR, EXE_DIR, SDL_DIR, RUN_DIR
} from 'fchomo';
@@ -44,13 +44,26 @@ const methods = {
args: { type: 'type', params: 'params' },
call: function(req) {
/* https://github.com/MetaCubeX/mihomo/blob/Alpha/component/generator/cmd.go */
if (!(req.args?.type in ['uuid', 'reality-keypair', 'wg-keypair', 'ech-keypair', 'vless-mlkem768', 'vless-x25519', 'sudoku-keypair']))
if (!(req.args?.type in ['uuid',
'reality-keypair', 'wg-keypair', 'sudoku-keypair',
'age-x25519', 'age-mlkem768-x25519', 'age-convert',
'vless-mlkem768', 'vless-x25519',
'ech-keypair'])) {
return { result: false, error: 'illegal type' };
}
const type = req.args?.type;
let cmd = `generate ${type}`;
let result = {};
const fd = popen('/usr/bin/mihomo generate ' + type + (req.args?.params ? ' ' + shellQuote(req.args.params) : ''));
if (type === 'age-x25519') {
cmd = 'age keygen';
} else if (type === 'age-mlkem768-x25519') {
cmd = 'age keygen-pq';
} else if (type === 'age-convert') {
cmd = 'age convert';
}
const fd = popen(`/usr/bin/mihomo ${cmd}` + (req.args?.params ? ' ' + shellQuote(req.args.params) : ''));
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (type === 'uuid')
@@ -62,6 +75,15 @@ const methods = {
let pub = match(trim(line), /PublicKey: (.*)/);
if (pub)
result.public_key = pub[1];
} else if (type in ['age-x25519', 'age-mlkem768-x25519']) {
let priv = match(trim(line), /AGE-SECRET-KEY-.*/);
if (priv)
result.private_key = priv[0];
let pub = match(trim(line), /# public key: (.*)/);
if (pub)
result.public_key = pub[1];
} else if (type in ['age-convert']) {
result.public_key = trim(line);
} else if (type in ['vless-x25519', 'vless-mlkem768']) {
let priv = match(trim(line), /PrivateKey: (.*)/);
if (priv)
@@ -96,6 +118,56 @@ const methods = {
}
},
age_crypto: {
args: { action: 'action', key: 'key', content: 'content'},
call: function(req) {
if (!(req.args?.action in ['decrypt', 'encrypt']))
return { result: false, error: 'illegal action' };
if (!req.args?.key)
return { result: false, error: 'illegal key' };
if (!req.args?.content)
return { result: '' };
let content = req.args?.content;
let infd = mkstemp();
if (content) {
content = trim(content);
content = replace(content, /\r\n?/g, '\n');
}
infd.write(content);
infd.seek();
const out = executeCommand(infd, '/usr/bin/mihomo age', req.args?.action, req.args?.key, '-', '-');
infd.close();
return out.exitcode === 0 ? { result: out.stdout } : { result: false, error: out.stderr };
}
},
age_crypto_file: {
args: { action: 'action', key: 'key', input: 'input', output: 'output'},
call: function(req) {
if (!(req.args?.action in ['decrypt', 'encrypt']))
return { result: false, error: 'illegal action' };
if (!req.args?.key)
return { result: false, error: 'illegal key' };
if ((!req.args?.input) || match(req.args?.input, /\.\.\//))
return { result: false, error: 'illegal input' };
if ((!req.args?.output) || match(req.args?.output, /\.\.\//))
return { result: false, error: 'illegal output' };
const out = executeCommand(null, '/usr/bin/mihomo age', req.args?.action, req.args?.key, req.args?.input, req.args?.output);
return out.exitcode === 0 ? { result: true } : { result: false, error: out.stderr };
}
},
get_features: {
call: function() {
let features = {};
+43 -13
View File
@@ -62,7 +62,7 @@ The Vite development server uses middleware to rewrite local requests to serve C
1. Proxies `/cgi-bin` and `/luci-static` requests to OpenWrt device
2. Uses middleware (`createLocalServePlugin`) to rewrite request paths for CSS and JS files
3. CSS requests to `/luci-static/aurora/main.css` are rewritten to serve from `.dev/src/media/main.css`
3. CSS requests to `/luci-static/aurora/main.css` and `/luci-static/aurora/login.css` are rewritten to serve from `.dev/src/media/main.css` and `.dev/src/media/login.css` respectively
4. JS file requests are served directly from `.dev/src/resource/` with middleware reading and returning file content
5. Injects Vite HMR client into proxied HTML responses for live reload support
6. Redirects `/` to `/cgi-bin/luci` for proper routing
@@ -83,6 +83,20 @@ Thanks to **lightningcss**, you can freely use [CSS Nesting syntax](https://draf
This will be compiled to standard CSS that works in all browsers.
### CSS Architecture
The theme has two independent Tailwind CSS v4 entry points, both sourced from `.dev/src/media/`:
- **`main.css`** — the LuCI admin UI. It contains no rules of its own; it's a pure import manifest that pulls in (in order) `_tokens.css` (OKLCH theme tokens, mapped via `@theme inline`), `_base.css`, `_layout.css`, every file in `components/` (one partial per UI component — buttons, cards, modals, tables, etc.), `_utilities.css`, and `_patches.css`.
- **`login.css`** — the standalone login page (`sysauth.ut`). Self-contained: imports Tailwind and `_tokens.css` directly.
**Adding new styles:**
- New UI component → create `components/_<name>.css` and add an `@import` line to `main.css`. Each file is its own organizational unit — no `@layer` wrappers needed (any that remain are stripped by PostCSS).
- Compatibility fix for a third-party LuCI app/page → add a narrow, selector-scoped rule to `_patches.css` under a comment naming the app (e.g. `/* luci-app-openclash */`).
All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS properties.
### LuCI JavaScript API
For LuCI-specific JavaScript development, refer to the official API documentation:
@@ -150,7 +164,8 @@ This compiles all assets to the production directory `htdocs/luci-static/`, whic
```
htdocs/luci-static/
├── aurora/
│ ├── main.css # Minified CSS (via lightningcss)
│ ├── main.css # Minified admin UI CSS (via lightningcss)
│ ├── login.css # Minified login page CSS (via lightningcss)
│ ├── fonts/ # Web fonts (Lato)
│ └── images/ # Logo assets + PWA icons
└── resources/
@@ -159,7 +174,7 @@ htdocs/luci-static/
**Build Process:**
1. Vite builds CSS entry point (`src/media/main.css`)
1. Vite builds the CSS entry points (`src/media/main.css` and `src/media/login.css`)
2. Custom PostCSS plugin removes `@layer` at-rules for OpenWrt compatibility
3. Custom Vite plugin (`luci-js-compress`) minifies JS files via Terser
4. Static assets copied from `.dev/public/aurora/`
@@ -175,17 +190,22 @@ htdocs/luci-static/
**Build `.ipk`/`.apk` packages:**
1. Push a version tag (`v*`) or push to `master` with `[build]` in the commit message
2. The `build-theme-package` workflow compiles the OpenWrt package
1. Push a version tag (`v*`), push to `master`/`feat/**` with `[build]` in the commit message, or manually trigger the workflow
2. The `build-theme-package` workflow compiles both `.ipk` and `.apk` OpenWrt packages
**PR checks:**
**PR review:**
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically linted and build-verified by the `pr-check` workflow.
Pull requests that touch `.dev/`, `htdocs/`, `ucode/`, or `root/` are automatically reviewed by the `claude-pr-review` workflow — it posts inline comments on the source diff (generated `htdocs/` output is excluded) plus a summary comment. Mention `@claude` in a PR comment to request a follow-up review or ask a question.
**Issue triage:**
New issues are handled by the `claude-issue-bot` workflow — it checks for spam/duplicates, applies labels, and posts a deep technical analysis comment. Mention `@claude` in an issue comment to get a response.
**Workflow Files:** `.github/workflows/`
- `frontend-assets-build.yml` — Build assets and auto-commit
- `frontend-assets-build.yml` — Build assets and auto-commit (manual trigger)
- `build-theme-package.yml` — Compile `.ipk`/`.apk` packages
- `pr-check.yml` — Lint and build verification for PRs
- `claude-pr-review.yml` — AI code review for PRs (inline + summary comments)
- `claude-issue-bot.yml` — AI issue triage and analysis
## Directory Structure
@@ -202,8 +222,15 @@ luci-theme-aurora/
│ │ └── clean.js # Build cleanup utility
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS entry points
│ │ │ ── main.css # Main stylesheet (Tailwind CSS)
│ │ ├── media/ # CSS source (Tailwind CSS v4)
│ │ │ ── main.css # Admin UI entry point (import manifest)
│ │ │ ├── login.css # Login page entry point
│ │ │ ├── _tokens.css # OKLCH theme tokens (@theme inline)
│ │ │ ├── _base.css # Base element styles
│ │ │ ├── _layout.css # Page layout/structure
│ │ │ ├── _utilities.css # Custom utility classes
│ │ │ ├── _patches.css # Third-party LuCI app/page overrides
│ │ │ └── components/ # One partial per UI component
│ │ └── resource/ # JavaScript resources
│ │ └── menu-aurora.js # Menu logic
│ ├── .env.example # Environment variables template
@@ -221,7 +248,8 @@ luci-theme-aurora/
│ ├── aurora/ # Theme CSS and assets
│ │ ├── fonts/ # Built font files
│ │ ├── images/ # Built images + PWA icons
│ │ ── main.css # Compiled CSS
│ │ ── main.css # Compiled admin UI CSS
│ │ └── login.css # Compiled login page CSS
│ └── resources/ # Built JavaScript modules
│ └── menu-aurora.js # Minified menu logic
├── root/etc/uci-defaults/ # OpenWrt system integration
@@ -240,8 +268,10 @@ luci-theme-aurora/
- **[Tailwind CSS v4](https://tailwindcss.com/)** - Utility-first CSS framework
- **[Vite](https://vitejs.dev/)** - Build tool and development server
**[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
- **[pnpm](https://pnpm.io/)** - Fast, disk space efficient package manager
- **[lightningcss](https://lightningcss.dev/)** - CSS minifier
- **[Terser](https://terser.org/)** - JavaScript minifier
- **[Prettier](https://prettier.io/)** - Code formatter
- **[prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)** - Tailwind class sorting
- **[tw-animate-css](https://github.com/Wombosvideo/tw-animate-css)** - Animation utilities for Tailwind CSS
- **[tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar)** - Custom scrollbar styling plugin
+23 -4
View File
@@ -1,8 +1,8 @@
/*
PATCH LAYER (temporary)
- Purpose: Quick fixes and app-specific overrides that may be removed at any time.
- Scope: Keep selectors narrow and limited to known breakages only.
- Migration: Promote stable rules to appropriate layers (e.g., components/plugins) or delete once fixed upstream.
PLUGIN/PAGE PATCHES
- Purpose: Compatibility overrides for third-party LuCI apps/pages that don't adapt to the theme.
- Scope: Keep selectors narrow and limited to the specific app/page being patched.
- Note: May need to be revisited when the corresponding plugin updates its markup/styles.
*/
/* admin-network-network */
@@ -10,7 +10,21 @@
@apply max-md:overflow-visible!;
}
/* luci-app-filemanager */
#file-manager-container {
@apply overflow-auto;
#status-bar {
@apply bg-page-bg border-0;
}
}
/* luci-app-openclash */
.diag-style {
@apply max-md:grid max-md:grid-cols-1 max-md:gap-4;
& > div {
@apply flex flex-col items-center gap-2 max-md:w-full! max-md:gap-1;
}
}
[data-page="admin-services-openclash-config"] {
.sub_div {
img {
@@ -26,6 +40,11 @@
}
}
/* luci-app-statistics */
[data-page="admin-statistics-graphs"] [data-plugin] img {
@apply dark:hue-rotate-150 dark:invert;
}
/* luci-mod-dashboard */
.Dashboard {
& > .section-content {
@@ -1,20 +0,0 @@
/* luci-app-openclash */
.diag-style {
@apply max-md:grid max-md:grid-cols-1 max-md:gap-4;
& > div {
@apply flex flex-col items-center gap-2 max-md:w-full! max-md:gap-1;
}
}
/* luci-app-filemanager */
#file-manager-container {
@apply overflow-auto;
#status-bar {
@apply bg-page-bg border-0;
}
}
/* luci-app-statistics */
[data-page="admin-statistics-graphs"] [data-plugin] img {
@apply dark:hue-rotate-150 dark:invert;
}
@@ -38,5 +38,4 @@
@import "./components/_tab.css";
@import "./_utilities.css";
@import "./_plugins.css";
@import "./_patches.css";
+3 -3
View File
@@ -19,12 +19,12 @@ No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vsc
**Dual-layer build**: source in `.dev/` → OpenWrt-compatible output in `htdocs/luci-static/`.
- `.dev/src/media/main.css``htdocs/luci-static/aurora/main.css` (TailwindCSS v4, lightningcss)
- `.dev/src/media/main.css` and `.dev/src/media/login.css``htdocs/luci-static/aurora/main.css` and `login.css` (TailwindCSS v4, lightningcss)
- `.dev/src/resource/*.js``htdocs/luci-static/resources/*.js` (Terser, no bundling)
- `.dev/public/aurora/``htdocs/luci-static/aurora/` (copied as-is)
- `ucode/template/themes/aurora/*.ut` — server-side templates, not processed by Vite
**CSS**: single entry point `.dev/src/media/main.css`. All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `:root` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility.
**CSS**: two Tailwind CSS v4 entry points in `.dev/src/media/``main.css` (admin UI; a pure import manifest over `_tokens.css`, `_base.css`, `_layout.css`, `components/*.css`, `_utilities.css`, `_patches.css`) and `login.css` (standalone login page). All styling MUST use TailwindCSS v4 utility classes via `@apply` — no raw CSS properties (e.g. write `@apply text-sm font-semibold;` not `font-size: 14px; font-weight: 600;`). Use [CSS Nesting syntax](https://drafts.csswg.org/css-nesting/) for selectors. Theme colors defined as OKLCH custom properties in `_tokens.css` and mapped via `@theme inline`. `@layer` at-rules stripped by PostCSS plugin for OpenWrt compatibility. See `.dev/docs/DEVELOPMENT.md` for the full CSS file layout.
**JavaScript**: LuCI `E()` DOM API (not React/Vue). Minified but not bundled.
@@ -35,5 +35,5 @@ No test suite or linter CLI. Formatting uses Prettier with format-on-save (`.vsc
## Key References
- **Development guide**: `.dev/docs/DEVELOPMENT.md` — dev server setup, env config, proxy details, CI workflows, directory structure
- **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, ut-sync, remove-layers)
- **Vite config**: `.dev/vite.config.ts` — custom plugins (luci-js-compress, local-serve, redirect, ut-sync, remove-layers)
- **Version**: `PKG_VERSION` and `PKG_RELEASE` in `Makefile`
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=0.12.3
PKG_RELEASE:=20
PKG_VERSION:=0.12.4
PKG_RELEASE:=21
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long