From 5c59d225b811675d858b7f8115c506962438ba2e Mon Sep 17 00:00:00 2001 From: Ahmed Emad <122463699+Ahmed5Emad@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:02:33 +0300 Subject: [PATCH] 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 --- game-launcher/README.md | 70 ++ game-launcher/gamelauncher.c | 1165 ++++++++++++++++++++++++++++ game-launcher/panel.luau | 634 +++++++++++++++ game-launcher/plugin.toml | 43 + game-launcher/search.luau | 82 ++ game-launcher/thumbnail.webp | Bin 0 -> 53700 bytes game-launcher/translations/en.json | 10 + game-launcher/widget.luau | 11 + 8 files changed, 2015 insertions(+) create mode 100644 game-launcher/README.md create mode 100755 game-launcher/gamelauncher.c create mode 100644 game-launcher/panel.luau create mode 100644 game-launcher/plugin.toml create mode 100644 game-launcher/search.luau create mode 100644 game-launcher/thumbnail.webp create mode 100644 game-launcher/translations/en.json create mode 100644 game-launcher/widget.luau diff --git a/game-launcher/README.md b/game-launcher/README.md new file mode 100644 index 0000000..a9a794c --- /dev/null +++ b/game-launcher/README.md @@ -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. diff --git a/game-launcher/gamelauncher.c b/game-launcher/gamelauncher.c new file mode 100755 index 0000000..4b416b7 --- /dev/null +++ b/game-launcher/gamelauncher.c @@ -0,0 +1,1165 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_GAMES 4096 +#define MAX_STR 1024 +#define MAX_PATH 8192 +#define MAX_RUN_CMD 4096 + +typedef struct { + char id[MAX_STR]; + char name[MAX_STR]; + char runner[MAX_STR]; + char cover[MAX_PATH]; + char cover_url[MAX_PATH]; + char run_command[MAX_RUN_CMD]; + char slug[MAX_STR]; +} Game; + +typedef struct { + Game *games; + int count; + int capacity; +} GameArray; + +GameArray ga = {0}; + +void init_games() { + ga.capacity = MAX_GAMES; + ga.games = calloc(ga.capacity, sizeof(Game)); + ga.count = 0; +} + +void free_games() { + free(ga.games); + ga.games = NULL; + ga.count = 0; + ga.capacity = 0; +} + +int game_exists(const char *name) { + for (int i = 0; i < ga.count; i++) { + if (strcasecmp(ga.games[i].name, name) == 0) return 1; + } + return 0; +} + +void add_game(const char *id, const char *name, const char *runner, + const char *cover, const char *cover_url, const char *run_command, const char *slug) { + if (ga.count >= ga.capacity) return; + Game *g = &ga.games[ga.count++]; + strncpy(g->id, id, MAX_STR - 1); + strncpy(g->name, name, MAX_STR - 1); + strncpy(g->runner, runner, MAX_STR - 1); + strncpy(g->cover, cover ? cover : "", MAX_PATH - 1); + strncpy(g->cover_url, cover_url ? cover_url : "", MAX_PATH - 1); + strncpy(g->run_command, run_command ? run_command : "", MAX_RUN_CMD - 1); + strncpy(g->slug, slug ? slug : "", MAX_STR - 1); +} + +void print_json_escaped(const char *s) { + for (; *s; s++) { + if (*s == '"' || *s == '\\') putchar('\\'); + putchar(*s); + } +} + +void print_json() { + printf("[\n"); + for (int i = 0; i < ga.count; i++) { + Game *g = &ga.games[i]; + printf(" {\n"); + printf(" \"id\": \""); + print_json_escaped(g->id); + printf("\",\n"); + printf(" \"name\": \""); + print_json_escaped(g->name); + printf("\",\n"); + printf(" \"runner\": \""); + print_json_escaped(g->runner); + printf("\",\n"); + printf(" \"cover\": \""); + print_json_escaped(g->cover); + printf("\",\n"); + printf(" \"cover_url\": \""); + print_json_escaped(g->cover_url); + printf("\",\n"); + printf(" \"run_command\": \""); + print_json_escaped(g->run_command); + printf("\",\n"); + printf(" \"slug\": \""); + print_json_escaped(g->slug); + printf("\"\n"); + printf(" }%s\n", i < ga.count - 1 ? "," : ""); + } + printf("]\n"); +} + +char *get_home() { + char *home = getenv("HOME"); + return home ? home : ""; +} + +char *get_xdg_data_home() { + char *xdg = getenv("XDG_DATA_HOME"); + if (xdg && xdg[0]) return xdg; + static char buf[MAX_PATH]; + snprintf(buf, sizeof(buf), "%s/.local/share", get_home()); + return buf; +} + +char *get_xdg_config_home() { + char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg && xdg[0]) return xdg; + static char buf[MAX_PATH]; + snprintf(buf, sizeof(buf), "%s/.config", get_home()); + return buf; +} + +char *get_xdg_cache_home() { + char *xdg = getenv("XDG_CACHE_HOME"); + if (xdg) return xdg; + static char buf[MAX_PATH]; + snprintf(buf, sizeof(buf), "%s/.cache", get_home()); + return buf; +} + +int file_exists(const char *path) { + struct stat st; + return stat(path, &st) == 0; +} + +void scan_steam_shortcuts() { + char base[MAX_PATH]; + const char *data_home = get_xdg_data_home(); + + const char *steam_bases[] = { + "/Steam/userdata", + "/.steam/root/userdata", + "/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata", + NULL + }; + + for (int b = 0; steam_bases[b]; b++) { + snprintf(base, sizeof(base), "%s%s", data_home, steam_bases[b]); + + DIR *ud = opendir(base); + if (!ud) continue; + + struct dirent *entry; + while ((entry = readdir(ud))) { + if (entry->d_name[0] == '.') continue; + + char config_path[MAX_PATH]; + snprintf(config_path, sizeof(config_path), "%s/%s/config/shortcuts.vdf", + base, entry->d_name); + + FILE *f = fopen(config_path, "rb"); + if (!f) continue; + + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + rewind(f); + + char *buf = malloc(fsize + 1); + if (!buf) { fclose(f); continue; } + size_t nread = fread(buf, 1, fsize, f); + buf[nread] = '\0'; + fclose(f); + + char *p = buf; + while ((p = strstr(p, "\"AppName\""))) { + char *vstart = p + 9; + while (*vstart && *vstart != '"') vstart++; + if (*vstart != '"') { p++; continue; } + vstart++; + char *vend = strchr(vstart, '"'); + if (!vend) { p++; continue; } + + char appname[MAX_STR]; + size_t nlen = vend - vstart; + if (nlen >= MAX_STR) nlen = MAX_STR - 1; + strncpy(appname, vstart, nlen); + appname[nlen] = '\0'; + + if (game_exists(appname)) { p = vend; continue; } + + char *appid_p = strstr(vend, "\"appid\""); + if (!appid_p) { p = vend; continue; } + + appid_p += 7; + while (*appid_p && *appid_p != '"') appid_p++; + if (*appid_p == '"') { + appid_p++; + char *appid_end = strchr(appid_p, '"'); + if (appid_end) { + char appid_str[32]; + size_t idlen = appid_end - appid_p; + if (idlen >= sizeof(appid_str)) idlen = sizeof(appid_str) - 1; + strncpy(appid_str, appid_p, idlen); + appid_str[idlen] = '\0'; + + unsigned long long appid = strtoull(appid_str, NULL, 10); + unsigned long long short_appid = appid & 0xFFFFFFFFULL; + unsigned long long long_id = (short_appid << 32) | 0x02000000ULL; + + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), + "steam://rungameid/%llu", long_id); + + add_game(appid_str, appname, "steam", "", "", run_command, ""); + } + } + p = appid_p; + } + + free(buf); + } + closedir(ud); + } +} + +static void steam_acf_parse_value(const char *pos, const char *key, + char *out, size_t out_size) { + char search_key[64]; + snprintf(search_key, sizeof(search_key), "\"%s\"", key); + const char *kp = strstr(pos, search_key); + if (!kp) { out[0] = '\0'; return; } + kp += strlen(search_key); + while (*kp && *kp != '"') kp++; + if (*kp++ != '"') { out[0] = '\0'; return; } + const char *end = strchr(kp, '"'); + if (!end) { out[0] = '\0'; return; } + size_t len = end - kp; + if (len >= out_size) len = out_size - 1; + strncpy(out, kp, len); + out[len] = '\0'; +} + +int should_exclude_steam(const char *name) { + const char *excludes[] = { + "Proton", "Steam Runtime", "Steamworks", "Steam Client", + "Steam", "SteamVR", "Steam Linux Runtime", NULL + }; + for (int i = 0; excludes[i]; i++) { + if (strcasestr(name, excludes[i])) return 1; + } + return 0; +} + +void scan_steam() { + const char *data_home = get_xdg_data_home(); + + const char *steam_roots[] = { + "/Steam", + "/.steam/steam", + "/.var/app/com.valvesoftware.Steam/.local/share/Steam", + NULL + }; + + char (*steamapps_paths)[MAX_PATH] = calloc(MAX_GAMES, MAX_PATH); + if (!steamapps_paths) return; + int num_steamapps = 0; + + for (int r = 0; steam_roots[r]; r++) { + char sp[MAX_PATH]; + snprintf(sp, sizeof(sp), "%s%s/steamapps", data_home, steam_roots[r]); + if (file_exists(sp)) { + strncpy(steamapps_paths[num_steamapps++], sp, MAX_PATH - 1); + } + } + + for (int r = 0; steam_roots[r]; r++) { + char vdf_path[MAX_PATH]; + snprintf(vdf_path, sizeof(vdf_path), "%s%s/steamapps/libraryfolders.vdf", + data_home, steam_roots[r]); + + FILE *f = fopen(vdf_path, "r"); + if (!f) continue; + + fseek(f, 0, SEEK_END); + long len = ftell(f); + rewind(f); + char *content = malloc(len + 1); + if (!content) { fclose(f); continue; } + fread(content, 1, len, f); + content[len] = '\0'; + fclose(f); + + char *p = content; + while ((p = strstr(p, "\"path\""))) { + p += 6; + while (*p && *p != '"') p++; + if (*p++ != '"') continue; + char *end = strchr(p, '"'); + if (!end) continue; + + char path_buf[MAX_PATH]; + size_t plen = end - p; + if (plen >= sizeof(path_buf)) plen = sizeof(path_buf) - 1; + strncpy(path_buf, p, plen); + path_buf[plen] = '\0'; + + char sa[MAX_PATH]; + snprintf(sa, sizeof(sa), "%s/steamapps", path_buf); + if (file_exists(sa)) { + int found = 0; + for (int i = 0; i < num_steamapps; i++) { + if (strcmp(steamapps_paths[i], sa) == 0) { found = 1; break; } + } + if (!found && num_steamapps < MAX_GAMES) { + strncpy(steamapps_paths[num_steamapps++], sa, MAX_PATH - 1); + } + } + + p = end; + } + free(content); + } + + if (num_steamapps == 0) { free(steamapps_paths); return; } + + for (int s = 0; s < num_steamapps; s++) { + DIR *dir = opendir(steamapps_paths[s]); + if (!dir) continue; + + struct dirent *entry; + while ((entry = readdir(dir))) { + if (strncmp(entry->d_name, "appmanifest_", 12) != 0) continue; + if (strcmp(entry->d_name + strlen(entry->d_name) - 4, ".acf") != 0) continue; + + char acf_path[MAX_PATH]; + snprintf(acf_path, sizeof(acf_path), "%s/%s", + steamapps_paths[s], entry->d_name); + + FILE *f = fopen(acf_path, "r"); + if (!f) continue; + + fseek(f, 0, SEEK_END); + long flen = ftell(f); + rewind(f); + char *acf_content = malloc(flen + 1); + if (!acf_content) { fclose(f); continue; } + fread(acf_content, 1, flen, f); + acf_content[flen] = '\0'; + fclose(f); + + char appid_str[32], name[MAX_STR]; + steam_acf_parse_value(acf_content, "appid", appid_str, sizeof(appid_str)); + steam_acf_parse_value(acf_content, "name", name, sizeof(name)); + + free(acf_content); + + if (!appid_str[0] || !name[0] || should_exclude_steam(name)) continue; + if (game_exists(name)) continue; + + char header[MAX_PATH] = ""; + char candidates[MAX_PATH]; + + snprintf(candidates, sizeof(candidates), "%s/../appcache/librarycache/%s_header.jpg", + steamapps_paths[s], appid_str); + if (file_exists(candidates)) strncpy(header, candidates, MAX_PATH - 1); + + if (!header[0]) { + snprintf(candidates, sizeof(candidates), + "%s/../appcache/librarycache/%s/library_600x900.jpg", + steamapps_paths[s], appid_str); + if (file_exists(candidates)) strncpy(header, candidates, MAX_PATH - 1); + } + + if (!header[0]) { + snprintf(candidates, sizeof(candidates), "%s/gamelauncher/steam/%s.jpg", + get_xdg_cache_home(), appid_str); + if (file_exists(candidates)) strncpy(header, candidates, MAX_PATH - 1); + } + + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), "steam://rungameid/%s", appid_str); + + add_game(appid_str, name, "steam", header, "", run_command, ""); + } + closedir(dir); + } + free(steamapps_paths); +} + +void scan_lutris() { + char db_paths[8][MAX_PATH]; + int num_dbs = 0; + const char *data_home = get_xdg_data_home(); + + const char *paths[] = { + "/lutris/pga.db", + "/lutris/lutris.db", + "/lutris/db.sqlite", + NULL + }; + + for (int i = 0; paths[i]; i++) { + snprintf(db_paths[num_dbs], MAX_PATH, "%s%s", data_home, paths[i]); + num_dbs++; + } + + char flatpak_path[MAX_PATH]; + snprintf(flatpak_path, sizeof(flatpak_path), + "%s/.var/app/net.lutris.Lutris/data/lutris/pga.db", get_home()); + if (file_exists(flatpak_path)) + strncpy(db_paths[num_dbs++], flatpak_path, MAX_PATH - 1); + + snprintf(flatpak_path, sizeof(flatpak_path), + "%s/.var/app/net.lutris.Lutris/data/lutris/lutris.db", get_home()); + if (file_exists(flatpak_path)) + strncpy(db_paths[num_dbs++], flatpak_path, MAX_PATH - 1); + + snprintf(flatpak_path, sizeof(flatpak_path), + "%s/.var/app/net.lutris.Lutris/data/lutris/db.sqlite", get_home()); + if (file_exists(flatpak_path)) + strncpy(db_paths[num_dbs++], flatpak_path, MAX_PATH - 1); + + char *chosen_db = NULL; + time_t newest = 0; + + for (int i = 0; i < num_dbs; i++) { + if (!file_exists(db_paths[i])) continue; + struct stat st; + stat(db_paths[i], &st); + if (st.st_mtime > newest) { + newest = st.st_mtime; + chosen_db = db_paths[i]; + } + } + + if (!chosen_db) { + const char *lutris_dirs[] = { + "/lutris", + "/.var/app/net.lutris.Lutris/data/lutris", + NULL + }; + + for (int d = 0; lutris_dirs[d]; d++) { + char dir_path[MAX_PATH]; + snprintf(dir_path, sizeof(dir_path), "%s%s", data_home, lutris_dirs[d]); + + DIR *dir = opendir(dir_path); + if (!dir) continue; + + struct dirent *entry; + while ((entry = readdir(dir))) { + char *dot = strrchr(entry->d_name, '.'); + if (!dot || strcmp(dot, ".db") != 0) continue; + + char fp[MAX_PATH]; + snprintf(fp, sizeof(fp), "%s/%s", dir_path, entry->d_name); + + struct stat st; + stat(fp, &st); + if (st.st_mtime > newest) { + newest = st.st_mtime; + chosen_db = fp; + } + } + closedir(dir); + } + } + + if (!chosen_db) return; + + sqlite3 *db; + int rc = sqlite3_open_v2(chosen_db, &db, SQLITE_OPEN_READONLY, NULL); + if (rc != SQLITE_OK) return; + + sqlite3_stmt *stmt; + + const char *query = + "SELECT id, name, slug, runner " + "FROM games WHERE installed = 1"; + + rc = sqlite3_prepare_v2(db, query, -1, &stmt, NULL); + + if (rc != SQLITE_OK) { + const char *alt_queries[] = { + "SELECT id, name, slug, runner FROM installed_game", + "SELECT id, name, slug, runner FROM game WHERE installed = 1", + NULL + }; + + for (int q = 0; alt_queries[q]; q++) { + rc = sqlite3_prepare_v2(db, alt_queries[q], -1, &stmt, NULL); + if (rc == SQLITE_OK) break; + } + + if (rc != SQLITE_OK) { + sqlite3_close(db); + return; + } + } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *id = (const char *)sqlite3_column_text(stmt, 0); + const char *name = (const char *)sqlite3_column_text(stmt, 1); + const char *slug = (const char *)sqlite3_column_text(stmt, 2); + const char *runner = (const char *)sqlite3_column_text(stmt, 3); + + if (!name || !slug) continue; + if (game_exists(name)) continue; + + char cover[MAX_PATH] = ""; + const char *cover_dirs[] = { + "/.local/share/lutris/coverart", + "/.var/app/net.lutris.Lutris/data/lutris/coverart", + "/.cache/lutris/coverart", + NULL + }; + + for (int c = 0; cover_dirs[c]; c++) { + char cd[MAX_PATH]; + snprintf(cd, sizeof(cd), "%s%s", get_home(), cover_dirs[c]); + + const char *exts[] = {".jpg", ".png", ".jpeg", NULL}; + for (int e = 0; exts[e]; e++) { + char fp[MAX_PATH]; + snprintf(fp, sizeof(fp), "%s/%s%s", cd, slug, exts[e]); + if (file_exists(fp)) { + strncpy(cover, fp, MAX_PATH - 1); + break; + } + } + if (cover[0]) break; + } + + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), "lutris:rungame/%s", slug); + + if (!id) id = "0"; + if (!runner) runner = "linux"; + + add_game(id, name, "lutris", cover, "", run_command, slug ? slug : ""); + } + + sqlite3_finalize(stmt); + sqlite3_close(db); +} + +void scan_lutris_manual_files() { + const char *data_home = get_xdg_data_home(); + char games_dir[MAX_PATH]; + snprintf(games_dir, sizeof(games_dir), "%s/lutris/games", data_home); + + DIR *dir = opendir(games_dir); + if (!dir) return; + + struct dirent *entry; + while ((entry = readdir(dir))) { + char *dot = strrchr(entry->d_name, '.'); + if (!dot || (strcmp(dot, ".yml") != 0 && strcmp(dot, ".yaml") != 0)) continue; + + char fp[MAX_PATH]; + snprintf(fp, sizeof(fp), "%s/%s", games_dir, entry->d_name); + + FILE *f = fopen(fp, "r"); + if (!f) continue; + + char name[MAX_STR] = ""; + char line[1024]; + while (fgets(line, sizeof(line), f)) { + if (strncmp(line, "name:", 5) == 0) { + const char *v = line + 5; + while (*v == ' ') v++; + size_t l = strlen(v); + while (l > 0 && (v[l - 1] == '\n' || v[l - 1] == '\r')) l--; + if (l >= MAX_STR) l = MAX_STR - 1; + strncpy(name, v, l); + name[l] = '\0'; + break; + } + } + fclose(f); + + if (!name[0]) continue; + if (game_exists(name)) continue; + + char slug[MAX_STR]; + for (int si = 0; name[si]; si++) { + slug[si] = isalnum((unsigned char)name[si]) ? + tolower((unsigned char)name[si]) : '-'; + slug[si + 1] = '\0'; + } + + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), "lutris:rungame/%s", slug); + + add_game("0", name, "lutris", "", "", run_command, slug); + } + closedir(dir); +} + +typedef struct { + char **keys; + char **values; + int count; + int cap; +} KVMap; + +void kv_init(KVMap *m) { + m->keys = NULL; + m->values = NULL; + m->count = 0; + m->cap = 0; +} + +void kv_free(KVMap *m) { + for (int i = 0; i < m->count; i++) { + free(m->keys[i]); + free(m->values[i]); + } + free(m->keys); + free(m->values); + m->keys = NULL; + m->values = NULL; + m->count = 0; + m->cap = 0; +} + +void kv_add(KVMap *m, const char *key, const char *value) { + if (m->count >= m->cap) { + m->cap = m->cap ? m->cap * 2 : 16; + m->keys = realloc(m->keys, m->cap * sizeof(char *)); + m->values = realloc(m->values, m->cap * sizeof(char *)); + } + m->keys[m->count] = strdup(key); + m->values[m->count] = strdup(value); + m->count++; +} + +char *kv_get(KVMap *m, const char *key) { + for (int i = 0; i < m->count; i++) { + if (strcmp(m->keys[i], key) == 0) return m->values[i]; + } + return NULL; +} + +char *json_extract_string(const char *json, const char *key) { + char search[128]; + snprintf(search, sizeof(search), "\"%s\"", key); + const char *p = strstr(json, search); + if (!p) return NULL; + p += strlen(search); + while (*p && *p != '"') p++; + if (*p != '"') return NULL; + p++; + size_t max_len = 65536; + char *result = malloc(max_len); + if (!result) return NULL; + size_t o = 0; + while (*p && *p != '"' && o < max_len - 1) { + if (*p == '\\' && *(p + 1)) { + p++; + switch (*p) { + case 'n': result[o++] = '\n'; break; + case 'r': result[o++] = '\r'; break; + case 't': result[o++] = '\t'; break; + case '/': result[o++] = '/'; break; + default: result[o++] = *p; break; + } + } else { + result[o++] = *p; + } + p++; + } + result[o] = '\0'; + return result; +} + +int json_extract_bool(const char *json, const char *key) { + char search[128]; + snprintf(search, sizeof(search), "\"%s\"", key); + const char *p = strstr(json, search); + if (!p) return 0; + p += strlen(search); + while (*p && *p != 't' && *p != 'f') p++; + return (strncmp(p, "true", 4) == 0); +} + +void heroic_scan_sideloaded(const char *config_home) { + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/heroic/sideload_apps/library.json", config_home); + if (!file_exists(path)) return; + FILE *f = fopen(path, "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long len = ftell(f); + rewind(f); + char *content = malloc(len + 1); + if (!content) { fclose(f); return; } + fread(content, 1, len, f); + content[len] = '\0'; + fclose(f); + char *p = content; + int brace_depth = 0; + while (*p) { + p = strstr(p, "\"games\""); + if (!p) break; + p += 7; + while (*p && *p != '[') p++; + if (*p != '[') continue; + p++; + brace_depth = 0; + char *obj_start = NULL; + while (*p) { + if (*p == '{') { + if (brace_depth == 0) obj_start = p; + brace_depth++; + } else if (*p == '}') { + brace_depth--; + if (brace_depth == 0 && obj_start) { + size_t obj_len = p - obj_start + 1; + char *obj = malloc(obj_len + 1); + strncpy(obj, obj_start, obj_len); + obj[obj_len] = '\0'; + char *app_name = json_extract_string(obj, "app_name"); + char *title = json_extract_string(obj, "title"); + int installed = json_extract_bool(obj, "is_installed"); + char *art_cover = json_extract_string(obj, "art_cover"); + if (title && installed && !game_exists(title)) { + char cover_path[MAX_PATH] = ""; + char cover_url[MAX_PATH] = ""; + if (art_cover && strstr(art_cover, "http")) { + strncpy(cover_url, art_cover, MAX_PATH - 1); + } else if (art_cover) { + strncpy(cover_path, art_cover, MAX_PATH - 1); + } + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), + "heroic://launch/sideload/%s", app_name ? app_name : ""); + add_game(app_name ? app_name : "", title, "heroic", + cover_path, cover_url, run_command, ""); + } + free(app_name); free(title); free(art_cover); free(obj); + obj_start = NULL; + } + } + if (*p) p++; + } + } + free(content); +} + +void heroic_load_installed_ids(const char *config_home, char installed_ids[][64], int *count) { + const char *install_files[] = { + "/heroic/store_cache/legendary_install_info.json", + "/heroic/store_cache/gog_install_info.json", + "/heroic/store_cache/nile_install_info.json", + NULL + }; + *count = 0; + for (int f = 0; install_files[f]; f++) { + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s%s", config_home, install_files[f]); + if (!file_exists(path)) continue; + FILE *fp = fopen(path, "r"); + if (!fp) continue; + fseek(fp, 0, SEEK_END); + long len = ftell(fp); + rewind(fp); + char *content = malloc(len + 1); + if (!content) { fclose(fp); continue; } + fread(content, 1, len, fp); + content[len] = '\0'; + fclose(fp); + char *p = content; + while (*p) { + while (*p && *p != '"') p++; + if (!*p) break; + p++; + char *end = strchr(p, '"'); + if (!end) break; + size_t id_len = end - p; + if (id_len > 0 && id_len < 64 && strcmp(p, "__timestamp") != 0) { + if (*count < MAX_GAMES) { + strncpy(installed_ids[*count], p, id_len); + installed_ids[*count][id_len] = '\0'; + (*count)++; + } + } + p = end + 1; + } + free(content); + } +} + +void heroic_load_library_map(const char *path, KVMap *titles, KVMap *covers) { + if (!file_exists(path)) return; + FILE *f = fopen(path, "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long len = ftell(f); + rewind(f); + char *content = malloc(len + 1); + if (!content) { fclose(f); return; } + fread(content, 1, len, f); + content[len] = '\0'; + fclose(f); + char *p = content; + int brace_depth = 0; + while (*p) { + p = strstr(p, "\"library\""); + if (!p) { + p = content; + brace_depth = 0; + char *obj_start = NULL; + while (*p) { + if (*p == '{') { + if (brace_depth == 0) obj_start = p; + brace_depth++; + } else if (*p == '}') { + brace_depth--; + if (brace_depth == 0 && obj_start) { + size_t olen = p - obj_start + 1; + char *obj = malloc(olen + 1); + strncpy(obj, obj_start, olen); + obj[olen] = '\0'; + char *app_name = json_extract_string(obj, "app_name"); + char *title = json_extract_string(obj, "title"); + char *art_cover = json_extract_string(obj, "art_cover"); + if (app_name && title) kv_add(titles, app_name, title); + if (app_name && art_cover) kv_add(covers, app_name, art_cover); + free(app_name); free(title); free(art_cover); free(obj); + obj_start = NULL; + } + } + if (*p) p++; + } + break; + } + p += 8; + while (*p && *p != '[') p++; + if (*p++ != '[') break; + brace_depth = 0; + char *obj_start = NULL; + while (*p) { + if (*p == '{') { + if (brace_depth == 0) obj_start = p; + brace_depth++; + } else if (*p == '}') { + brace_depth--; + if (brace_depth == 0 && obj_start) { + size_t olen = p - obj_start + 1; + char *obj = malloc(olen + 1); + strncpy(obj, obj_start, olen); + obj[olen] = '\0'; + char *app_name = json_extract_string(obj, "app_name"); + char *title = json_extract_string(obj, "title"); + char *art_cover = json_extract_string(obj, "art_cover"); + if (app_name && title) kv_add(titles, app_name, title); + if (app_name && art_cover) kv_add(covers, app_name, art_cover); + free(app_name); free(title); free(art_cover); free(obj); + obj_start = NULL; + } + } + if (*p) p++; + } + break; + } + free(content); +} + +void scan_heroic() { + const char *config_home = get_xdg_config_home(); + heroic_scan_sideloaded(config_home); + char installed_ids[MAX_GAMES][64]; + int num_installed = 0; + heroic_load_installed_ids(config_home, installed_ids, &num_installed); + if (num_installed == 0) return; + KVMap titles, covers; + kv_init(&titles); + kv_init(&covers); + const char *lib_files[] = { "legendary_library.json", "gog_library.json", "nile_library.json", NULL }; + for (int i = 0; lib_files[i]; i++) { + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/heroic/store_cache/%s", config_home, lib_files[i]); + heroic_load_library_map(path, &titles, &covers); + } + char games_config[MAX_PATH]; + snprintf(games_config, sizeof(games_config), "%s/heroic/GamesConfig", config_home); + DIR *dir = opendir(games_config); + if (!dir) { kv_free(&titles); kv_free(&covers); return; } + struct dirent *entry; + while ((entry = readdir(dir))) { + char *dot = strrchr(entry->d_name, '.'); + if (!dot || strcmp(dot, ".json") != 0) continue; + char app_id[64]; + size_t nlen = dot - entry->d_name; + if (nlen >= sizeof(app_id)) nlen = sizeof(app_id) - 1; + strncpy(app_id, entry->d_name, nlen); + app_id[nlen] = '\0'; + int found_installed = 0; + for (int i = 0; i < num_installed; i++) { + if (strcmp(installed_ids[i], app_id) == 0) { found_installed = 1; break; } + } + if (!found_installed) continue; + char *title = kv_get(&titles, app_id); + if (!title) { + char cfg_path[MAX_PATH]; + snprintf(cfg_path, sizeof(cfg_path), "%s/%s", games_config, entry->d_name); + FILE *cf = fopen(cfg_path, "r"); + if (cf) { + fseek(cf, 0, SEEK_END); + long clen = ftell(cf); + rewind(cf); + char *cc = malloc(clen + 1); + if (cc) { + fread(cc, 1, clen, cf); + cc[clen] = '\0'; + char *t = json_extract_string(cc, "name"); + if (!t) t = json_extract_string(cc, "title"); + if (t) title = t; + free(cc); + } + fclose(cf); + } + } + if (!title) continue; + if (game_exists(title)) continue; + char cover[MAX_PATH] = ""; + char cover_url[MAX_PATH] = ""; + char icon_path[MAX_PATH]; + snprintf(icon_path, sizeof(icon_path), "%s/heroic/icons/%s.jpg", config_home, app_id); + if (file_exists(icon_path)) strncpy(cover, icon_path, MAX_PATH - 1); + else { + char cache_path[MAX_PATH]; + snprintf(cache_path, sizeof(cache_path), "%s/gamelauncher/heroic", get_xdg_cache_home()); + DIR *cache_dir = opendir(cache_path); + if (cache_dir) { + struct dirent *ce; + while ((ce = readdir(cache_dir))) { + if (strstr(ce->d_name, app_id)) { + snprintf(cover, sizeof(cover), "%s/%s", cache_path, ce->d_name); + break; + } + } + closedir(cache_dir); + } + } + if (!cover[0]) { + char *art = kv_get(&covers, app_id); + if (art) { + if (strstr(art, "http")) { + strncpy(cover_url, art, MAX_PATH - 1); + } else { + strncpy(cover, art, MAX_PATH - 1); + } + } + } + char run_command[MAX_RUN_CMD]; + snprintf(run_command, sizeof(run_command), "heroic://launch/%s", app_id); + add_game(app_id, title, "heroic", cover, cover_url, run_command, ""); + } + closedir(dir); + kv_free(&titles); kv_free(&covers); +} + +int cache_valid() { + char ts_path[MAX_PATH]; + snprintf(ts_path, sizeof(ts_path), "%s/gamelauncher/cache_ts", get_xdg_cache_home()); + FILE *f = fopen(ts_path, "r"); + if (!f) return 0; + long cached_ts; + if (fscanf(f, "%ld", &cached_ts) != 1) { fclose(f); return 0; } + fclose(f); + + char games_path[MAX_PATH]; + snprintf(games_path, sizeof(games_path), "%s/gamelauncher/games.json", get_xdg_cache_home()); + struct stat gs; + if (stat(games_path, &gs) != 0) return 0; + + struct stat st; + const char *data_home = get_xdg_data_home(); + const char *config_home = get_xdg_config_home(); + + const char *steam_roots[] = { + "/Steam", "/.steam/steam", + "/.var/app/com.valvesoftware.Steam/.local/share/Steam", NULL + }; + for (int r = 0; steam_roots[r]; r++) { + char sp[MAX_PATH]; + snprintf(sp, sizeof(sp), "%s%s/steamapps", data_home, steam_roots[r]); + if (stat(sp, &st) == 0 && st.st_mtime > cached_ts) return 0; + char vdf_path[MAX_PATH]; + snprintf(vdf_path, sizeof(vdf_path), "%s%s/steamapps/libraryfolders.vdf", data_home, steam_roots[r]); + if (stat(vdf_path, &st) == 0 && st.st_mtime > cached_ts) return 0; + } + + const char *lutris_dbs[] = { + "/lutris/pga.db", "/lutris/lutris.db", "/lutris/db.sqlite", NULL + }; + for (int i = 0; lutris_dbs[i]; i++) { + char lp[MAX_PATH]; + snprintf(lp, sizeof(lp), "%s%s", data_home, lutris_dbs[i]); + if (stat(lp, &st) == 0 && st.st_mtime > cached_ts) return 0; + } + + char hp[MAX_PATH]; + snprintf(hp, sizeof(hp), "%s/heroic/GamesConfig", config_home); + if (stat(hp, &st) == 0 && st.st_mtime > cached_ts) return 0; + snprintf(hp, sizeof(hp), "%s/heroic/sideload_apps/library.json", config_home); + if (stat(hp, &st) == 0 && st.st_mtime > cached_ts) return 0; + + for (int r = 0; steam_roots[r]; r++) { + char vdf_path[MAX_PATH]; + snprintf(vdf_path, sizeof(vdf_path), "%s%s/steamapps/libraryfolders.vdf", data_home, steam_roots[r]); + FILE *f = fopen(vdf_path, "r"); + if (!f) continue; + fseek(f, 0, SEEK_END); + long len = ftell(f); + rewind(f); + char *content = malloc(len + 1); + if (!content) { fclose(f); continue; } + fread(content, 1, len, f); + content[len] = '\0'; + fclose(f); + char *p = content; + while ((p = strstr(p, "\"path\""))) { + p += 6; + while (*p && *p != '"') p++; + if (*p++ != '"') continue; + char *end = strchr(p, '"'); + if (!end) continue; + char path_buf[MAX_PATH]; + size_t plen = end - p; + if (plen >= sizeof(path_buf)) plen = sizeof(path_buf) - 1; + strncpy(path_buf, p, plen); + path_buf[plen] = '\0'; + char sa[MAX_PATH]; + snprintf(sa, sizeof(sa), "%s/steamapps", path_buf); + if (stat(sa, &st) == 0 && st.st_mtime > cached_ts) { free(content); return 0; } + p = end; + } + free(content); + } + + return 1; +} + +int load_cached_games() { + char path[MAX_PATH]; + snprintf(path, sizeof(path), "%s/gamelauncher/games.json", get_xdg_cache_home()); + FILE *f = fopen(path, "r"); + if (!f) return 0; + char buf[65536]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + if (n <= 0) return 0; + buf[n] = '\0'; + fwrite(buf, 1, n, stdout); + return 1; +} + +void write_cache() { + char dir[MAX_PATH]; + snprintf(dir, sizeof(dir), "%s/gamelauncher", get_xdg_cache_home()); + mkdir(dir, 0755); + char steam_dir[MAX_PATH]; + snprintf(steam_dir, sizeof(steam_dir), "%s/steam", dir); + mkdir(steam_dir, 0755); + char heroic_dir[MAX_PATH]; + snprintf(heroic_dir, sizeof(heroic_dir), "%s/heroic", dir); + mkdir(heroic_dir, 0755); + + char ts_path[MAX_PATH]; + snprintf(ts_path, sizeof(ts_path), "%s/cache_ts", dir); + FILE *f = fopen(ts_path, "w"); + if (f) { fprintf(f, "%ld\n", (long)time(NULL)); fclose(f); } + + char games_path[MAX_PATH]; + snprintf(games_path, sizeof(games_path), "%s/games.json", dir); + f = fopen(games_path, "w"); + if (!f) return; + fprintf(f, "[\n"); + for (int i = 0; i < ga.count; i++) { + Game *g = &ga.games[i]; + fprintf(f, " {\n"); + fprintf(f, " \"id\": \""); + for (const char *s = g->id; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"name\": \""); + for (const char *s = g->name; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"runner\": \""); + for (const char *s = g->runner; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"cover\": \""); + for (const char *s = g->cover; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"cover_url\": \""); + for (const char *s = g->cover_url; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"run_command\": \""); + for (const char *s = g->run_command; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\",\n"); + fprintf(f, " \"slug\": \""); + for (const char *s = g->slug; *s; s++) { + if (*s == '"' || *s == '\\') putc('\\', f); + putc(*s, f); + } + fprintf(f, "\"\n"); + fprintf(f, " }%s\n", i < ga.count - 1 ? "," : ""); + } + fprintf(f, "]\n"); + fclose(f); +} + +int compare_names(const void *a, const void *b) { + return strcasecmp(((const Game *)a)->name, ((const Game *)b)->name); +} + +int main(int argc, char *argv[]) { + int use_steam = 0, use_heroic = 0, use_lutris = 0, force = 0; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--steam") == 0) use_steam = 1; + if (strcmp(argv[i], "--heroic") == 0) use_heroic = 1; + if (strcmp(argv[i], "--lutris") == 0) use_lutris = 1; + if (strcmp(argv[i], "--no-lutris") == 0) use_lutris = 0; + if (strcmp(argv[i], "--force") == 0) force = 1; + } + + if (!force && cache_valid()) { + if (load_cached_games()) return 0; + } + + init_games(); + if (use_lutris) { scan_lutris(); scan_lutris_manual_files(); } + if (use_steam) { scan_steam(); scan_steam_shortcuts(); } + if (use_heroic) { scan_heroic(); } + qsort(ga.games, ga.count, sizeof(Game), compare_names); + print_json(); + write_cache(); + free_games(); + return 0; +} diff --git a/game-launcher/panel.luau b/game-launcher/panel.luau new file mode 100644 index 0000000..c548f52 --- /dev/null +++ b/game-launcher/panel.luau @@ -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 diff --git a/game-launcher/plugin.toml b/game-launcher/plugin.toml new file mode 100644 index 0000000..2763860 --- /dev/null +++ b/game-launcher/plugin.toml @@ -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 diff --git a/game-launcher/search.luau b/game-launcher/search.luau new file mode 100644 index 0000000..5c6c074 --- /dev/null +++ b/game-launcher/search.luau @@ -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 diff --git a/game-launcher/thumbnail.webp b/game-launcher/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..0086bec0c6a4d435bcc4a75a5158d46fd66f6726 GIT binary patch literal 53700 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�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 Nxc$zqjl~QM3;>KS39