Files
community-plugins/config-swap/config-swap-panel.luau
T
Tadomika-AriandGitHub b6b141ecec feat!: add Config Swap plugin (#112)
* feat!: init plugin

* fix: fix widget not found on bar setting

* feat!: add creation folder for plugin

* feat!: add first version of Config Swap (simple modification test)

* feat!: update toml with new feature

* docs: prepare docs

* feat!: add more place for view preview

* feat!: add new page for store and allerady download

* docs: improve lisibility

* feat!: add description emplacement

* feat!: add wallpaper support for install and set

* docs: add more info to readme

* feat: add cp depedencie

* docs: add more info

* feat!: add trad support

* fix: change order for set wallpaper after change config

* feat!: add settingsPath for data.json

* feat!: add data.json creation if not exist

* docs: add more docs info

* feat!: rm useless preview image

* docs: improve docs

* feat!: change false description

* feat!: add shellQuote for protect bad command

* feat!: add path var in infoConfig.Change config type for infoConfig Type

* feat!: add secure delete config and confirmation for delete config

* feat!: add save function for save current config

* feat!: add welcome warning and confirmation switch

* feat!: add info for store section

* docs: add new info in setting section

* feat!: add resolve state dir for check all state dir possibility

* feat!: add english translation compatibility

* feat!: change thumbnail

* docs: add more information on the readme

* fix: rm useless button

* docs: add comment

* fix: add depedencie missing

* fix: rm death code

* fix: rm update interval not use

* feat!: add delete, save and apply protection, reject invalid name

* docs: add more info in info section

* feat!: add more charactere in match string in isDetect function

* fix: change wrong name function

* feat!: add protection for invalid name in dowload Config
2026-08-06 10:43:14 -04:00

655 lines
20 KiB
Luau

--!nonstrict
-- Config Swap panel
-- Local variables
local configBox = "~/Config-Swap"
local listConfig = nil
local pickSlots = {}
local columns = 2
local previewWidth = 470
local previewHeight = 350
local previewImage = ""
local selectedView = 0 -- Controls which screen to render (0=welcome, 1=installed, 2=store)
local previewCache = {} -- In-memory map: config name -> cached preview image path
local previewPending = {}
local configWidth = 800 -- Preview dialog size
local configHeight = 600
-- Data model
type infoConfig = {
name: string, -- Configuration name
author: string, -- Configuration author
origin: string, -- Distribution/source (NixOS, Arch, CachyOS, etc.)
preview: string, -- Preview image path (preview.png)
path: string, -- Config path (example: config/{name})
description: string, -- Short description
wallpaperPath: string, -- Wallpaper path
depedencie: boolean, -- Whether extra dependencies are required
}
-- Resolve Noctalia state directory
local function resolveStateDir()
return noctalia.getenv("NOCTALIA_STATE_HOME")
or ((noctalia.getenv("XDG_STATE_HOME") or ((noctalia.getenv("HOME") or "") .. "/.local/state")) .. "/noctalia")
end
-- Detecte dangerous charactère
function isDetect(target: string)
local match_string = "[/.$();]"
if string.match(target, match_string) then
return true
else
return false
end
end
-- Store listing cache
local allConfig: { infoConfig } = {}
local requestFetchList = {
url = "https://api.github.com/repos/Tadomika-Ari/Config-Swap-Box/contents/config",
method = "GET",
headers = { "Accept: application/json", "User-Agent: Config-Swap-Box-Plugin" },
follow_redirects = true
}
-- Translation helper
function tr(key, subst)
if subst then
return noctalia.tr("panel." .. key, subst)
end
return noctalia.tr("panel." .. key)
end
-- Build settings.toml path from the resolved state dir
function takePosSetting()
local noctaliaStateDir = resolveStateDir()
local settingsTomlPath = noctaliaStateDir .. "/settings.toml"
return settingsTomlPath
end
-- Shell escaping to safely build external commands
local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- Welcome warning screen
local function welcomeWarning() -- Shown after startup; refusing closes the plugin
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("title"), textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
}),
ui.label({ text = tr("welcome_message_title"), textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("welcome_message1"), textAlign = "center", fontSize = 14, color = "on_surface_variant" }),
ui.label({ text = tr("welcome_message2"), textAlign = "center", fontSize = 14, color = "on_surface_variant" }),
ui.row({ gap = 8, justify = "center" }, {
ui.button({ text = tr("not_agree"), variant = "ghost", onClick = "onCloseClicked" }),
ui.button({ text = tr("agree"), onClick = function()
selectedView = 1
render()
end
}),
}),
}))
end
local function renderConfirmApply(data: infoConfig) -- Confirmation dialog before applying a config
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("title"), textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
}),
ui.label({ text = tr("confirmation"), textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = data.name, textAlign = "center", fontSize = 14, color = "on_surface_variant" }),
ui.label({ text = tr("description_confirmation"), textAlign = "center", fontSize = 13, color = "on_surface_variant" }),
ui.row({ gap = 8, justify = "center" }, {
ui.button({ text = tr("cancel"), variant = "ghost", onClick = "render" }),
ui.button({ text = tr("apply"), variant = "primary", onClick = function()
applyConfig(data)
render()
end
}),
}),
}))
end
-- Apply selected config and wallpaper
function applyConfig(data: infoConfig)
noctalia.notify(tr("apply_notification_title"))
if isDetect(data.name) == true then -- detecte malicious name
noctalia.notify(tr("apply_notification_title"), "Invalid name. Please check your info.json")
return
end
local pathSettings = takePosSetting()
if not pathSettings then
noctalia.notify(tr("notification_title"), "Path not found, please check your setting toml")
return
end
local src = shellQuote(noctalia.expandPath(configBox .. "/" .. data.name .. "/settings.toml"))
local dest = shellQuote(noctalia.expandPath(pathSettings))
local cmd = "cp " .. src .. " " .. dest
noctalia.runAsync(cmd, function(result)
if result and result.exitCode == 0 then
noctalia.setWallpaper(configBox .. "/" .. data.name .. "/wallpaper.png")
else
noctalia.notify(tr("notification_title"), "swap failed")
end
end)
end
-- Download the full config bundle: settings, info json, preview, wallpaper
function downloadConfig(data: infoConfig)
noctalia.notify(tr("install_notification_title"))
if isDetect(data.name) == true then
noctalia.notify("Download", "Invalide name detected, please check target config")
return
end
noctalia.mkdirAll(configBox .. "/" .. data.name)
local path = data.path .. "/settings.toml"
local url = "https://raw.githubusercontent.com/Tadomika-Ari/Config-Swap-Box/main/" .. path
noctalia.download(url, configBox .. "/" .. data.name .. "/settings.toml" , function(ok)
if ok then
noctalia.notify(tr("notification_title"), tr("download_ok"))
else
noctalia.notify(tr("notification_title"), tr("download_failed"))
end
end)
local path = data.path .. "/info.json"
local url = "https://raw.githubusercontent.com/Tadomika-Ari/Config-Swap-Box/main/" .. path
noctalia.download(url, configBox .. "/" .. data.name .. "/info.json" , function(ok)
if ok then
noctalia.notify(tr("notification_title"), tr("download_ok"))
else
noctalia.notify(tr("notification_title"), tr("download_failed"))
end
end)
local path = data.path .. "/preview.png"
local url = "https://raw.githubusercontent.com/Tadomika-Ari/Config-Swap-Box/main/" .. path
noctalia.download(url, configBox .. "/" .. data.name .. "/preview.png" , function(ok)
if ok then
noctalia.notify(tr("notification_title"), tr("download_ok"))
else
noctalia.notify(tr("notification_title"), tr("download_failed"))
end
end)
local path = data.path .. "/wallpaper.png"
local url = "https://raw.githubusercontent.com/Tadomika-Ari/Config-Swap-Box/main/" .. path
noctalia.download(url, configBox .. "/" .. data.name .. "/wallpaper.png" , function(ok)
if ok then
noctalia.notify(tr("notification_title"), tr("download_ok"))
else
noctalia.notify(tr("notification_title"), tr("download_failed"))
end
end)
end
-- GitHub store preview cache
local function previewCachePath(name)
-- Keep cached previews under plugin data to avoid repeated downloads.
local dir = noctalia.pluginDataDir() .. "/preview-cache"
noctalia.mkdirAll(dir)
return dir .. "/" .. name .. ".png"
end
local function queuePreviewDownload(name)
-- Prevent duplicate requests for the same preview while one is in flight.
if previewCache[name] ~= nil or previewPending[name] then
return
end
local dest = previewCachePath(name)
if noctalia.fileExists(dest) then
previewCache[name] = dest
return
end
previewPending[name] = true
local url = "https://raw.githubusercontent.com/Tadomika-Ari/Config-Swap-Box/main/config/" .. name .. "/preview.png"
noctalia.download(url, dest, function(ok)
previewPending[name] = nil
if ok then
previewCache[name] = dest
render()
end
end)
end
-- Delete an installed config
local function delete(name)
if not name or name == "" then
noctalia.notify("Delete", "invalid name")
return
end
if isDetect(name) == true then -- detecte malicious name
noctalia.notify("Delete", "Invalide Name. Please check info.json")
return
end
local target = noctalia.expandPath(configBox .. "/" .. name)
local cmd = "rm -rf " .. shellQuote(target) -- shellQuote protects the rm target path
noctalia.runAsync(cmd, function(result)
if result and result.exitCode == 0 then
noctalia.notify("Delete Ok")
else
noctalia.notify("Delete failed")
end
end)
render()
end
local function renderDeleteConfirmation(name) -- Confirmation panel before deleting a config
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.label({ text = tr("confirmation_delete") .. name .. "?", align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ text = tr("delete"), onClick = function()
delete(name)
end
}),
ui.button({ glyph = "close", onClick = "render" }),
}))
end
-- Save/backup settings section
local noctaliaStateDir = resolveStateDir()
local settingsTomlPath = noctaliaStateDir .. "/settings.toml" -- Active settings file path
local saveNameInput = "save"
local function saveConfig()
local name = saveNameInput ~= "" and saveNameInput or "save"
if isDetect(name) then -- detecte special charactere
noctalia.notify("Save", "Invalid Name")
return
end
local savePath = configBox .. "/" .. name
noctalia.mkdirAll(savePath)
local settingFile: infoConfig = {
name = name,
description = "save for reset",
path = configBox .. "/" .. name,
origin = "not give",
wallpaperPath = nil,
depedencie = false,
preview = nil,
author = noctalia.getenv("USER"),
}
local jsonData = noctalia.json.encode(settingFile)
noctalia.writeFile(savePath .. "/info.json", jsonData)
local content = noctalia.readFile(settingsTomlPath)
if content then
noctalia.writeFile(savePath .. "/settings.toml", content)
noctalia.notify("Save", "Save ok : " .. name)
render()
else
noctalia.notify("Save", "settings.toml not found")
end
end
function onSaveNameChange(value)
saveNameInput = value
end
local function renderSetting()
local rowsSettings = {
ui.column({ gap = 8 }, {
ui.label({
text = tr("description_setting1"),
fontSize = 12,
color = "on_surface_variant",
}),
ui.row({ flexGrow = 1, gap = 16, align = "center" }, {
ui.input({
key = "save-name-input",
value = saveNameInput,
placeholder = "Save name",
onChange = "onSaveNameChange",
flexGrow = 1,
}),
ui.button({ text = tr("save"), onClick = function()
saveConfig()
end
}),
}),
}),
}
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.label({ text = "Setting section", textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ text = "X", onClick = "render"}),
ui.scroll({ gap = 12, align = "stretch", flexGrow = 1 }, rowsSettings),
}))
end
-- Store information panel
local function renderInfoStore()
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("info_store_title"), textAlign = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ text = tr("info_store_github_button"), onClick = function()
noctalia.copyToClipboard("https://github.com/Tadomika-Ari/Config-Swap-Box", "text/plain")
noctalia.notify(tr("notification_title"), tr("info_store_copy_link_success"))
end
}),
ui.button({ glyph = "close", onClick = "render" }),
}),
ui.scroll({ gap = 16, align = "stretch", flexGrow = 1 }, {
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_store_what_is_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_store_what_is_body"), fontSize = 12, color = "on_surface_variant" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_store_installing_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_store_installing_body"), fontSize = 12, color = "on_surface_variant" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_store_applying_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_store_applying_body"), fontSize = 12, color = "on_surface_variant" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_store_community_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_store_community_body"), fontSize = 12, color = "on_surface_variant" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_how_to_submite_config_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_how_to_submite_config_body"), fontSize = 12, color = "on_surface_variant" }),
}),
ui.column({ gap = 4 }, {
ui.label({ text = tr("info_store_rate_limits_title"), fontSize = 13, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = tr("info_store_rate_limits_body"), fontSize = 12, color = "on_surface_variant" }),
}),
}),
}))
end
local function renderList()
pickSlots = {}
local tiles = {}
local list = listConfig or {}
for count = 1, #list, 1 do
local data = list[count]
local slotIndex = count - 1
pickSlots[slotIndex] = data.path
queuePreviewDownload(data.name)
local imagePath = previewCache[data.name] or previewImage
table.insert(tiles, ui.column({ gap = 4, key = data.path, align = "center" }, {
ui.image({
path = imagePath,
width = previewWidth,
height = previewHeight,
fit = "cover",
radius = 8,
onClick = ""
}),
ui.row( {gap = 12, align = "start"}, {
ui.button({ text = tr("install"), onClick = function()
downloadConfig(data)
end
}),
ui.label({
text = data.name,
fontSize = 11,
maxLines = 1,
maxWidth = 120,
textAlign = "center",
}),
} ),
}))
end
local rows = {}
local row = {}
for i, tile in ipairs(tiles) do
table.insert(row, tile)
if #row == columns or i == #tiles then
table.insert(rows, ui.row({ gap = 12, align = "start" }, row))
row = {}
end
end
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("title"), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("store_title"), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ text = tr("show_list"), onClick = function()
selectedView = 1
render()
end
}),
ui.button({ text = "Info", onClick = function()
renderInfoStore()
end
}),
ui.button({ text = tr("refresh"), onClick = "getFetchList"}),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.scroll({ gap = 12, align = "stretch", flexGrow = 1 }, rows),
}))
end
-- Installed config details panel
local function renderConfig(data: infoConfig)
local rows = {
ui.image({
path = configBox .. "/" .. data.name .. "/preview.png",
width = configWidth,
height = configHeight,
fit = "cover",
radius = 8,
}),
ui.label({ text = data.name, align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("created_by", { author = data.author }), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("origin", { origin = data.origin }), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("description", { description = data.description or "" }), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ glyph = "trash", onClick = function()
renderDeleteConfirmation(data.name)
end
}),
ui.button({ glyph = "close", onClick = "render" }),
}
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.scroll({ gap = 12, align = "stretch", flexGrow = 1 }, rows),
}))
end
-- List installed configs from ~/Config-Swap
function getListInstall()
local localPath = configBox
local list = noctalia.listDir(localPath)
local listShow: { infoConfig } = {}
for i, entryName in ipairs(list) do
local infoPath = configBox .. "/" .. entryName .. "/info.json"
local content = noctalia.readFile(infoPath)
local data = content and noctalia.json.decode(content) or nil
if not data then
-- Fallback entry when info.json is missing or invalid.
table.insert(listShow, {
name = entryName,
path = "config/" .. entryName,
author = "None",
origin = "None",
preview = nil,
description = nil,
wallpaperPath = nil,
depedencie = false,
})
continue
end
table.insert(listShow, {
name = data.name,
path = data.path,
author = data.author,
origin = data.origin,
preview = data.preview,
description = data.description,
wallpaperPath = data.wallpaperPath,
depedencie = data.depedencie,
})
end
return listShow
end
-- Render installed configs grid
local function renderInstall()
pickSlots = {}
local tiles = {}
local list = getListInstall() or {}
for count = 1, #list, 1 do
local data = list[count]
local slotIndex = count - 1
pickSlots[slotIndex] = data.path
table.insert(tiles, ui.column({ gap = 4, key = data.path, align = "center" }, {
ui.image({
path = configBox .. "/" .. data.name .. "/preview.png",
width = previewWidth,
height = previewHeight,
fit = "cover",
radius = 8,
onClick = function()
renderConfig(data)
end
}),
ui.row( {gap = 12, align = "start"}, {
ui.button({ text = "delete", onClick = function()
renderDeleteConfirmation(data.name)
end
}),
ui.label({
text = data.name,
fontSize = 11,
maxLines = 1,
maxWidth = 120,
textAlign = "center",
}),
ui.button({ text = "apply", onClick = function()
renderConfirmApply(data)
end
}),
} ),
}))
end
local rows = {}
local row = {}
for i, tile in ipairs(tiles) do
table.insert(row, tile)
if #row == columns or i == #tiles then
table.insert(rows, ui.row({ gap = 12, align = "start" }, row))
row = {}
end
end
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("title"), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.label({ text = tr("installed_title"), align = "center", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
ui.button({ text = tr("show_store"), onClick = function()
selectedView = 2
render()
end
}),
ui.button({ text = "Setting", onClick = function()
renderSetting()
end
}),
ui.button({ text = tr("refresh"), onClick = "getFetchList"}),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.scroll({ gap = 12, align = "stretch", flexGrow = 1 }, rows),
}))
end
-- Root view router
function render()
if selectedView == 0 then
welcomeWarning()
end
if selectedView == 1 then
renderInstall()
end
if selectedView == 2 then
renderList()
end
end
-- Fetch available configs from GitHub
function getFetchList(onDone)
allConfig = {}
noctalia.http(requestFetchList, function(respond)
noctalia.log("raw body: " .. tostring(respond.body))
if not respond.ok then
noctalia.notify("Error", respond.status)
end
local data, err = noctalia.json.decode(respond.body)
if err then
noctalia.log(err)
return
end
for i, item in ipairs(data) do
table.insert(allConfig, {
name = item.name,
path = item.path,
author = nil,
depedencie = nil,
description = nil,
origin = nil,
preview = nil,
wallpaperPath = nil,
})
end
listConfig = allConfig
render()
end)
end
-- Plugin entry points
function onOpen(_context)
render()
end
function onCloseClicked()
panel.close()
end