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 0000000..0086bec Binary files /dev/null and b/game-launcher/thumbnail.webp differ diff --git a/game-launcher/translations/en.json b/game-launcher/translations/en.json new file mode 100644 index 0000000..0ef458e --- /dev/null +++ b/game-launcher/translations/en.json @@ -0,0 +1,10 @@ +{ + "settings": { + "glyph": { + "label": "Icon" + }, + "steampoacher_enabled": { + "label": "Steampoacher proxy for covers" + } + } +} diff --git a/game-launcher/widget.luau b/game-launcher/widget.luau new file mode 100644 index 0000000..3c19ae3 --- /dev/null +++ b/game-launcher/widget.luau @@ -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