Add Game Launcher (#57)
* Add game-launcher plugin * auto-build: compile gamelauncher.c on first run * rename leo -> Alexander * add cc to dependencies for CI * fix CI issues: lowercase id, prefix, translations, getConfig * fix: revert prefix to g (noctalia prepends / via provider_prefix) * game-launcher: fix all 4 security audit issues, cache deletion crash, covers not showing, close button - Issue 1: Removed all system/popen/curl/wget/python3/grep from C scanner - Issue 2: Protocol URL validation + character-level filtering + shell escaping - Issue 3: Added xdg-utils to plugin.toml dependencies - Issue 4: Steampoacher opt-in setting, documented data flow in README - Fix: Plugin crash after cache deletion (pcall-wrapped all error paths) - Fix: Close button uses noctalia.togglePanel instead of panel.close - Fix: onOpen rescans if any game missing a cover - Fix: Cover display fallback path reconstruction * game-launcher: revert close button to panel.close * game-launcher: note steampoacher needed for HQ covers * game-launcher: shorten steampoacher note * 1. Refactored README by hand **Stupid AI** 2. deleted the binary , it will be built with plugin open * fix: Remove the character-by-character sanitization
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Game Launcher
|
||||
|
||||
Browse and launch games from Steam, Lutris, and Heroic Games Launcher directly from your bar. Opens a floating panel with search, cover art, and one-click launch.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `alexander/game-launcher` |
|
||||
| Entries | Bar widget: `launcher`; panel: `browser`; launcher provider: `search` |
|
||||
| Launcher Prefix | `/g` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Requires `libsqlite3-dev`, `xdg-utils` (provides `xdg-open`), and `gcc` on PATH.
|
||||
|
||||
```sh
|
||||
# Debian/Ubuntu
|
||||
sudo apt install libsqlite3-dev xdg-utils gcc
|
||||
|
||||
# Fedora
|
||||
sudo dnf install sqlite-devel xdg-utils gcc
|
||||
|
||||
# Arch
|
||||
sudo pacman -S sqlite xdg-utils gcc
|
||||
```
|
||||
|
||||
The scanner binary (`gamelauncher`) is compiled automatically on first use — the plugin runs `cc` to build it when needed. No manual build step required.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the bar widget `alexander/game-launcher:launcher` to your bar. The widget shows a gamepad icon — click it to open the browser panel.
|
||||
|
||||
In the panel, use the search bar to filter by name or runner. Click **Launch** on any game to start it.
|
||||
|
||||
To open the panel via IPC:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle alexander/game-launcher:browser
|
||||
```
|
||||
|
||||
From the launcher, type `/g` followed by a game name to search. Activate a result to launch the game.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `glyph` | `glyph` | `device-gamepad-2` | Bar widget icon |
|
||||
| `steampoacher_enabled` | `bool` | `false` | Enable steampoacher proxy for Steam cover art |
|
||||
|
||||
## Security & Data Flow
|
||||
|
||||
The plugin addresses all findings from Noctalia's security audit:
|
||||
|
||||
**1. No shell commands in C scanner** — The scanner (`gamelauncher.c`) uses only local filesystem reads and SQLite queries. No `system()`, `popen()`, `curl`, `wget`, `python3`, or `grep` is invoked. All network requests (cover downloads) are handled in Luau via Noctalia's built-in `noctalia.http` and `noctalia.download` APIs, which respect offline mode.
|
||||
|
||||
**2. No shell injection in launch paths** — The C scanner outputs protocol URLs only (e.g., `steam://rungameid/730`, `lutris:rungame/slug`, `heroic://launch/appid`). Luau validates each URL against known protocol prefixes, filters every character through a strict allowlist (`[%w_%-%.%/]` — no shell metacharacters), and double-quotes the argument before passing it to `xdg-open` via `noctalia.runAsync`.
|
||||
|
||||
**3. xdg-utils declared** — `xdg-utils` is listed in `plugin.toml` dependencies.
|
||||
|
||||
**4. Steampoacher opt-in & disclosure** — By default, Steam cover art is fetched directly from `store.steampowered.com/api/appdetails`. The API only provides small `header_image` art (460×215). For high-resolution library capsule covers, enable the **steampoacher** Cloudflare Worker by setting `steampoacher_enabled` to `true` in `~/.config/noctalia/plugins/game-launcher.json`. When enabled, Steam app IDs from your installed library are sent to the proxy at `steam-asset-proxy.steampoacher.workers.dev`, which returns a CDN capsule URL on `shared.steamstatic.com` with full-size 1200×450 art. Cover art for Heroic games uses the art URL from Heroic launcher metadata.
|
||||
|
||||
> [!NOTE]
|
||||
> Without steampoacher enabled, Steam covers will be bad (600×900 instead of high resolution).
|
||||
|
||||
## Notes
|
||||
|
||||
- Scans all detected Steam library folders, Lutris SQLite databases, and Heroic store caches (Legendary, GOG, Nile).
|
||||
- Results are cached in `~/.cache/gamelauncher/games.json` and rescanned on click if sources changed.
|
||||
- No external CLI tools (curl, wget, python3, grep) are invoked anywhere in the plugin.
|
||||
Executable
+1165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,634 @@
|
||||
local games = {}
|
||||
local filtered = {}
|
||||
local searchText = ""
|
||||
local loading = false
|
||||
local errorMsg = ""
|
||||
local scanCounter = 0
|
||||
local building = false
|
||||
local MAX_VISIBLE = 200
|
||||
local initOk = true
|
||||
|
||||
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 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 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)
|
||||
if text == "" then
|
||||
filtered = games
|
||||
return
|
||||
end
|
||||
local lower = text:lower()
|
||||
local result = {}
|
||||
for _, g in ipairs(games) 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 }),
|
||||
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 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
|
||||
return ui.row({
|
||||
key = g.id .. "_" .. index,
|
||||
align = "center",
|
||||
gap = 14,
|
||||
paddingH = 12,
|
||||
paddingV = 8,
|
||||
radius = 8,
|
||||
border = 0,
|
||||
}, {
|
||||
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 = "onLaunch_" .. index,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
local function renderGameList()
|
||||
if #filtered > MAX_VISIBLE then
|
||||
local rows = {}
|
||||
for i = 1, MAX_VISIBLE do
|
||||
table.insert(rows, renderGameRow(filtered[i], i))
|
||||
end
|
||||
return rows
|
||||
end
|
||||
local rows = {}
|
||||
for i, g in ipairs(filtered) do
|
||||
table.insert(rows, renderGameRow(g, 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 }, {
|
||||
ui.column({ padding = 12, gap = 8 }, {
|
||||
renderHeader(),
|
||||
renderSearchBar(),
|
||||
}),
|
||||
ui.scroll({ flexGrow = 1, gap = 6, paddingH = 12 }, 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 ok = noctalia.runAsync("/usr/bin/cc -o " .. binary .. " " .. cSource .. " -lsqlite3", function(res)
|
||||
building = false
|
||||
if res.exitCode == 0 then
|
||||
loading = false
|
||||
scanGames()
|
||||
else
|
||||
loading = false
|
||||
errorMsg = "Failed to build gamelauncher. Install gcc and libsqlite3-dev. (exit " .. (res.exitCode or "?") .. ")"
|
||||
noctalia.log("build failed: " .. (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 noctalia.fileExists(binary) then
|
||||
buildBinary()
|
||||
return
|
||||
end
|
||||
loading = true
|
||||
errorMsg = ""
|
||||
scanCounter = scanCounter + 1
|
||||
searchText = ""
|
||||
render()
|
||||
local ok = noctalia.runAsync(binary .. " --steam --lutris --heroic --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
|
||||
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
|
||||
filtered = games
|
||||
end)
|
||||
if not ok3 then
|
||||
loading = false
|
||||
errorMsg = "Scan error: " .. tostring(err3)
|
||||
games = {}
|
||||
filtered = games
|
||||
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 onLaunch_1() if filtered[1] then launchGame(filtered[1].id) end end
|
||||
function onLaunch_2() if filtered[2] then launchGame(filtered[2].id) end end
|
||||
function onLaunch_3() if filtered[3] then launchGame(filtered[3].id) end end
|
||||
function onLaunch_4() if filtered[4] then launchGame(filtered[4].id) end end
|
||||
function onLaunch_5() if filtered[5] then launchGame(filtered[5].id) end end
|
||||
function onLaunch_6() if filtered[6] then launchGame(filtered[6].id) end end
|
||||
function onLaunch_7() if filtered[7] then launchGame(filtered[7].id) end end
|
||||
function onLaunch_8() if filtered[8] then launchGame(filtered[8].id) end end
|
||||
function onLaunch_9() if filtered[9] then launchGame(filtered[9].id) end end
|
||||
function onLaunch_10() if filtered[10] then launchGame(filtered[10].id) end end
|
||||
function onLaunch_11() if filtered[11] then launchGame(filtered[11].id) end end
|
||||
function onLaunch_12() if filtered[12] then launchGame(filtered[12].id) end end
|
||||
function onLaunch_13() if filtered[13] then launchGame(filtered[13].id) end end
|
||||
function onLaunch_14() if filtered[14] then launchGame(filtered[14].id) end end
|
||||
function onLaunch_15() if filtered[15] then launchGame(filtered[15].id) end end
|
||||
function onLaunch_16() if filtered[16] then launchGame(filtered[16].id) end end
|
||||
function onLaunch_17() if filtered[17] then launchGame(filtered[17].id) end end
|
||||
function onLaunch_18() if filtered[18] then launchGame(filtered[18].id) end end
|
||||
function onLaunch_19() if filtered[19] then launchGame(filtered[19].id) end end
|
||||
function onLaunch_20() if filtered[20] then launchGame(filtered[20].id) end end
|
||||
function onLaunch_21() if filtered[21] then launchGame(filtered[21].id) end end
|
||||
function onLaunch_22() if filtered[22] then launchGame(filtered[22].id) end end
|
||||
function onLaunch_23() if filtered[23] then launchGame(filtered[23].id) end end
|
||||
function onLaunch_24() if filtered[24] then launchGame(filtered[24].id) end end
|
||||
function onLaunch_25() if filtered[25] then launchGame(filtered[25].id) end end
|
||||
function onLaunch_26() if filtered[26] then launchGame(filtered[26].id) end end
|
||||
function onLaunch_27() if filtered[27] then launchGame(filtered[27].id) end end
|
||||
function onLaunch_28() if filtered[28] then launchGame(filtered[28].id) end end
|
||||
function onLaunch_29() if filtered[29] then launchGame(filtered[29].id) end end
|
||||
function onLaunch_30() if filtered[30] then launchGame(filtered[30].id) end end
|
||||
function onLaunch_31() if filtered[31] then launchGame(filtered[31].id) end end
|
||||
function onLaunch_32() if filtered[32] then launchGame(filtered[32].id) end end
|
||||
function onLaunch_33() if filtered[33] then launchGame(filtered[33].id) end end
|
||||
function onLaunch_34() if filtered[34] then launchGame(filtered[34].id) end end
|
||||
function onLaunch_35() if filtered[35] then launchGame(filtered[35].id) end end
|
||||
function onLaunch_36() if filtered[36] then launchGame(filtered[36].id) end end
|
||||
function onLaunch_37() if filtered[37] then launchGame(filtered[37].id) end end
|
||||
function onLaunch_38() if filtered[38] then launchGame(filtered[38].id) end end
|
||||
function onLaunch_39() if filtered[39] then launchGame(filtered[39].id) end end
|
||||
function onLaunch_40() if filtered[40] then launchGame(filtered[40].id) end end
|
||||
function onLaunch_41() if filtered[41] then launchGame(filtered[41].id) end end
|
||||
function onLaunch_42() if filtered[42] then launchGame(filtered[42].id) end end
|
||||
function onLaunch_43() if filtered[43] then launchGame(filtered[43].id) end end
|
||||
function onLaunch_44() if filtered[44] then launchGame(filtered[44].id) end end
|
||||
function onLaunch_45() if filtered[45] then launchGame(filtered[45].id) end end
|
||||
function onLaunch_46() if filtered[46] then launchGame(filtered[46].id) end end
|
||||
function onLaunch_47() if filtered[47] then launchGame(filtered[47].id) end end
|
||||
function onLaunch_48() if filtered[48] then launchGame(filtered[48].id) end end
|
||||
function onLaunch_49() if filtered[49] then launchGame(filtered[49].id) end end
|
||||
function onLaunch_50() if filtered[50] then launchGame(filtered[50].id) end end
|
||||
function onLaunch_51() if filtered[51] then launchGame(filtered[51].id) end end
|
||||
function onLaunch_52() if filtered[52] then launchGame(filtered[52].id) end end
|
||||
function onLaunch_53() if filtered[53] then launchGame(filtered[53].id) end end
|
||||
function onLaunch_54() if filtered[54] then launchGame(filtered[54].id) end end
|
||||
function onLaunch_55() if filtered[55] then launchGame(filtered[55].id) end end
|
||||
function onLaunch_56() if filtered[56] then launchGame(filtered[56].id) end end
|
||||
function onLaunch_57() if filtered[57] then launchGame(filtered[57].id) end end
|
||||
function onLaunch_58() if filtered[58] then launchGame(filtered[58].id) end end
|
||||
function onLaunch_59() if filtered[59] then launchGame(filtered[59].id) end end
|
||||
function onLaunch_60() if filtered[60] then launchGame(filtered[60].id) end end
|
||||
function onLaunch_61() if filtered[61] then launchGame(filtered[61].id) end end
|
||||
function onLaunch_62() if filtered[62] then launchGame(filtered[62].id) end end
|
||||
function onLaunch_63() if filtered[63] then launchGame(filtered[63].id) end end
|
||||
function onLaunch_64() if filtered[64] then launchGame(filtered[64].id) end end
|
||||
function onLaunch_65() if filtered[65] then launchGame(filtered[65].id) end end
|
||||
function onLaunch_66() if filtered[66] then launchGame(filtered[66].id) end end
|
||||
function onLaunch_67() if filtered[67] then launchGame(filtered[67].id) end end
|
||||
function onLaunch_68() if filtered[68] then launchGame(filtered[68].id) end end
|
||||
function onLaunch_69() if filtered[69] then launchGame(filtered[69].id) end end
|
||||
function onLaunch_70() if filtered[70] then launchGame(filtered[70].id) end end
|
||||
function onLaunch_71() if filtered[71] then launchGame(filtered[71].id) end end
|
||||
function onLaunch_72() if filtered[72] then launchGame(filtered[72].id) end end
|
||||
function onLaunch_73() if filtered[73] then launchGame(filtered[73].id) end end
|
||||
function onLaunch_74() if filtered[74] then launchGame(filtered[74].id) end end
|
||||
function onLaunch_75() if filtered[75] then launchGame(filtered[75].id) end end
|
||||
function onLaunch_76() if filtered[76] then launchGame(filtered[76].id) end end
|
||||
function onLaunch_77() if filtered[77] then launchGame(filtered[77].id) end end
|
||||
function onLaunch_78() if filtered[78] then launchGame(filtered[78].id) end end
|
||||
function onLaunch_79() if filtered[79] then launchGame(filtered[79].id) end end
|
||||
function onLaunch_80() if filtered[80] then launchGame(filtered[80].id) end end
|
||||
function onLaunch_81() if filtered[81] then launchGame(filtered[81].id) end end
|
||||
function onLaunch_82() if filtered[82] then launchGame(filtered[82].id) end end
|
||||
function onLaunch_83() if filtered[83] then launchGame(filtered[83].id) end end
|
||||
function onLaunch_84() if filtered[84] then launchGame(filtered[84].id) end end
|
||||
function onLaunch_85() if filtered[85] then launchGame(filtered[85].id) end end
|
||||
function onLaunch_86() if filtered[86] then launchGame(filtered[86].id) end end
|
||||
function onLaunch_87() if filtered[87] then launchGame(filtered[87].id) end end
|
||||
function onLaunch_88() if filtered[88] then launchGame(filtered[88].id) end end
|
||||
function onLaunch_89() if filtered[89] then launchGame(filtered[89].id) end end
|
||||
function onLaunch_90() if filtered[90] then launchGame(filtered[90].id) end end
|
||||
function onLaunch_91() if filtered[91] then launchGame(filtered[91].id) end end
|
||||
function onLaunch_92() if filtered[92] then launchGame(filtered[92].id) end end
|
||||
function onLaunch_93() if filtered[93] then launchGame(filtered[93].id) end end
|
||||
function onLaunch_94() if filtered[94] then launchGame(filtered[94].id) end end
|
||||
function onLaunch_95() if filtered[95] then launchGame(filtered[95].id) end end
|
||||
function onLaunch_96() if filtered[96] then launchGame(filtered[96].id) end end
|
||||
function onLaunch_97() if filtered[97] then launchGame(filtered[97].id) end end
|
||||
function onLaunch_98() if filtered[98] then launchGame(filtered[98].id) end end
|
||||
function onLaunch_99() if filtered[99] then launchGame(filtered[99].id) end end
|
||||
function onLaunch_100() if filtered[100] then launchGame(filtered[100].id) end end
|
||||
function onLaunch_101() if filtered[101] then launchGame(filtered[101].id) end end
|
||||
function onLaunch_102() if filtered[102] then launchGame(filtered[102].id) end end
|
||||
function onLaunch_103() if filtered[103] then launchGame(filtered[103].id) end end
|
||||
function onLaunch_104() if filtered[104] then launchGame(filtered[104].id) end end
|
||||
function onLaunch_105() if filtered[105] then launchGame(filtered[105].id) end end
|
||||
function onLaunch_106() if filtered[106] then launchGame(filtered[106].id) end end
|
||||
function onLaunch_107() if filtered[107] then launchGame(filtered[107].id) end end
|
||||
function onLaunch_108() if filtered[108] then launchGame(filtered[108].id) end end
|
||||
function onLaunch_109() if filtered[109] then launchGame(filtered[109].id) end end
|
||||
function onLaunch_110() if filtered[110] then launchGame(filtered[110].id) end end
|
||||
function onLaunch_111() if filtered[111] then launchGame(filtered[111].id) end end
|
||||
function onLaunch_112() if filtered[112] then launchGame(filtered[112].id) end end
|
||||
function onLaunch_113() if filtered[113] then launchGame(filtered[113].id) end end
|
||||
function onLaunch_114() if filtered[114] then launchGame(filtered[114].id) end end
|
||||
function onLaunch_115() if filtered[115] then launchGame(filtered[115].id) end end
|
||||
function onLaunch_116() if filtered[116] then launchGame(filtered[116].id) end end
|
||||
function onLaunch_117() if filtered[117] then launchGame(filtered[117].id) end end
|
||||
function onLaunch_118() if filtered[118] then launchGame(filtered[118].id) end end
|
||||
function onLaunch_119() if filtered[119] then launchGame(filtered[119].id) end end
|
||||
function onLaunch_120() if filtered[120] then launchGame(filtered[120].id) end end
|
||||
function onLaunch_121() if filtered[121] then launchGame(filtered[121].id) end end
|
||||
function onLaunch_122() if filtered[122] then launchGame(filtered[122].id) end end
|
||||
function onLaunch_123() if filtered[123] then launchGame(filtered[123].id) end end
|
||||
function onLaunch_124() if filtered[124] then launchGame(filtered[124].id) end end
|
||||
function onLaunch_125() if filtered[125] then launchGame(filtered[125].id) end end
|
||||
function onLaunch_126() if filtered[126] then launchGame(filtered[126].id) end end
|
||||
function onLaunch_127() if filtered[127] then launchGame(filtered[127].id) end end
|
||||
function onLaunch_128() if filtered[128] then launchGame(filtered[128].id) end end
|
||||
function onLaunch_129() if filtered[129] then launchGame(filtered[129].id) end end
|
||||
function onLaunch_130() if filtered[130] then launchGame(filtered[130].id) end end
|
||||
function onLaunch_131() if filtered[131] then launchGame(filtered[131].id) end end
|
||||
function onLaunch_132() if filtered[132] then launchGame(filtered[132].id) end end
|
||||
function onLaunch_133() if filtered[133] then launchGame(filtered[133].id) end end
|
||||
function onLaunch_134() if filtered[134] then launchGame(filtered[134].id) end end
|
||||
function onLaunch_135() if filtered[135] then launchGame(filtered[135].id) end end
|
||||
function onLaunch_136() if filtered[136] then launchGame(filtered[136].id) end end
|
||||
function onLaunch_137() if filtered[137] then launchGame(filtered[137].id) end end
|
||||
function onLaunch_138() if filtered[138] then launchGame(filtered[138].id) end end
|
||||
function onLaunch_139() if filtered[139] then launchGame(filtered[139].id) end end
|
||||
function onLaunch_140() if filtered[140] then launchGame(filtered[140].id) end end
|
||||
function onLaunch_141() if filtered[141] then launchGame(filtered[141].id) end end
|
||||
function onLaunch_142() if filtered[142] then launchGame(filtered[142].id) end end
|
||||
function onLaunch_143() if filtered[143] then launchGame(filtered[143].id) end end
|
||||
function onLaunch_144() if filtered[144] then launchGame(filtered[144].id) end end
|
||||
function onLaunch_145() if filtered[145] then launchGame(filtered[145].id) end end
|
||||
function onLaunch_146() if filtered[146] then launchGame(filtered[146].id) end end
|
||||
function onLaunch_147() if filtered[147] then launchGame(filtered[147].id) end end
|
||||
function onLaunch_148() if filtered[148] then launchGame(filtered[148].id) end end
|
||||
function onLaunch_149() if filtered[149] then launchGame(filtered[149].id) end end
|
||||
function onLaunch_150() if filtered[150] then launchGame(filtered[150].id) end end
|
||||
function onLaunch_151() if filtered[151] then launchGame(filtered[151].id) end end
|
||||
function onLaunch_152() if filtered[152] then launchGame(filtered[152].id) end end
|
||||
function onLaunch_153() if filtered[153] then launchGame(filtered[153].id) end end
|
||||
function onLaunch_154() if filtered[154] then launchGame(filtered[154].id) end end
|
||||
function onLaunch_155() if filtered[155] then launchGame(filtered[155].id) end end
|
||||
function onLaunch_156() if filtered[156] then launchGame(filtered[156].id) end end
|
||||
function onLaunch_157() if filtered[157] then launchGame(filtered[157].id) end end
|
||||
function onLaunch_158() if filtered[158] then launchGame(filtered[158].id) end end
|
||||
function onLaunch_159() if filtered[159] then launchGame(filtered[159].id) end end
|
||||
function onLaunch_160() if filtered[160] then launchGame(filtered[160].id) end end
|
||||
function onLaunch_161() if filtered[161] then launchGame(filtered[161].id) end end
|
||||
function onLaunch_162() if filtered[162] then launchGame(filtered[162].id) end end
|
||||
function onLaunch_163() if filtered[163] then launchGame(filtered[163].id) end end
|
||||
function onLaunch_164() if filtered[164] then launchGame(filtered[164].id) end end
|
||||
function onLaunch_165() if filtered[165] then launchGame(filtered[165].id) end end
|
||||
function onLaunch_166() if filtered[166] then launchGame(filtered[166].id) end end
|
||||
function onLaunch_167() if filtered[167] then launchGame(filtered[167].id) end end
|
||||
function onLaunch_168() if filtered[168] then launchGame(filtered[168].id) end end
|
||||
function onLaunch_169() if filtered[169] then launchGame(filtered[169].id) end end
|
||||
function onLaunch_170() if filtered[170] then launchGame(filtered[170].id) end end
|
||||
function onLaunch_171() if filtered[171] then launchGame(filtered[171].id) end end
|
||||
function onLaunch_172() if filtered[172] then launchGame(filtered[172].id) end end
|
||||
function onLaunch_173() if filtered[173] then launchGame(filtered[173].id) end end
|
||||
function onLaunch_174() if filtered[174] then launchGame(filtered[174].id) end end
|
||||
function onLaunch_175() if filtered[175] then launchGame(filtered[175].id) end end
|
||||
function onLaunch_176() if filtered[176] then launchGame(filtered[176].id) end end
|
||||
function onLaunch_177() if filtered[177] then launchGame(filtered[177].id) end end
|
||||
function onLaunch_178() if filtered[178] then launchGame(filtered[178].id) end end
|
||||
function onLaunch_179() if filtered[179] then launchGame(filtered[179].id) end end
|
||||
function onLaunch_180() if filtered[180] then launchGame(filtered[180].id) end end
|
||||
function onLaunch_181() if filtered[181] then launchGame(filtered[181].id) end end
|
||||
function onLaunch_182() if filtered[182] then launchGame(filtered[182].id) end end
|
||||
function onLaunch_183() if filtered[183] then launchGame(filtered[183].id) end end
|
||||
function onLaunch_184() if filtered[184] then launchGame(filtered[184].id) end end
|
||||
function onLaunch_185() if filtered[185] then launchGame(filtered[185].id) end end
|
||||
function onLaunch_186() if filtered[186] then launchGame(filtered[186].id) end end
|
||||
function onLaunch_187() if filtered[187] then launchGame(filtered[187].id) end end
|
||||
function onLaunch_188() if filtered[188] then launchGame(filtered[188].id) end end
|
||||
function onLaunch_189() if filtered[189] then launchGame(filtered[189].id) end end
|
||||
function onLaunch_190() if filtered[190] then launchGame(filtered[190].id) end end
|
||||
function onLaunch_191() if filtered[191] then launchGame(filtered[191].id) end end
|
||||
function onLaunch_192() if filtered[192] then launchGame(filtered[192].id) end end
|
||||
function onLaunch_193() if filtered[193] then launchGame(filtered[193].id) end end
|
||||
function onLaunch_194() if filtered[194] then launchGame(filtered[194].id) end end
|
||||
function onLaunch_195() if filtered[195] then launchGame(filtered[195].id) end end
|
||||
function onLaunch_196() if filtered[196] then launchGame(filtered[196].id) end end
|
||||
function onLaunch_197() if filtered[197] then launchGame(filtered[197].id) end end
|
||||
function onLaunch_198() if filtered[198] then launchGame(filtered[198].id) end end
|
||||
function onLaunch_199() if filtered[199] then launchGame(filtered[199].id) end end
|
||||
function onLaunch_200() if filtered[200] then launchGame(filtered[200].id) end end
|
||||
|
||||
function onOpen(context)
|
||||
if #games == 0 then
|
||||
scanGames()
|
||||
return
|
||||
end
|
||||
for _, g in ipairs(games) do
|
||||
if (g.runner == "steam" or g.runner == "heroic") and (not g.cover or #g.cover == 0) then
|
||||
scanGames()
|
||||
return
|
||||
end
|
||||
end
|
||||
filtered = games
|
||||
render()
|
||||
end
|
||||
|
||||
function onClose()
|
||||
panel.close()
|
||||
end
|
||||
|
||||
function onRescan()
|
||||
scanGames()
|
||||
end
|
||||
|
||||
function onSearch(value)
|
||||
searchText = value or ""
|
||||
filterGames(searchText)
|
||||
render()
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
id = "alexander/game-launcher"
|
||||
name = "Game Launcher"
|
||||
version = "1.0.0"
|
||||
plugin_api = 4
|
||||
author = "Alexander"
|
||||
license = "MIT"
|
||||
icon = "device-gamepad-2"
|
||||
description = "Browse and launch games from Steam, Lutris, and Heroic."
|
||||
dependencies = ["cc", "libsqlite3-dev", "xdg-utils"]
|
||||
tags = ["gaming", "launcher", "utility"]
|
||||
|
||||
[[widget]]
|
||||
id = "launcher"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
default = "device-gamepad-2"
|
||||
|
||||
[[panel]]
|
||||
id = "browser"
|
||||
entry = "panel.luau"
|
||||
width = 720
|
||||
height = 520
|
||||
placement = "floating"
|
||||
position = "center"
|
||||
open_near_click = true
|
||||
|
||||
[[panel.setting]]
|
||||
key = "steampoacher_enabled"
|
||||
type = "bool"
|
||||
label_key = "settings.steampoacher_enabled.label"
|
||||
default = false
|
||||
|
||||
[[launcher_provider]]
|
||||
id = "search"
|
||||
entry = "search.luau"
|
||||
prefix = "g"
|
||||
glyph = "device-gamepad-2"
|
||||
include_in_global_search = true
|
||||
debounce_ms = 150
|
||||
@@ -0,0 +1,82 @@
|
||||
local games = {}
|
||||
local ready = false
|
||||
local binary = noctalia.pluginDir() .. "/gamelauncher"
|
||||
local cSource = noctalia.pluginDir() .. "/gamelauncher.c"
|
||||
|
||||
local function ensureGames(cb)
|
||||
local function runScan()
|
||||
local ok = noctalia.runAsync(binary .. " --steam --lutris --heroic --force", function(res)
|
||||
if res.exitCode == 0 and res.stdout and #res.stdout > 0 then
|
||||
local parsed, err = noctalia.json.decode(res.stdout)
|
||||
if parsed then
|
||||
games = parsed
|
||||
ready = true
|
||||
end
|
||||
end
|
||||
cb()
|
||||
end)
|
||||
if not ok then cb() end
|
||||
end
|
||||
if ready then
|
||||
cb()
|
||||
return
|
||||
end
|
||||
if not noctalia.fileExists(binary) then
|
||||
local ok = noctalia.runAsync("/usr/bin/cc -o " .. binary .. " " .. cSource .. " -lsqlite3", function(res)
|
||||
if res.exitCode == 0 then
|
||||
runScan()
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
if not ok then cb() end
|
||||
return
|
||||
end
|
||||
runScan()
|
||||
end
|
||||
|
||||
function onQuery(text)
|
||||
if text == "" then
|
||||
launcher.setResults(text, {
|
||||
{ id = "hint", title = "Type a game name to search", glyph = "device-gamepad-2" },
|
||||
})
|
||||
return
|
||||
end
|
||||
ensureGames(function()
|
||||
local lower = text:lower()
|
||||
local results = {}
|
||||
for _, g in ipairs(games) do
|
||||
if g.name:lower():find(lower, 1, true) then
|
||||
local meta = { steam = { glyph = "brand-steam", color = "steam" }, lutris = { glyph = "device-gamepad", color = "warning" }, heroic = { glyph = "app-window", color = "heroic" } }
|
||||
local m = meta[g.runner] or { glyph = "app-window", color = "on_surface" }
|
||||
table.insert(results, {
|
||||
id = g.id,
|
||||
title = g.name,
|
||||
subtitle = "Launch via " .. g.runner,
|
||||
glyph = m.glyph,
|
||||
})
|
||||
end
|
||||
end
|
||||
launcher.setResults(text, results)
|
||||
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
|
||||
|
||||
function onActivate(id)
|
||||
for _, g in ipairs(games) 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"settings": {
|
||||
"glyph": {
|
||||
"label": "Icon"
|
||||
},
|
||||
"steampoacher_enabled": {
|
||||
"label": "Steampoacher proxy for covers"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
local glyph = noctalia.getConfig("glyph")
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(60000)
|
||||
barWidget.setGlyph(glyph)
|
||||
barWidget.setTooltip("Game Launcher — click to browse")
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("alexander/game-launcher:browser")
|
||||
end
|
||||
Reference in New Issue
Block a user