* 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>
511 lines
15 KiB
Luau
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
|