Files
community-plugins/game-launcher/panel.luau
T
b5098f8eed Fix/gamelauncher nixos build (#225)
* game-launcher: bundle self-contained SQLite reader, drop libsqlite3 dependency

Fixes building gamelauncher.c on NixOS where the runtime env lacks
sqlite3.h/libsqlite3. scan_lutris now reads Lutris' pga.db through a
bundled read-only SQLite file parser instead of linking sqlite3.

* game-launcher: rework /g provider to reuse panel scan results

The launcher provider now reads the games the panel already scanned via
state/cache, trims the query, and surfaces status/error rows instead of
duplicating the scan.

---------

Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
2026-08-02 23:12:29 -04:00

511 lines
15 KiB
Luau

local games = {}
local filtered = {}
local searchText = ""
local loading = false
local errorMsg = ""
local scanCounter = 0
local building = false
local initOk = true
local selectedIndex = 0
local WINDOW_SIZE = 4
local steamCoverDir = ""
local heroicCoverDir = ""
local ok1, err1 = pcall(function()
local xdgCache = noctalia.getenv("XDG_CACHE_HOME")
local home = noctalia.getenv("HOME")
local cacheBase
if xdgCache and #xdgCache > 0 then
cacheBase = xdgCache .. "/gamelauncher"
elseif home and #home > 0 then
cacheBase = home .. "/.cache/gamelauncher"
else
cacheBase = "/tmp/gamelauncher"
end
steamCoverDir = cacheBase .. "/steam"
heroicCoverDir = cacheBase .. "/heroic"
local ok, err = noctalia.mkdirAll(steamCoverDir)
if not ok then noctalia.log("failed to create steam cover dir: " .. (err or "unknown")) end
ok, err = noctalia.mkdirAll(heroicCoverDir)
if not ok then noctalia.log("failed to create heroic cover dir: " .. (err or "unknown")) end
end)
if not ok1 then
initOk = false
noctalia.log("init error: " .. tostring(err1))
end
local binary = noctalia.pluginDir() .. "/gamelauncher"
local cSource = noctalia.pluginDir() .. "/gamelauncher.c"
local cReaderSource = noctalia.pluginDir() .. "/sqlite_reader.c"
local function buildCommand()
return "cc -o " .. binary .. " " .. cSource .. " " .. cReaderSource
end
local buildTagFile
do
local ok, dir = pcall(noctalia.pluginDataDir)
if ok and dir then buildTagFile = dir .. "/build_command" end
end
local function binaryReady()
if not noctalia.fileExists(binary) then return false end
if not buildTagFile then return true end
if noctalia.readFile(buildTagFile) ~= buildCommand() then
noctalia.removeFile(binary)
return false
end
return true
end
local function markBuilt()
if buildTagFile then noctalia.writeFile(buildTagFile, buildCommand()) end
end
local function isValidProtocol(cmd)
local protocols = { "steam://", "lutris:", "heroic://" }
for _, p in ipairs(protocols) do
if cmd:sub(1, #p) == p then
local rest = cmd:sub(#p + 1)
if rest:match("^[%w_%-%.%/]+$") then return true end
end
end
return false
end
local function isRunnerEnabled(runner)
local ok, val = pcall(noctalia.getConfig, runner .. "_enabled")
if ok then return val == true or val == "true" end
return true
end
local function filterByRunner(g)
return isRunnerEnabled(g.runner)
end
local function launchGame(id)
for _, g in ipairs(filtered) do
if g.id == id and g.run_command and #g.run_command > 0 and isValidProtocol(g.run_command) then
noctalia.runAsync('xdg-open "' .. g.run_command .. '"')
return
end
end
end
local function filterGames(text)
local pool = {}
for _, g in ipairs(games) do
if filterByRunner(g) then table.insert(pool, g) end
end
if text == "" then
filtered = pool
return
end
local lower = text:lower()
local result = {}
for _, g in ipairs(pool) do
if g.name:lower():find(lower, 1, true) or g.runner:lower():find(lower, 1, true) then
table.insert(result, g)
end
end
filtered = result
end
local function isImageFile(path)
return noctalia.fileExists(path)
end
local function fetchSteamStoreCover(g, dest, cb)
noctalia.http({ url = "https://store.steampowered.com/api/appdetails?appids=" .. g.id }, function(res2)
pcall(function()
if res2.ok and res2.status == 200 then
local ok2, data2 = pcall(noctalia.json.decode, res2.body)
if ok2 and data2 then
local appData = data2[g.id]
if appData and appData.data and appData.data.header_image then
noctalia.download(appData.data.header_image, dest, function(ok3)
pcall(function() if ok3 then g.cover = dest; render() end end)
end)
return
end
end
end
end)
end)
end
local function fetchSteampoacherCover(g, dest, fallback)
noctalia.http({ url = "https://steam-asset-proxy.steampoacher.workers.dev?appid=" .. g.id }, function(res)
pcall(function()
if res.ok and res.status == 200 then
local ok, data = pcall(noctalia.json.decode, res.body)
if ok and data and data.response and data.response.store_items then
local item = data.response.store_items[1]
if item and item.assets then
local assets = item.assets
local filename = assets.library_capsule_2x or assets.library_capsule or assets.header
local fmt = assets.asset_url_format
if filename and fmt then
local placeholder = "${FILENAME}"
local pos = fmt:find(placeholder, 1, true)
local cdnUrl
if pos then
local prefix = fmt:sub(1, pos - 1)
local suffix = fmt:sub(pos + #placeholder)
cdnUrl = "https://shared.steamstatic.com/store_item_assets/" .. prefix .. filename .. suffix
else
cdnUrl = "https://shared.steamstatic.com/store_item_assets/" .. fmt .. "/" .. filename
end
noctalia.download(cdnUrl, dest, function(ok2)
pcall(function()
if ok2 then g.cover = dest; render() return end
fallback()
end)
end)
return
end
end
end
end
pcall(function() fallback() end)
end)
end)
end
local function downloadSteamCover(g)
local ok, err = pcall(function()
if g.cover and #g.cover > 0 then return end
local dest = steamCoverDir .. "/" .. g.id .. ".jpg"
if isImageFile(dest) then
g.cover = dest
return
end
local steampoacherEnabled = false
local okc, val = pcall(noctalia.getConfig, "steampoacher_enabled")
if okc then
steampoacherEnabled = val == true or val == "true"
end
if steampoacherEnabled then
fetchSteampoacherCover(g, dest, function()
fetchSteamStoreCover(g, dest)
end)
else
fetchSteamStoreCover(g, dest)
end
end)
if not ok then
noctalia.log("downloadSteamCover error: " .. tostring(err))
end
end
local function downloadHeroicCover(g)
local ok, err = pcall(function()
if g.cover and #g.cover > 0 then return end
if not g.cover_url or #g.cover_url == 0 then return end
local dest = heroicCoverDir .. "/" .. g.id .. ".jpg"
if isImageFile(dest) then
g.cover = dest
return
end
noctalia.download(g.cover_url, dest, function(ok2)
pcall(function()
if ok2 then g.cover = dest; render() end
end)
end)
end)
if not ok then
noctalia.log("downloadHeroicCover error: " .. tostring(err))
end
end
local function fetchMissingCovers()
for _, g in ipairs(games) do
if g.runner == "steam" then
downloadSteamCover(g)
elseif g.runner == "heroic" then
downloadHeroicCover(g)
end
end
end
local runnerMeta = {
steam = { glyph = "brand-steam", color = "#66c0f4" },
lutris = { glyph = "device-gamepad", color = "#ff6600" },
heroic = { glyph = "app-window", color = "#a78bfa" },
}
local function renderHeader()
return ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "device-gamepad-2", size = 20, color = "primary" }),
ui.label({ text = "Game Launcher", fontSize = 18, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.label({ text = "(" .. #games .. " games)", color = "on_surface_variant", fontSize = 12 }),
ui.button({ glyph = "reload", variant = "ghost", onClick = "onRescan" }),
ui.button({ glyph = "close", variant = "ghost", onClick = "onClose" }),
})
end
local function renderSearchBar()
return ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "search", size = 14, color = "on_surface_variant" }),
ui.input({ key = "search_" .. scanCounter, placeholder = "Search games…", onChange = "onSearch", value = searchText, flexGrow = 1, focus = true }),
ui.button({ glyph = "filter", variant = "ghost", visible = false }),
})
end
local function renderStatus()
if loading then
return ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
ui.label({ text = "Scanning for games…", color = "on_surface_variant" }),
})
end
if errorMsg ~= "" then
return ui.column({ align = "center", justify = "center", flexGrow = 1, gap = 12 }, {
ui.glyph({ name = "alert-circle", size = 40, color = "error" }),
ui.label({ text = errorMsg, color = "error", maxWidth = 400, textAlign = "center" }),
ui.button({ text = "Retry", variant = "primary", onClick = "onRescan" }),
})
end
if #filtered == 0 then
return ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
ui.label({
text = #games == 0 and "No games found. Click the reload button to scan." or "No games match your search.",
color = "on_surface_variant",
}),
})
end
return nil
end
local function renderGameRow(g, index)
local meta = runnerMeta[g.runner] or { glyph = "app-window", color = "on_surface_variant" }
local isSelected = (index - 1) == selectedIndex
local cp = (g.cover and #g.cover > 0) and g.cover or nil
if not cp then
if g.runner == "steam" then
cp = steamCoverDir .. "/" .. g.id .. ".jpg"
elseif g.runner == "heroic" then
cp = heroicCoverDir .. "/" .. g.id .. ".jpg"
end
end
local coverWidget
if cp and noctalia.fileExists(cp) then
coverWidget = ui.image({ path = cp, width = 56, height = 80, radius = 6, fit = "cover" })
else
coverWidget = ui.box({ width = 56, height = 80, radius = 6, fill = "surface_variant" })
end
local children = {
coverWidget,
ui.column({ flexGrow = 1, gap = 4 }, {
ui.label({ text = g.name, fontWeight = "bold", fontSize = 14, maxLines = 1 }),
ui.row({ align = "center", gap = 6 }, {
ui.glyph({ name = meta.glyph, size = 12, color = meta.color }),
ui.label({ text = g.runner, fontSize = 11, color = "on_surface_variant" }),
}),
}),
ui.button({
text = "Launch",
glyph = "player-play",
variant = "primary",
onClick = function() launchGame(g.id) end,
}),
}
if isSelected then
table.insert(children, 1, ui.box({ width = 2, height = 60, radius = 1, fill = "primary" }))
return ui.row({
key = g.id .. "_sel_" .. index,
align = "center",
gap = 14,
paddingH = 12,
paddingV = 8,
radius = 8,
}, children)
end
return ui.row({
key = g.id .. "_" .. index,
align = "center",
gap = 14,
paddingH = 12,
paddingV = 8,
radius = 8,
}, children)
end
local function renderGameList()
local rows = {}
local total = #filtered
if total == 0 then return rows end
local half = math.floor(WINDOW_SIZE / 2)
local start = math.max(1, selectedIndex + 1 - half)
if start + WINDOW_SIZE > total then
start = math.max(1, total - WINDOW_SIZE + 1)
end
local endIdx = math.min(total, start + WINDOW_SIZE - 1)
for i = start, endIdx do
table.insert(rows, renderGameRow(filtered[i], i))
end
return rows
end
local function renderBody(content)
if loading then
return ui.label({ text = "Scanning for games…", color = "on_surface_variant" })
end
if errorMsg ~= "" then
return ui.column({ align = "center", justify = "center", gap = 12 }, {
ui.glyph({ name = "alert-circle", size = 40, color = "error" }),
ui.label({ text = errorMsg, color = "error", maxWidth = 400, textAlign = "center" }),
ui.button({ text = "Retry", variant = "primary", onClick = "onRescan" }),
})
end
if #filtered == 0 then
return ui.label({
text = #games == 0 and "No games found. Click the reload button to scan." or "No games match your search.",
color = "on_surface_variant",
})
end
return content
end
local function render()
local ok, err = pcall(function()
panel.render(ui.column({ flexGrow = 1, gap = 8, padding = 12 }, {
ui.column({ gap = 8 }, {
renderHeader(),
renderSearchBar(),
}),
ui.column({ flexGrow = 1, gap = 6 }, renderBody(renderGameList())),
}))
end)
if not ok then
noctalia.log("render error: " .. tostring(err))
end
end
local function buildBinary()
if building then return end
building = true
loading = true
errorMsg = ""
render()
local cmd = buildCommand()
local ok = noctalia.runAsync(cmd, function(res)
building = false
if res.exitCode == 0 then
loading = false
markBuilt()
scanGames()
else
loading = false
errorMsg = "Failed to build gamelauncher. Install gcc. (exit " .. (res.exitCode or "?") .. ")"
noctalia.log("build failed (cmd: " .. cmd .. "): " .. (res.stderr or "no stderr"))
render()
end
end)
if not ok then
building = false
loading = false
errorMsg = "Failed to start build process"
render()
end
end
local function scanGames()
if loading or building then return end
if not binaryReady() then
buildBinary()
return
end
loading = true
errorMsg = ""
scanCounter = scanCounter + 1
searchText = ""
render()
local flags = ""
if isRunnerEnabled("steam") then flags = flags .. " --steam" end
if isRunnerEnabled("lutris") then flags = flags .. " --lutris" end
if isRunnerEnabled("heroic") then flags = flags .. " --heroic" end
local ok = noctalia.runAsync(binary .. flags .. " --force", function(res)
local ok3, err3 = pcall(function()
loading = false
if res.exitCode == 0 and res.stdout and #res.stdout > 0 then
local parsed, err2 = noctalia.json.decode(res.stdout)
if parsed then
games = parsed
pcall(noctalia.state.set, "games", parsed)
fetchMissingCovers()
else
errorMsg = "Failed to parse game list: " .. (err2 or "unknown error")
noctalia.log("parse error on stdout: " .. res.stdout:sub(1, 200))
games = {}
end
else
errorMsg = "Game scan failed (exit " .. (res.exitCode or "?") .. ")"
if res.stderr and #res.stderr > 0 then
errorMsg = errorMsg .. ": " .. res.stderr:sub(1, 200)
end
games = {}
end
selectedIndex = 0
filterGames(searchText)
end)
if not ok3 then
loading = false
errorMsg = "Scan error: " .. tostring(err3)
games = {}
filtered = games
selectedIndex = 0
noctalia.log("scan callback error: " .. tostring(err3))
end
render()
end)
if not ok then
loading = false
errorMsg = "Failed to start game scan"
render()
end
end
function onOpen(context)
selectedIndex = 0
filterGames(searchText)
render()
scanGames()
end
function onClose()
panel.close()
end
function onRescan()
scanGames()
end
function onSearch(value)
searchText = value or ""
selectedIndex = 0
filterGames(searchText)
render()
end
function onKey(chord, pressed)
if not pressed then return end
if chord == "Up" then
if #filtered > 0 then
selectedIndex = selectedIndex - 1
if selectedIndex < 0 then selectedIndex = #filtered - 1 end
render()
end
elseif chord == "Down" then
if #filtered > 0 then
selectedIndex = selectedIndex + 1
if selectedIndex >= #filtered then selectedIndex = 0 end
render()
end
elseif chord == "Return" then
if #filtered > 0 and selectedIndex >= 0 and selectedIndex < #filtered then
launchGame(filtered[selectedIndex + 1].id)
end
end
end