* Add umedbazarov/ruh-vpn: VPN/proxy manager for sing-box New community plugin: bar widget, panel, service and control-center shortcut for managing SSH, VLESS, VMess, Shadowsocks and SOCKS5 connections through sing-box, with routing presets, custom rules, system-proxy/TUN modes and a kill switch. The bundled Python backend serves a loopback control API protected by a per-launch bearer token. * Address review: sanitize kill-switch ruleset, scope TUN capability, fix mux error path, disclose DNS - kill switch: only pre-resolved, canonicalized literal IPs enter the nft ruleset; domains are resolved first and anything unparseable is dropped, so subscription-supplied addresses can no longer inject nft syntax - TUN: CAP_NET_ADMIN is granted to a plugin-private copy of sing-box in a 0700 directory instead of the shared system binary; the copy is refreshed (clearing the cap) when the system binary changes, and the legacy grant on the shared binary is removed in the same polkit prompt - fix NameError in the mux startup failure path (undefined mux_name) that hid the log tail and skipped teardown - README: disclose plain-UDP DNS endpoints (8.8.8.8 via tunnel, 223.5.5.5 direct in rules mode) alongside the TUN DoH endpoint --------- Co-authored-by: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com>
914 lines
40 KiB
Luau
914 lines
40 KiB
Luau
--!nonstrict
|
|
-- panel.luau — main VPN UI. Multi-view single panel:
|
|
-- view = "main" | "editor" | "rules" | "logs"
|
|
-- Value-driven: every render reads current state; controls report changes
|
|
-- through named global callbacks. ui.input is uncontrolled (value seeds once,
|
|
-- edits flow through onChange into the module-level `form` table).
|
|
|
|
local MAX_ROWS = 20 -- clickable server rows
|
|
local MAX_PRE = 8 -- preset toggles
|
|
local MAX_RULE = 20 -- custom rule rows
|
|
local MAX_SUB = 12 -- subscription rows
|
|
local nonce = 0
|
|
|
|
local view = "main"
|
|
local slotIds = {} -- server row slot -> id
|
|
local presetKeys = {} -- preset slot -> key
|
|
local ruleIds = {} -- rule slot -> id
|
|
|
|
-- editor form
|
|
local form = {}
|
|
local formProto = "ssh"
|
|
local editingId = nil
|
|
local formRev = 0 -- bumps to reset ui.input identity on open
|
|
|
|
-- add-rule form
|
|
local ruleForm = { pattern = "", type = "force-proxy" }
|
|
local ruleRev = 0
|
|
|
|
-- subscriptions
|
|
local subUrls = {} -- sub slot -> url
|
|
local subForm = { url = "", name = "" }
|
|
local subRev = 0
|
|
|
|
local resetArmed = false -- "Reset all servers" waits for a second click
|
|
|
|
local PROTOS = { "ssh", "vless", "vmess", "shadowsocks", "socks5" }
|
|
local TRANSPORTS = { "tcp", "ws", "grpc", "http" }
|
|
local SECURITIES = { "none", "tls", "reality" }
|
|
local RULE_TYPES = { "force-proxy", "direct", "block" }
|
|
|
|
-- ── design tokens ───────────────────────────────────────────────
|
|
-- The fixed olive/chartreuse palette gives the plugin its own visual identity.
|
|
local T = {
|
|
bg = "#1b1c17",
|
|
card = "#26271f",
|
|
cardHi = "#2e2f25",
|
|
cardActive = "#34362a",
|
|
border = "#33342a",
|
|
borderSoft = "#2a2b22",
|
|
accent = "#cfe04e",
|
|
accentText = "#1b1c17",
|
|
text = "#e8e7df",
|
|
textDim = "#a3a497",
|
|
muted = "#7d7e71",
|
|
success = "#9bd17a",
|
|
danger = "#d68a7a",
|
|
pingGood = "#9bd17a",
|
|
pingMid = "#e0c84e",
|
|
pingBad = "#d68a7a",
|
|
}
|
|
|
|
-- Colour props take a role token or a plain hex. Flatten alpha against the
|
|
-- backdrop because alpha suffixes are only legal on theme roles.
|
|
local function mix(fg, bg, a)
|
|
local function ch(h, i) return tonumber(h:sub(i, i + 1), 16) end
|
|
local r = ch(bg, 2) + (ch(fg, 2) - ch(bg, 2)) * a
|
|
local g = ch(bg, 4) + (ch(fg, 4) - ch(bg, 4)) * a
|
|
local b = ch(bg, 6) + (ch(fg, 6) - ch(bg, 6)) * a
|
|
return string.format("#%02x%02x%02x", math.floor(r + 0.5), math.floor(g + 0.5), math.floor(b + 0.5))
|
|
end
|
|
|
|
-- Protocol pill colours, alpha pre-flattened.
|
|
local PROTO_TAG = {
|
|
SSH = { fg = "#9ab7d1", a = 0.12 },
|
|
VLESS = { fg = "#cfe04e", a = 0.14 },
|
|
VMess = { fg = "#c18cd9", a = 0.14 },
|
|
SS = { fg = "#d99967", a = 0.14 },
|
|
SOCKS5 = { fg = "#d99967", a = 0.14 },
|
|
}
|
|
|
|
local PROTO_LABEL = {
|
|
ssh = "SSH", vless = "VLESS", vmess = "VMess",
|
|
shadowsocks = "SS", socks5 = "SOCKS5",
|
|
}
|
|
|
|
-- ── command channel ─────────────────────────────────────────────
|
|
local function send(method, args)
|
|
nonce = nonce + 1
|
|
noctalia.state.set("cmd", {
|
|
method = method, args = args or {},
|
|
nonce = tostring(nonce) .. ":" .. tostring(os.time()),
|
|
})
|
|
end
|
|
|
|
-- ── helpers ─────────────────────────────────────────────────────
|
|
local function indexOf(list, val, dflt)
|
|
for i, v in ipairs(list) do if v == val then return i - 1 end end
|
|
return dflt or 0
|
|
end
|
|
|
|
local function pingColor(ms)
|
|
ms = tonumber(ms)
|
|
if not ms or ms < 0 then return T.muted end
|
|
if ms < 60 then return T.pingGood end
|
|
if ms < 150 then return T.pingMid end
|
|
return T.pingBad
|
|
end
|
|
|
|
-- Uppercase protocol pill.
|
|
local function protoTag(proto)
|
|
local label = PROTO_LABEL[(proto or ""):lower()]
|
|
if not label then return nil end
|
|
local tag = PROTO_TAG[label]
|
|
return ui.row({ fill = mix(tag.fg, T.card, tag.a), radius = 4, paddingH = 6, align = "center" }, {
|
|
ui.label({ text = label:upper(), color = tag.fg, fontSize = 10, fontWeight = "bold" }),
|
|
})
|
|
end
|
|
|
|
-- Coloured latency dot and milliseconds.
|
|
local function pingBadge(ms)
|
|
return ui.row({ gap = 5, align = "center" }, {
|
|
ui.box({ width = 6, height = 6, radius = 3, fill = pingColor(ms) }),
|
|
ui.label({ text = tostring(ms) .. "ms", color = T.textDim, fontSize = 11 }),
|
|
})
|
|
end
|
|
|
|
-- ISO-3166 alpha-2 to regional indicator pair.
|
|
local function flagFor(country)
|
|
if type(country) ~= "string" or #country ~= 2 then return nil end
|
|
local cc, out = country:upper(), ""
|
|
for i = 1, 2 do
|
|
local b = cc:byte(i)
|
|
if b < 65 or b > 90 then return nil end
|
|
out = out .. utf8.char(0x1F1E6 + b - 65)
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function activeName(servers, id)
|
|
for _, s in ipairs(servers) do if s.id == id then return s.name or s.id end end
|
|
return nil
|
|
end
|
|
|
|
local function num(v, dflt) return tonumber(v) or dflt end
|
|
|
|
-- Leaving the view disarms the reset confirm; it must never survive a round trip.
|
|
local function navigate(v) view = v; resetArmed = false; render() end -- render is assigned below
|
|
|
|
-- ── declared forward ────────────────────────────────────────────
|
|
render = nil
|
|
|
|
-- ================================================================
|
|
-- MAIN VIEW
|
|
-- ================================================================
|
|
-- Server row: status, flag, name, protocol, endpoint, latency and actions.
|
|
-- Row and column nodes do not accept onClick, so the name button and status
|
|
-- dot are the click targets.
|
|
local function serverRows(servers, status, running, health)
|
|
local rows = {}
|
|
slotIds = {}
|
|
for i, s in ipairs(servers) do
|
|
local slot = i - 1
|
|
if slot >= MAX_ROWS then break end
|
|
slotIds[slot] = s.id
|
|
local isSelected = s.id == status.activeServerId
|
|
local isActive = isSelected and running
|
|
|
|
local dot = (isActive and T.success) or (isSelected and mix(T.accent, T.bg, 0.7)) or T.border
|
|
-- Keep idle rows transparent instead of painting over the panel.
|
|
local fill = (isActive and T.cardActive) or (isSelected and mix(T.accent, T.bg, 0.05)) or nil
|
|
local edge = (isActive and T.border) or (isSelected and mix(T.accent, T.bg, 0.4)) or nil
|
|
|
|
local pick = "onServer" .. tostring(slot)
|
|
local title = { ui.button({ text = s.name or s.id or noctalia.tr("panel.fallback-server-name"),
|
|
variant = "ghost", contentAlign = "start", onClick = pick }) }
|
|
local tag = protoTag(s.protocol)
|
|
if tag then title[#title + 1] = tag end
|
|
|
|
local cells = { ui.box({ width = 7, height = 7, radius = 4, fill = dot, onClick = pick }) }
|
|
local flag = flagFor(s.country)
|
|
if flag then cells[#cells + 1] = ui.label({ text = flag, fontSize = 16 }) end
|
|
cells[#cells + 1] = ui.column({ gap = 2, flexGrow = 1 }, {
|
|
ui.row({ gap = 6, align = "center" }, title),
|
|
-- Indent to clear the name button's own inner padding so the endpoint
|
|
-- lines up under the name rather than under the button's edge.
|
|
ui.row({ paddingH = 15 }, {
|
|
ui.label({ text = s.host or s.address or "", color = T.muted, fontSize = 11, maxLines = 1 }),
|
|
}),
|
|
})
|
|
if isActive and health.latency_ms and health.latency_ms > 0 then
|
|
cells[#cells + 1] = pingBadge(health.latency_ms)
|
|
end
|
|
cells[#cells + 1] = ui.button({ glyph = "pencil", glyphSize = 13, variant = "ghost",
|
|
onClick = "onEdit" .. tostring(slot) })
|
|
cells[#cells + 1] = ui.button({ glyph = "trash", glyphSize = 13, variant = "ghost",
|
|
onClick = "onDel" .. tostring(slot) })
|
|
|
|
rows[#rows + 1] = ui.row({ gap = 10, align = "center", fill = fill, radius = 10,
|
|
border = edge, borderWidth = edge and 1 or nil,
|
|
paddingH = 12, paddingV = 10 }, cells)
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = ui.row({ paddingH = 12, paddingV = 10 }, {
|
|
ui.label({ text = noctalia.tr("panel.no-servers"), color = T.muted }),
|
|
})
|
|
end
|
|
return rows
|
|
end
|
|
|
|
local function hero(status, backend, running)
|
|
if not backend.ready then return "shield-off", T.muted end
|
|
local level = status.statusLevel
|
|
if running and (level == "error" or level == "failed") then return "alert-triangle", T.danger end
|
|
if running and level == "degraded" then return "alert-circle", T.pingMid end
|
|
if running then return "shield-check", T.success end
|
|
return "shield-off", T.muted
|
|
end
|
|
|
|
local function mainView()
|
|
local status = noctalia.state.get("status") or {}
|
|
local servers = noctalia.state.get("servers") or {}
|
|
local health = noctalia.state.get("health") or {}
|
|
local backend = noctalia.state.get("backend") or {}
|
|
local sp = noctalia.state.get("speedtest") or {}
|
|
local running = status.running == true
|
|
|
|
local down = (sp.down_mbps and sp.down_mbps > 0) and sp.down_mbps or health.down_mbps
|
|
local up = (sp.up_mbps and sp.up_mbps > 0) and sp.up_mbps or health.up_mbps
|
|
-- A finished test reports its own latency; prefer it over the background probe.
|
|
local ping = (sp.ping_ms and sp.ping_ms > 0) and sp.ping_ms or health.latency_ms
|
|
|
|
local heroGlyph, heroColor = hero(status, backend, running)
|
|
|
|
local heroTitle
|
|
if not backend.ready then
|
|
heroTitle = noctalia.tr("panel.starting")
|
|
elseif running then
|
|
heroTitle = noctalia.tr("panel.connected")
|
|
else
|
|
heroTitle = noctalia.tr("panel.disconnected")
|
|
end
|
|
|
|
-- Active server subtitle.
|
|
local heroSub = running and activeName(servers, status.activeServerId) or nil
|
|
|
|
-- Telemetry line.
|
|
local line2
|
|
if not backend.ready then
|
|
line2 = backend.error and ("backend: " .. backend.error) or ""
|
|
elseif running and ping and ping > 0 then
|
|
line2 = tostring(ping) .. " ms"
|
|
if down and down > 0 then
|
|
line2 = line2 .. " ↓" .. string.format("%.1f", down)
|
|
.. " ↑" .. string.format("%.1f", up or 0) .. " Mbps"
|
|
end
|
|
else
|
|
line2 = ""
|
|
end
|
|
|
|
local heroHead = {
|
|
ui.box({ width = 7, height = 7, radius = 4, fill = heroColor }),
|
|
ui.label({ text = heroTitle, color = T.text, fontSize = 15, fontWeight = "semibold" }),
|
|
}
|
|
if heroSub then
|
|
heroHead[#heroHead + 1] = ui.label({ text = "· " .. heroSub, color = T.muted,
|
|
fontSize = 14, maxLines = 1, flexGrow = 1 })
|
|
end
|
|
|
|
local heroBody = { ui.row({ gap = 8, align = "center" }, heroHead) }
|
|
if line2 ~= "" then
|
|
heroBody[#heroBody + 1] = ui.label({ text = line2, color = T.textDim, fontSize = 12, maxLines = 1 })
|
|
end
|
|
|
|
-- No width here: the column fills the panel declared in plugin.toml, so the
|
|
-- content uses the whole window instead of sitting in a 380px strip.
|
|
return ui.column({ gap = 0, flexGrow = 1 }, {
|
|
-- ── header ──────────────────────────────────────────────────
|
|
ui.row({ gap = 8, align = "center", paddingH = 16, paddingV = 14 }, {
|
|
ui.label({ text = noctalia.tr("title"), color = T.text, fontSize = 16,
|
|
fontWeight = "semibold", flexGrow = 1 }),
|
|
ui.toggle({ checked = running, onChange = "onMaster" }),
|
|
ui.button({ glyph = "cloud-download", glyphSize = 15, variant = "ghost", onClick = "onOpenSubs" }),
|
|
ui.button({ glyph = "file-text", glyphSize = 15, variant = "ghost", onClick = "onOpenLogs" }),
|
|
ui.button({ glyph = "settings", glyphSize = 15, variant = "ghost", onClick = "onOpenRules" }),
|
|
ui.button({ glyph = "x", glyphSize = 15, variant = "ghost", onClick = "onClosePanel" }),
|
|
}),
|
|
ui.separator({}),
|
|
|
|
ui.column({ gap = 14, paddingH = 16, paddingV = 14, flexGrow = 1 }, {
|
|
-- ── status hero card ──────────────────────────────────────
|
|
ui.row({ gap = 14, align = "center", fill = T.card, radius = 14,
|
|
border = T.borderSoft, borderWidth = 1, paddingH = 16, paddingV = 14 }, {
|
|
ui.row({ fill = mix(heroColor, T.card, 0.14), radius = 12,
|
|
border = mix(heroColor, T.card, 0.28), borderWidth = 1,
|
|
paddingH = 10, paddingV = 10, align = "center" }, {
|
|
ui.glyph({ name = heroGlyph, size = 22, color = heroColor }),
|
|
}),
|
|
ui.column({ gap = 3, flexGrow = 1 }, heroBody),
|
|
-- Single test button, in the hero's right corner: it drives both
|
|
-- numbers shown to its left (latency and throughput).
|
|
ui.button({ text = noctalia.tr("action.run-test"), glyph = "gauge", glyphSize = 14,
|
|
variant = "outline", onClick = "onRunTest", enabled = backend.ready == true }),
|
|
}),
|
|
|
|
-- ── mode chips ────────────────────────────────────────────
|
|
ui.row({ gap = 10 }, {
|
|
ui.column({ gap = 4, flexGrow = 1 }, {
|
|
ui.label({ text = noctalia.tr("panel.routing"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = (status.mode == "global") and 1 or 0,
|
|
options = { "rules", "global" }, onChange = "onMode", flexGrow = 1 }),
|
|
}),
|
|
ui.column({ gap = 4, flexGrow = 1 }, {
|
|
ui.label({ text = noctalia.tr("panel.via"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = (status.proxyMode == "tun") and 1 or 0,
|
|
options = { "system", "tun" }, onChange = "onVia", flexGrow = 1 }),
|
|
}),
|
|
}),
|
|
|
|
-- ── servers ───────────────────────────────────────────────
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.label({ text = noctalia.tr("panel.servers") .. " (" .. tostring(#servers) .. ")",
|
|
fontSize = 12, color = T.muted, flexGrow = 1 }),
|
|
ui.button({ glyph = "clipboard", glyphSize = 14, variant = "ghost", onClick = "onImportClip" }),
|
|
ui.button({ text = noctalia.tr("action.add"), glyph = "plus", glyphSize = 14,
|
|
variant = "primary", onClick = "onAddServer" }),
|
|
}),
|
|
-- flexGrow, not a fixed height: the list takes whatever vertical space
|
|
-- is left so the panel has no dead area at the bottom.
|
|
ui.scroll({ flexGrow = 1, gap = 4 }, { ui.column({ gap = 4 }, serverRows(servers, status, running, health)) }),
|
|
}),
|
|
})
|
|
end
|
|
|
|
-- ================================================================
|
|
-- EDITOR VIEW
|
|
-- ================================================================
|
|
local function field(key, labelKey, placeholder)
|
|
return ui.column({ gap = 2 }, {
|
|
ui.label({ text = noctalia.tr(labelKey), fontSize = 11, color = T.muted }),
|
|
ui.input({ key = "fld-" .. key .. "-" .. formRev, value = tostring(form[key] or ""),
|
|
placeholder = placeholder or "", onChange = "onFld_" .. key, flexGrow = 1 }),
|
|
})
|
|
end
|
|
|
|
local function protoFields()
|
|
local p = formProto
|
|
-- GetServers strips secrets, so an edited server's password/uuid arrive
|
|
-- empty; the backend keeps the stored value when they stay empty on save.
|
|
local keep = editingId and noctalia.tr("editor.keep") or nil
|
|
local f = {}
|
|
if p == "ssh" then
|
|
f = { field("host", "editor.host", "1.2.3.4"), field("port", "editor.port", "22"),
|
|
field("user", "editor.user", "root"), field("password", "editor.password", keep),
|
|
field("keyFile", "editor.keyfile", "~/.ssh/id_ed25519") }
|
|
elseif p == "vless" then
|
|
f = { field("address", "editor.address"), field("port", "editor.port"),
|
|
field("uuid", "editor.uuid", keep),
|
|
ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.transport"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = indexOf(TRANSPORTS, form.transport, 0), options = TRANSPORTS, onChange = "onFldTransport", flexGrow = 1 }) }),
|
|
ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.security"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = indexOf(SECURITIES, form.security, 0), options = SECURITIES, onChange = "onFldSecurity", flexGrow = 1 }) }),
|
|
field("sni", "editor.sni"), field("flow", "editor.flow"),
|
|
field("pbk", "editor.pbk"), field("sid", "editor.sid"), field("fp", "editor.fp"),
|
|
field("path", "editor.path"), field("host", "editor.wshost") }
|
|
elseif p == "vmess" then
|
|
f = { field("address", "editor.address"), field("port", "editor.port"),
|
|
field("uuid", "editor.uuid", keep), field("alterId", "editor.alterid", "0"),
|
|
ui.column({ gap = 2 }, { ui.label({ text = noctalia.tr("editor.transport"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = indexOf(TRANSPORTS, form.transport, 0), options = TRANSPORTS, onChange = "onFldTransport", flexGrow = 1 }) }),
|
|
field("sni", "editor.sni"), field("path", "editor.path"), field("host", "editor.wshost") }
|
|
elseif p == "shadowsocks" then
|
|
f = { field("address", "editor.address"), field("port", "editor.port"),
|
|
field("method", "editor.method", "aes-256-gcm"), field("password", "editor.password", keep) }
|
|
elseif p == "socks5" then
|
|
f = { field("host", "editor.host"), field("port", "editor.port"),
|
|
field("username", "editor.user"), field("password", "editor.password", keep) }
|
|
end
|
|
return f
|
|
end
|
|
|
|
local function editorView()
|
|
local children = {
|
|
ui.column({ gap = 2 }, {
|
|
ui.label({ text = noctalia.tr("editor.protocol"), fontSize = 11, color = T.muted }),
|
|
ui.select({ selectedIndex = indexOf(PROTOS, formProto, 0), options = PROTOS,
|
|
onChange = "onFldProto", flexGrow = 1, enabled = editingId == nil }),
|
|
}),
|
|
field("name", "editor.name", "my server"),
|
|
-- The flag in the server row comes from here. Nothing in the backend ever
|
|
-- derives a country (the models have no such field; they just accept extra
|
|
-- keys), so without this input flagFor() had no data and the flag could
|
|
-- never render.
|
|
field("country", "editor.country", "fi"),
|
|
}
|
|
for _, f in ipairs(protoFields()) do children[#children + 1] = f end
|
|
children[#children + 1] = ui.separator({})
|
|
children[#children + 1] = ui.row({ gap = 8 }, {
|
|
ui.button({ text = noctalia.tr("action.save"), glyph = "check", variant = "primary", onClick = "onSave" }),
|
|
ui.spacer({}),
|
|
editingId and ui.button({ text = noctalia.tr("action.delete"), glyph = "trash", glyphSize = 13,
|
|
variant = "outline", onClick = "onDelete" }) or ui.spacer({}),
|
|
})
|
|
-- Header outside the scroll, scroll on flexGrow: a fixed scroll height
|
|
-- overflowed the panel instead of clipping, leaving Save unreachable.
|
|
return ui.column({ gap = 10, padding = 16, flexGrow = 1 }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }),
|
|
ui.label({ text = editingId and noctalia.tr("editor.edit") or noctalia.tr("editor.new"),
|
|
fontWeight = "bold", fontSize = 15, flexGrow = 1 }),
|
|
}),
|
|
ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 10 }, children) }),
|
|
})
|
|
end
|
|
|
|
-- ================================================================
|
|
-- RULES VIEW
|
|
-- ================================================================
|
|
local function rulesView()
|
|
local presets = noctalia.state.get("presets") or {}
|
|
local rules = noctalia.state.get("rules") or {}
|
|
local kill = noctalia.state.get("killswitch") or {}
|
|
local dns = noctalia.state.get("dnsleak")
|
|
|
|
local dnsText, dnsColor
|
|
if dns == nil then
|
|
dnsText, dnsColor = noctalia.tr("rules.dns-hint"), T.muted
|
|
elseif dns.leaking then
|
|
dnsText, dnsColor = noctalia.tr("rules.dns-leak") .. " — " .. (dns.reason or ""), T.danger
|
|
else
|
|
dnsText, dnsColor = noctalia.tr("rules.dns-ok") .. " — " .. (dns.reason or ""), T.success
|
|
end
|
|
|
|
local presetRows = {}
|
|
presetKeys = {}
|
|
for i, p in ipairs(presets) do
|
|
local slot = i - 1
|
|
if slot >= MAX_PRE then break end
|
|
presetKeys[slot] = p.key
|
|
presetRows[#presetRows + 1] = ui.row({ gap = 8, align = "center" }, {
|
|
ui.label({ text = (p.flag or "") .. " " .. (p.name or p.key), flexGrow = 1 }),
|
|
ui.toggle({ checked = p.enabled == true, onChange = "onPreset" .. tostring(slot) }),
|
|
})
|
|
end
|
|
|
|
local ruleRows = {}
|
|
ruleIds = {}
|
|
for i, r in ipairs(rules) do
|
|
local slot = i - 1
|
|
if slot >= MAX_RULE then break end
|
|
ruleIds[slot] = r.id
|
|
ruleRows[#ruleRows + 1] = ui.row({ gap = 6, align = "center" }, {
|
|
ui.glyph({ name = r.type == "block" and "ban" or (r.type == "direct" and "arrow-right" or "shield"),
|
|
color = r.type == "block" and T.danger or T.muted }),
|
|
ui.column({ gap = 0, flexGrow = 1 }, {
|
|
ui.label({ text = r.pattern or "", fontSize = 13 }),
|
|
ui.label({ text = r.type or "", fontSize = 10, color = T.muted }),
|
|
}),
|
|
ui.button({ glyph = "x", variant = "ghost", onClick = "onRuleDel" .. tostring(slot) }),
|
|
})
|
|
end
|
|
if #ruleRows == 0 then
|
|
ruleRows[1] = ui.row({ paddingH = 6, paddingV = 6 }, {
|
|
ui.label({ text = noctalia.tr("rules.none"), color = T.muted }),
|
|
})
|
|
end
|
|
|
|
-- Header outside the scroll, scroll on flexGrow (same fix as editorView):
|
|
-- a fixed scroll height overflowed the panel instead of clipping.
|
|
return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }),
|
|
ui.label({ text = noctalia.tr("rules.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }),
|
|
}),
|
|
|
|
ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 12 }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.glyph({ name = "shield-x", color = kill.enabled and T.danger or T.muted }),
|
|
ui.column({ gap = 0, flexGrow = 1 }, {
|
|
ui.label({ text = noctalia.tr("rules.killswitch") }),
|
|
ui.label({ text = noctalia.tr("rules.killswitch-desc"), fontSize = 10, color = T.muted }),
|
|
}),
|
|
ui.toggle({ checked = kill.enabled == true, onChange = "onKill" }),
|
|
}),
|
|
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.glyph({ name = "world-search", color = dnsColor }),
|
|
ui.label({ text = dnsText, fontSize = 11, color = dnsColor, flexGrow = 1 }),
|
|
ui.button({ text = noctalia.tr("rules.dns-check"), variant = "outline", onClick = "onDnsCheck" }),
|
|
}),
|
|
|
|
ui.separator({}),
|
|
ui.label({ text = noctalia.tr("rules.presets"), fontSize = 12, color = T.muted }),
|
|
ui.column({ gap = 6 }, presetRows),
|
|
|
|
ui.separator({}),
|
|
ui.label({ text = noctalia.tr("rules.custom"), fontSize = 12, color = T.muted }),
|
|
ui.row({ gap = 6, align = "center" }, {
|
|
ui.input({ key = "rule-pat-" .. ruleRev, value = ruleForm.pattern, placeholder = "*.example.com | 10.0.0.0/8",
|
|
onChange = "onRulePattern", flexGrow = 1 }),
|
|
ui.select({ selectedIndex = indexOf(RULE_TYPES, ruleForm.type, 0), options = RULE_TYPES, onChange = "onRuleType" }),
|
|
ui.button({ glyph = "plus", variant = "primary", onClick = "onRuleAdd" }),
|
|
}),
|
|
ui.column({ gap = 6 }, ruleRows),
|
|
|
|
-- Two-step reset because there is no undo or backend bulk-clear command.
|
|
ui.separator({}),
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.glyph({ name = "trash", size = 15, color = resetArmed and T.danger or T.muted }),
|
|
ui.column({ gap = 0, flexGrow = 1 }, {
|
|
ui.label({ text = noctalia.tr("rules.reset-servers"), color = resetArmed and T.danger or T.text }),
|
|
ui.label({ text = noctalia.tr("rules.reset-servers-desc"), fontSize = 10, color = T.muted }),
|
|
}),
|
|
ui.button({ text = resetArmed and noctalia.tr("action.confirm") or noctalia.tr("action.reset"),
|
|
variant = "outline", onClick = "onResetServers" }),
|
|
}),
|
|
}) }),
|
|
})
|
|
end
|
|
|
|
-- ================================================================
|
|
-- LOGS VIEW
|
|
-- ================================================================
|
|
local function logsView()
|
|
local logs = noctalia.state.get("logs") or {}
|
|
local rows = {}
|
|
local startI = math.max(1, #logs - 150)
|
|
for i = startI, #logs do
|
|
local e = logs[i]
|
|
local lvl = (e and e.level) or "info"
|
|
rows[#rows + 1] = ui.label({
|
|
text = (e and e.message) or "",
|
|
fontSize = 11,
|
|
color = (lvl == "error" and T.danger) or (lvl == "warn" and T.pingMid) or T.muted,
|
|
})
|
|
end
|
|
if #rows == 0 then rows[1] = ui.label({ text = noctalia.tr("logs.empty"), color = T.muted }) end
|
|
return ui.column({ gap = 10, padding = 16, flexGrow = 1 }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }),
|
|
ui.label({ text = noctalia.tr("logs.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }),
|
|
}),
|
|
ui.scroll({ flexGrow = 1, stickToBottom = true }, { ui.column({ gap = 3 }, rows) }),
|
|
})
|
|
end
|
|
|
|
-- ================================================================
|
|
-- SUBSCRIPTIONS VIEW
|
|
-- ================================================================
|
|
local function subsView()
|
|
local subs = noctalia.state.get("subscriptions") or {}
|
|
local rows = {}
|
|
subUrls = {}
|
|
for i, sub in ipairs(subs) do
|
|
local slot = i - 1
|
|
if slot >= MAX_SUB then break end
|
|
subUrls[slot] = sub.url
|
|
rows[#rows + 1] = ui.row({ gap = 6, align = "center" }, {
|
|
ui.column({ gap = 0, flexGrow = 1 }, {
|
|
ui.label({ text = (sub.name and sub.name ~= "") and sub.name or (sub.url or ""), fontSize = 13 }),
|
|
ui.label({ text = sub.url or "", fontSize = 10, color = T.muted }),
|
|
}),
|
|
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onSubUpd" .. tostring(slot) }),
|
|
ui.button({ glyph = "x", variant = "ghost", onClick = "onSubDel" .. tostring(slot) }),
|
|
})
|
|
end
|
|
if #rows == 0 then
|
|
rows[1] = ui.row({ paddingH = 6, paddingV = 6 }, {
|
|
ui.label({ text = noctalia.tr("subs.none"), color = T.muted }),
|
|
})
|
|
end
|
|
|
|
return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({ glyph = "chevron-left", variant = "ghost", onClick = "onBack" }),
|
|
ui.label({ text = noctalia.tr("subs.title"), fontWeight = "bold", fontSize = 15, flexGrow = 1 }),
|
|
}),
|
|
ui.column({ gap = 6 }, {
|
|
ui.input({ key = "sub-url-" .. subRev, value = subForm.url, placeholder = "https://…/sub",
|
|
onChange = "onSubUrl", flexGrow = 1 }),
|
|
ui.row({ gap = 6, align = "center" }, {
|
|
ui.input({ key = "sub-name-" .. subRev, value = subForm.name, placeholder = noctalia.tr("subs.name"),
|
|
onChange = "onSubName", flexGrow = 1 }),
|
|
ui.button({ text = noctalia.tr("action.add"), glyph = "plus", variant = "primary", onClick = "onSubAdd" }),
|
|
}),
|
|
}),
|
|
ui.separator({}),
|
|
ui.scroll({ flexGrow = 1 }, { ui.column({ gap = 8 }, rows) }),
|
|
})
|
|
end
|
|
|
|
-- ── dispatch ────────────────────────────────────────────────────
|
|
render = function()
|
|
local tree
|
|
if view == "editor" then tree = editorView()
|
|
elseif view == "rules" then tree = rulesView()
|
|
elseif view == "logs" then tree = logsView()
|
|
elseif view == "subs" then tree = subsView()
|
|
else tree = mainView() end
|
|
panel.render(tree)
|
|
end
|
|
|
|
-- ── lifecycle ───────────────────────────────────────────────────
|
|
function onOpen()
|
|
for _, k in ipairs({ "status", "servers", "health", "backend", "presets", "rules",
|
|
"killswitch", "logs", "subscriptions", "speedtest", "dnsleak" }) do
|
|
noctalia.state.watch(k, function(_) render() end)
|
|
end
|
|
render()
|
|
end
|
|
function onClose() end
|
|
|
|
-- ── main handlers ───────────────────────────────────────────────
|
|
function onToggle()
|
|
local s = noctalia.state.get("status") or {}
|
|
if s.running then send("StopProxy", {})
|
|
elseif s.activeServerId and s.activeServerId ~= "" then
|
|
send("StartProxy", { s.activeServerId, s.mode or "rules", s.proxyMode or "system" })
|
|
else noctalia.notifyError("VPN", noctalia.tr("error.no-server")) end
|
|
end
|
|
-- The toggle reports the desired value; onToggle derives the action from state.
|
|
function onMaster(_) onToggle() end
|
|
function onClosePanel() panel.close() end
|
|
|
|
function onMode(_, label) send("SetMode", { label }) end
|
|
function onVia(_, label)
|
|
if label == "tun" then noctalia.notify("VPN", noctalia.tr("notice.tun")) end
|
|
send("SetProxyMode", { label })
|
|
end
|
|
-- RunSpeedTest reports latency and throughput in one command.
|
|
function onRunTest()
|
|
local s = noctalia.state.get("status") or {}
|
|
if not s.activeServerId or s.activeServerId == "" then
|
|
noctalia.notifyError("VPN", noctalia.tr("error.no-server"))
|
|
return
|
|
end
|
|
send("RunSpeedTest", {})
|
|
end
|
|
function onOpenRules() navigate("rules") end
|
|
function onOpenLogs() navigate("logs") end
|
|
function onOpenSubs() navigate("subs") end
|
|
function onBack() navigate("main") end
|
|
|
|
function onImportClip()
|
|
local txt = noctalia.clipboardText()
|
|
if txt and txt ~= "" then send("ParseShareLink", { txt })
|
|
else noctalia.notifyError("VPN", noctalia.tr("error.clipboard")) end
|
|
end
|
|
function onDnsCheck() send("CheckDnsLeak", {}) end
|
|
|
|
-- ── editor open/save ────────────────────────────────────────────
|
|
local function openEditor(server)
|
|
form = {}
|
|
formRev = formRev + 1
|
|
if server then
|
|
editingId = server.id
|
|
formProto = server.protocol or "ssh"
|
|
for k, v in pairs(server) do form[k] = v end
|
|
else
|
|
editingId = nil
|
|
formProto = "ssh"
|
|
form.port = "22"
|
|
end
|
|
navigate("editor")
|
|
end
|
|
|
|
function onAddServer() openEditor(nil) end
|
|
|
|
function onSave()
|
|
local p = { protocol = formProto, name = form.name or "" }
|
|
-- Persisted via the models' extra="allow"; only the UI reads it.
|
|
local cc = (form.country or ""):lower():gsub("%s", "")
|
|
if #cc == 2 then p.country = cc end
|
|
if editingId then p.id = editingId end
|
|
if formProto == "ssh" then
|
|
p.host = form.host; p.port = num(form.port, 22); p.user = form.user
|
|
if form.password and form.password ~= "" then p.password = form.password end
|
|
if form.keyFile and form.keyFile ~= "" then p.keyFile = form.keyFile end
|
|
elseif formProto == "vless" then
|
|
p.address = form.address; p.port = num(form.port, 443); p.uuid = form.uuid
|
|
p.transport = form.transport or "tcp"; p.security = form.security or "none"
|
|
for _, k in ipairs({ "sni", "flow", "pbk", "sid", "fp", "path", "host", "serviceName" }) do
|
|
if form[k] and form[k] ~= "" then p[k] = form[k] end
|
|
end
|
|
elseif formProto == "vmess" then
|
|
p.address = form.address; p.port = num(form.port, 443); p.uuid = form.uuid
|
|
p.alterId = num(form.alterId, 0); p.transport = form.transport or "tcp"
|
|
for _, k in ipairs({ "sni", "path", "host" }) do
|
|
if form[k] and form[k] ~= "" then p[k] = form[k] end
|
|
end
|
|
elseif formProto == "shadowsocks" then
|
|
p.address = form.address; p.port = num(form.port, 8388)
|
|
p.method = form.method; p.password = form.password
|
|
elseif formProto == "socks5" then
|
|
p.host = form.host; p.port = num(form.port, 1080)
|
|
if form.username and form.username ~= "" then p.username = form.username end
|
|
if form.password and form.password ~= "" then p.password = form.password end
|
|
end
|
|
send(editingId and "UpdateServer" or "AddServer", { p })
|
|
navigate("main")
|
|
end
|
|
|
|
function onDelete()
|
|
if editingId then send("RemoveServer", { editingId }) end
|
|
editingId = nil
|
|
navigate("main")
|
|
end
|
|
|
|
-- editor field handlers (uncontrolled inputs → accumulate in form)
|
|
function onFld_name(v) form.name = v end
|
|
function onFld_country(v) form.country = v end
|
|
function onFld_host(v) form.host = v end
|
|
function onFld_address(v) form.address = v end
|
|
function onFld_port(v) form.port = v end
|
|
function onFld_user(v) form.user = v end
|
|
function onFld_password(v) form.password = v end
|
|
function onFld_keyFile(v) form.keyFile = v end
|
|
function onFld_uuid(v) form.uuid = v end
|
|
function onFld_method(v) form.method = v end
|
|
function onFld_sni(v) form.sni = v end
|
|
function onFld_flow(v) form.flow = v end
|
|
function onFld_fp(v) form.fp = v end
|
|
function onFld_pbk(v) form.pbk = v end
|
|
function onFld_sid(v) form.sid = v end
|
|
function onFld_path(v) form.path = v end
|
|
function onFld_serviceName(v) form.serviceName = v end
|
|
function onFld_username(v) form.username = v end
|
|
function onFld_alterId(v) form.alterId = v end
|
|
function onFldTransport(_, label) form.transport = label end
|
|
function onFldSecurity(_, label) form.security = label end
|
|
function onFldProto(_, label) formProto = label; formRev = formRev + 1; render() end
|
|
|
|
-- ── rules handlers ──────────────────────────────────────────────
|
|
function onKill(value) send("SetKillSwitch", { value == "true" }) end
|
|
|
|
-- Arms on the first click, fires on the second. Every state.set is delivered in
|
|
-- order (verified — the host queues them rather than collapsing to the last),
|
|
-- so fanning out one RemoveServer per id is safe.
|
|
function onResetServers()
|
|
if not resetArmed then
|
|
resetArmed = true
|
|
render()
|
|
return
|
|
end
|
|
resetArmed = false
|
|
for _, s in ipairs(noctalia.state.get("servers") or {}) do
|
|
send("RemoveServer", { s.id })
|
|
end
|
|
render()
|
|
end
|
|
function onRulePattern(v) ruleForm.pattern = v end
|
|
function onRuleType(_, label) ruleForm.type = label end
|
|
function onRuleAdd()
|
|
if not ruleForm.pattern or ruleForm.pattern == "" then return end
|
|
send("AddRoutingRule", { { pattern = ruleForm.pattern, type = ruleForm.type, enabled = true } })
|
|
ruleForm.pattern = ""
|
|
ruleRev = ruleRev + 1
|
|
render()
|
|
end
|
|
|
|
local function onPresetAt(slot, value)
|
|
local key = presetKeys[slot]; if key then send("TogglePreset", { key, value == "true" }) end
|
|
end
|
|
local function onRuleDelAt(slot)
|
|
local id = ruleIds[slot]; if id then send("RemoveRoutingRule", { id }) end
|
|
end
|
|
|
|
-- ── subscription handlers ───────────────────────────────────────
|
|
function onSubUrl(v) subForm.url = v end
|
|
function onSubName(v) subForm.name = v end
|
|
function onSubAdd()
|
|
if not subForm.url or subForm.url == "" then return end
|
|
send("AddSubscription", { subForm.url, subForm.name or "" })
|
|
subForm.url = ""; subForm.name = ""; subRev = subRev + 1; render()
|
|
end
|
|
local function onSubUpdAt(slot) local u = subUrls[slot]; if u then send("UpdateSubscription", { u }) end end
|
|
local function onSubDelAt(slot) local u = subUrls[slot]; if u then send("RemoveSubscription", { u }) end end
|
|
|
|
-- ── server row handlers ─────────────────────────────────────────
|
|
local function onServerAt(slot)
|
|
local id = slotIds[slot]; if not id then return end
|
|
local s = noctalia.state.get("status") or {}
|
|
if s.running then send("SwitchServer", { id })
|
|
else send("StartProxy", { id, s.mode or "rules", s.proxyMode or "system" }) end
|
|
end
|
|
local function onEditAt(slot)
|
|
local id = slotIds[slot]; if not id then return end
|
|
for _, sv in ipairs(noctalia.state.get("servers") or {}) do
|
|
if sv.id == id then openEditor(sv); return end
|
|
end
|
|
end
|
|
local function onDelAt(slot)
|
|
local id = slotIds[slot]; if id then send("RemoveServer", { id }) end
|
|
end
|
|
|
|
-- ── fixed handler pools (host resolves onClick by global name) ───
|
|
function onServer0() onServerAt(0) end
|
|
function onServer1() onServerAt(1) end
|
|
function onServer2() onServerAt(2) end
|
|
function onServer3() onServerAt(3) end
|
|
function onServer4() onServerAt(4) end
|
|
function onServer5() onServerAt(5) end
|
|
function onServer6() onServerAt(6) end
|
|
function onServer7() onServerAt(7) end
|
|
function onServer8() onServerAt(8) end
|
|
function onServer9() onServerAt(9) end
|
|
function onServer10() onServerAt(10) end
|
|
function onServer11() onServerAt(11) end
|
|
function onServer12() onServerAt(12) end
|
|
function onServer13() onServerAt(13) end
|
|
function onServer14() onServerAt(14) end
|
|
function onServer15() onServerAt(15) end
|
|
function onServer16() onServerAt(16) end
|
|
function onServer17() onServerAt(17) end
|
|
function onServer18() onServerAt(18) end
|
|
function onServer19() onServerAt(19) end
|
|
|
|
function onEdit0() onEditAt(0) end
|
|
function onEdit1() onEditAt(1) end
|
|
function onEdit2() onEditAt(2) end
|
|
function onEdit3() onEditAt(3) end
|
|
function onEdit4() onEditAt(4) end
|
|
function onEdit5() onEditAt(5) end
|
|
function onEdit6() onEditAt(6) end
|
|
function onEdit7() onEditAt(7) end
|
|
function onEdit8() onEditAt(8) end
|
|
function onEdit9() onEditAt(9) end
|
|
function onEdit10() onEditAt(10) end
|
|
function onEdit11() onEditAt(11) end
|
|
function onEdit12() onEditAt(12) end
|
|
function onEdit13() onEditAt(13) end
|
|
function onEdit14() onEditAt(14) end
|
|
function onEdit15() onEditAt(15) end
|
|
function onEdit16() onEditAt(16) end
|
|
function onEdit17() onEditAt(17) end
|
|
function onEdit18() onEditAt(18) end
|
|
function onEdit19() onEditAt(19) end
|
|
|
|
function onDel0() onDelAt(0) end
|
|
function onDel1() onDelAt(1) end
|
|
function onDel2() onDelAt(2) end
|
|
function onDel3() onDelAt(3) end
|
|
function onDel4() onDelAt(4) end
|
|
function onDel5() onDelAt(5) end
|
|
function onDel6() onDelAt(6) end
|
|
function onDel7() onDelAt(7) end
|
|
function onDel8() onDelAt(8) end
|
|
function onDel9() onDelAt(9) end
|
|
function onDel10() onDelAt(10) end
|
|
function onDel11() onDelAt(11) end
|
|
function onDel12() onDelAt(12) end
|
|
function onDel13() onDelAt(13) end
|
|
function onDel14() onDelAt(14) end
|
|
function onDel15() onDelAt(15) end
|
|
function onDel16() onDelAt(16) end
|
|
function onDel17() onDelAt(17) end
|
|
function onDel18() onDelAt(18) end
|
|
function onDel19() onDelAt(19) end
|
|
|
|
function onPreset0(v) onPresetAt(0, v) end
|
|
function onPreset1(v) onPresetAt(1, v) end
|
|
function onPreset2(v) onPresetAt(2, v) end
|
|
function onPreset3(v) onPresetAt(3, v) end
|
|
function onPreset4(v) onPresetAt(4, v) end
|
|
function onPreset5(v) onPresetAt(5, v) end
|
|
function onPreset6(v) onPresetAt(6, v) end
|
|
function onPreset7(v) onPresetAt(7, v) end
|
|
|
|
function onRuleDel0() onRuleDelAt(0) end
|
|
function onRuleDel1() onRuleDelAt(1) end
|
|
function onRuleDel2() onRuleDelAt(2) end
|
|
function onRuleDel3() onRuleDelAt(3) end
|
|
function onRuleDel4() onRuleDelAt(4) end
|
|
function onRuleDel5() onRuleDelAt(5) end
|
|
function onRuleDel6() onRuleDelAt(6) end
|
|
function onRuleDel7() onRuleDelAt(7) end
|
|
function onRuleDel8() onRuleDelAt(8) end
|
|
function onRuleDel9() onRuleDelAt(9) end
|
|
function onRuleDel10() onRuleDelAt(10) end
|
|
function onRuleDel11() onRuleDelAt(11) end
|
|
function onRuleDel12() onRuleDelAt(12) end
|
|
function onRuleDel13() onRuleDelAt(13) end
|
|
function onRuleDel14() onRuleDelAt(14) end
|
|
function onRuleDel15() onRuleDelAt(15) end
|
|
function onRuleDel16() onRuleDelAt(16) end
|
|
function onRuleDel17() onRuleDelAt(17) end
|
|
function onRuleDel18() onRuleDelAt(18) end
|
|
function onRuleDel19() onRuleDelAt(19) end
|
|
|
|
function onSubUpd0() onSubUpdAt(0) end
|
|
function onSubUpd1() onSubUpdAt(1) end
|
|
function onSubUpd2() onSubUpdAt(2) end
|
|
function onSubUpd3() onSubUpdAt(3) end
|
|
function onSubUpd4() onSubUpdAt(4) end
|
|
function onSubUpd5() onSubUpdAt(5) end
|
|
function onSubUpd6() onSubUpdAt(6) end
|
|
function onSubUpd7() onSubUpdAt(7) end
|
|
function onSubUpd8() onSubUpdAt(8) end
|
|
function onSubUpd9() onSubUpdAt(9) end
|
|
function onSubUpd10() onSubUpdAt(10) end
|
|
function onSubUpd11() onSubUpdAt(11) end
|
|
|
|
function onSubDel0() onSubDelAt(0) end
|
|
function onSubDel1() onSubDelAt(1) end
|
|
function onSubDel2() onSubDelAt(2) end
|
|
function onSubDel3() onSubDelAt(3) end
|
|
function onSubDel4() onSubDelAt(4) end
|
|
function onSubDel5() onSubDelAt(5) end
|
|
function onSubDel6() onSubDelAt(6) end
|
|
function onSubDel7() onSubDelAt(7) end
|
|
function onSubDel8() onSubDelAt(8) end
|
|
function onSubDel9() onSubDelAt(9) end
|
|
function onSubDel10() onSubDelAt(10) end
|
|
function onSubDel11() onSubDelAt(11) end
|