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
deepbot/Makefile Normal file
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))

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)

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
deepbot/files/usr/bin/deepbot.lua Executable file
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()

View File

@ -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')],

View File

@ -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) {

View File

@ -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 */

View File

@ -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 */

View File

@ -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

View File

@ -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

View File

@ -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
})
};

View File

@ -42,8 +42,8 @@
"path": "fchomo/inbound"
}
},
"admin/services/fchomo/node": {
"title": "Node",
"admin/services/fchomo/outbound": {
"title": "Outbound",
"order": 30,
"action": {
"type": "view",

View File

@ -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 = {};

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

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 {

View File

@ -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;
}

View File

@ -38,5 +38,4 @@
@import "./components/_tab.css";
@import "./_utilities.css";
@import "./_plugins.css";
@import "./_patches.css";

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`

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