diff --git a/game-launcher/README.md b/game-launcher/README.md index ebae4cb..5351126 100644 --- a/game-launcher/README.md +++ b/game-launcher/README.md @@ -12,17 +12,17 @@ Browse and launch games from Steam, Lutris, and Heroic Games Launcher directly f ## Requirements -Requires `libsqlite3-dev`, `xdg-utils` (provides `xdg-open`), and `gcc` on PATH. +Requires `xdg-utils` (provides `xdg-open`) and `gcc` on PATH. ```sh # Debian/Ubuntu -sudo apt install libsqlite3-dev xdg-utils gcc +sudo apt install xdg-utils gcc # Fedora -sudo dnf install sqlite-devel xdg-utils gcc +sudo dnf install xdg-utils gcc # Arch -sudo pacman -S sqlite xdg-utils gcc +sudo pacman -S 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. @@ -55,7 +55,7 @@ From the launcher, type `/g` followed by a game name to search. Activate a resul 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. +**1. No shell commands in C scanner** — The scanner (`gamelauncher.c`) uses only local filesystem reads and a bundled SQLite database reader. 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`. diff --git a/game-launcher/gamelauncher.c b/game-launcher/gamelauncher.c index 3c0662e..0ea99a4 100755 --- a/game-launcher/gamelauncher.c +++ b/game-launcher/gamelauncher.c @@ -5,7 +5,7 @@ #include #include #include -#include +#include "sqlite_reader.h" #include #include #include @@ -393,6 +393,70 @@ void scan_steam() { free(steamapps_paths); } +typedef struct { + SqliteTable *t; + int col_id; + int col_name; + int col_slug; + int col_runner; + int col_installed; +} LutrisCtx; + +static int lutris_row_cb(void *ctxp, long long rowid, const unsigned char *rec, int reclen) { + LutrisCtx *c = ctxp; + SqliteRecHeader h; + if (!sq_parse_header(rec, reclen, &h)) return 0; + + char id[MAX_STR] = "0"; + char name[MAX_STR] = ""; + char slug[MAX_STR] = ""; + char runner[MAX_STR] = "linux"; + + if (c->t->id_is_rowid) + snprintf(id, sizeof(id), "%lld", rowid); + else if (c->col_id >= 0) + sq_col_text(&h, rec, reclen, c->col_id, id, sizeof(id)); + + int has_name = sq_col_text(&h, rec, reclen, c->col_name, name, sizeof(name)); + int has_slug = sq_col_text(&h, rec, reclen, c->col_slug, slug, sizeof(slug)); + + if (!has_name || !has_slug) return 0; + if (game_exists(name)) return 0; + + int has_runner = sq_col_text(&h, rec, reclen, c->col_runner, runner, sizeof(runner)); + if (!has_runner) strncpy(runner, "linux", sizeof(runner) - 1); + + 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 cd = 0; cover_dirs[cd]; cd++) { + char cover_dir[MAX_PATH]; + snprintf(cover_dir, sizeof(cover_dir), "%s%s", get_home(), cover_dirs[cd]); + + 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", cover_dir, 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); + + add_game(id, name, "lutris", cover, "", run_command, slug); + return 0; +} + void scan_lutris() { char db_paths[8][MAX_PATH]; int num_dbs = 0; @@ -474,80 +538,48 @@ void scan_lutris() { if (!chosen_db) return; - sqlite3 *db; - int rc = sqlite3_open_v2(chosen_db, &db, SQLITE_OPEN_READONLY, NULL); - if (rc != SQLITE_OK) return; + SqliteDb db; + if (!sq_open(&db, chosen_db)) return; - sqlite3_stmt *stmt; + struct { + const char *name; + int require_installed; + int filter_installed; + } cands[] = { + {"games", 1, 1}, + {"installed_game", 0, 0}, + {"game", 1, 1}, + {NULL, 0, 0} + }; - const char *query = - "SELECT id, name, slug, runner " - "FROM games WHERE installed = 1"; + SqliteTable t; - rc = sqlite3_prepare_v2(db, query, -1, &stmt, NULL); + for (int i = 0; cands[i].name; i++) { + memset(&t, 0, sizeof(t)); + if (!sq_find_table(&db, cands[i].name, &t)) continue; - 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 - }; + int col_id = sq_column_index(&t, "id"); + int col_name = sq_column_index(&t, "name"); + int col_slug = sq_column_index(&t, "slug"); + int col_runner = sq_column_index(&t, "runner"); + if (col_id < 0 || col_name < 0 || col_slug < 0 || col_runner < 0) continue; - for (int q = 0; alt_queries[q]; q++) { - rc = sqlite3_prepare_v2(db, alt_queries[q], -1, &stmt, NULL); - if (rc == SQLITE_OK) break; - } + int col_installed = sq_column_index(&t, "installed"); + if (cands[i].require_installed && col_installed < 0) continue; - if (rc != SQLITE_OK) { - sqlite3_close(db); - return; - } + LutrisCtx ctx; + ctx.t = &t; + ctx.col_id = col_id; + ctx.col_name = col_name; + ctx.col_slug = col_slug; + ctx.col_runner = col_runner; + ctx.col_installed = cands[i].filter_installed ? col_installed : -1; + + sq_walk_table(&db, t.rootpage, lutris_row_cb, &ctx); + break; } - 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); + sq_close(&db); } void scan_lutris_manual_files() { diff --git a/game-launcher/panel.luau b/game-launcher/panel.luau index f40488b..010a689 100644 --- a/game-launcher/panel.luau +++ b/game-launcher/panel.luau @@ -36,6 +36,31 @@ end local binary = noctalia.pluginDir() .. "/gamelauncher" local cSource = noctalia.pluginDir() .. "/gamelauncher.c" +local cReaderSource = noctalia.pluginDir() .. "/sqlite_reader.c" + +local function buildCommand() + return "cc -o " .. binary .. " " .. cSource .. " " .. cReaderSource +end + +local buildTagFile +do + local ok, dir = pcall(noctalia.pluginDataDir) + if ok and dir then buildTagFile = dir .. "/build_command" end +end + +local function binaryReady() + if not noctalia.fileExists(binary) then return false end + if not buildTagFile then return true end + if noctalia.readFile(buildTagFile) ~= buildCommand() then + noctalia.removeFile(binary) + return false + end + return true +end + +local function markBuilt() + if buildTagFile then noctalia.writeFile(buildTagFile, buildCommand()) end +end local function isValidProtocol(cmd) local protocols = { "steam://", "lutris:", "heroic://" } @@ -362,15 +387,17 @@ local function buildBinary() loading = true errorMsg = "" render() - local ok = noctalia.runAsync("cc -o " .. binary .. " " .. cSource .. " -lsqlite3", function(res) + local cmd = buildCommand() + local ok = noctalia.runAsync(cmd, function(res) building = false if res.exitCode == 0 then loading = false + markBuilt() scanGames() else loading = false - errorMsg = "Failed to build gamelauncher. Install gcc and libsqlite3-dev. (exit " .. (res.exitCode or "?") .. ")" - noctalia.log("build failed: " .. (res.stderr or "no stderr")) + errorMsg = "Failed to build gamelauncher. Install gcc. (exit " .. (res.exitCode or "?") .. ")" + noctalia.log("build failed (cmd: " .. cmd .. "): " .. (res.stderr or "no stderr")) render() end end) @@ -384,7 +411,7 @@ end local function scanGames() if loading or building then return end - if not noctalia.fileExists(binary) then + if not binaryReady() then buildBinary() return end @@ -404,6 +431,7 @@ local function scanGames() local parsed, err2 = noctalia.json.decode(res.stdout) if parsed then games = parsed + pcall(noctalia.state.set, "games", parsed) fetchMissingCovers() else errorMsg = "Failed to parse game list: " .. (err2 or "unknown error") diff --git a/game-launcher/plugin.toml b/game-launcher/plugin.toml index 586cd5b..1aa6f61 100644 --- a/game-launcher/plugin.toml +++ b/game-launcher/plugin.toml @@ -1,12 +1,12 @@ id = "alexander/game-launcher" name = "Game Launcher" -version = "1.1.2" +version = "1.3.0" plugin_api = 13 author = "Alexander" license = "MIT" icon = "device-gamepad-2" description = "Browse and launch games from Steam, Lutris, and Heroic." -dependencies = ["cc", "libsqlite3-dev", "xdg-utils"] +dependencies = ["cc", "xdg-utils"] tags = ["gaming", "launcher", "utility"] [[widget]] diff --git a/game-launcher/search.luau b/game-launcher/search.luau index b7893e7..9581855 100644 --- a/game-launcher/search.luau +++ b/game-launcher/search.luau @@ -1,78 +1,92 @@ local games = {} -local ready = false -local binary = noctalia.pluginDir() .. "/gamelauncher" -local cSource = noctalia.pluginDir() .. "/gamelauncher.c" +local lastQuery = "" + +local function statusRow(title, subtitle, glyph) + return { id = "", title = title, subtitle = subtitle, glyph = glyph } +end local function isRunnerEnabled(runner) local ok, val = pcall(noctalia.getConfig, runner .. "_enabled") - if ok then return val == true or val == "true" end + if ok and val ~= nil then return val == true or val == "true" end return true end -local function getRunnerFlags() - local flags = "" - if isRunnerEnabled("steam") then flags = flags .. " --steam" end - if isRunnerEnabled("lutris") then flags = flags .. " --lutris" end - if isRunnerEnabled("heroic") then flags = flags .. " --heroic" end - return flags +local function loadGames() + local ok, v = pcall(noctalia.state.get, "games") + if ok and type(v) == "table" and #v > 0 then + games = v + return true + end + local base = noctalia.getenv("XDG_CACHE_HOME") + if not base or #base == 0 then + local home = noctalia.getenv("HOME") or "" + base = home .. "/.cache" + end + local raw = noctalia.readFile(base .. "/gamelauncher/games.json") + if raw then + local parsed = noctalia.json.decode(raw) + if type(parsed) == "table" and #parsed > 0 then + games = parsed + return true + end + end + return false end -local function ensureGames(cb) - local function runScan() - local ok = noctalia.runAsync(binary .. getRunnerFlags() .. " --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 +local function filterRows(text) + local lower = noctalia.string.trim(text):lower() + local results = {} + for _, g in ipairs(games) do + if g.name and g.runner and g.name:lower():find(lower, 1, true) and isRunnerEnabled(g.runner) 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 - if ready then - cb() - return + if #results == 0 then + if #games == 0 then + return { + statusRow("No games found", "Open the Game Launcher panel to scan your games first.", "app-window"), + } + end + return { + statusRow("No games found", "Nothing matches \"" .. text .. "\". Try a different name.", "app-window"), + } end - if not noctalia.fileExists(binary) then - local ok = noctalia.runAsync("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() + return results end +pcall(function() + noctalia.state.watch("games", function(v) + if type(v) == "table" then + games = v + if lastQuery ~= "" then + launcher.setResults(lastQuery, filterRows(lastQuery)) + end + end + end) +end) + function onQuery(text) + lastQuery = text if text == "" then launcher.setResults(text, { - { id = "hint", title = "Type a game name to search", glyph = "device-gamepad-2" }, + statusRow("Type a game name to search", "Searches Steam, Lutris, and Heroic.", "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) and isRunnerEnabled(g.runner) 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) + if not loadGames() then + launcher.setResults(text, { + statusRow("No games loaded", "Open the Game Launcher panel first so it can scan your games.", "device-gamepad-2"), + }) + return + end + launcher.setResults(text, filterRows(text)) end local function isValidProtocol(cmd) @@ -87,6 +101,7 @@ local function isValidProtocol(cmd) end function onActivate(id) + if id == "" then return end 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 .. '"') diff --git a/game-launcher/sqlite_reader.c b/game-launcher/sqlite_reader.c new file mode 100644 index 0000000..ad524f4 --- /dev/null +++ b/game-launcher/sqlite_reader.c @@ -0,0 +1,308 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include "sqlite_reader.h" + +static int sq_get16(const unsigned char *p) { return (p[0] << 8) | p[1]; } +static int sq_get32(const unsigned char *p) { return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]); } + +static long long sq_varint(const unsigned char *p, int *len) { + long long v = 0; + int i; + for (i = 0; i < 8; i++) { + v = (v << 7) | (p[i] & 0x7f); + if (!(p[i] & 0x80)) { i++; break; } + } + if (i == 8) { v = (v << 8) | p[8]; i = 9; } + *len = i; + return v; +} + +int sq_open(SqliteDb *db, const char *path) { + memset(db, 0, sizeof(*db)); + FILE *f = fopen(path, "rb"); + if (!f) return 0; + if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; } + long sz = ftell(f); + rewind(f); + if (sz < 100) { fclose(f); return 0; } + db->data = malloc((size_t)sz); + if (!db->data) { fclose(f); return 0; } + if (fread(db->data, 1, (size_t)sz, f) != (size_t)sz) { free(db->data); db->data = NULL; fclose(f); return 0; } + fclose(f); + db->size = (size_t)sz; + if (memcmp(db->data, "SQLite format 3", 16) != 0) { free(db->data); db->data = NULL; return 0; } + int ps = sq_get16(db->data + 16); + db->page_size = (ps == 1) ? 65536 : ps; + db->reserved = db->data[20]; + return 1; +} + +void sq_close(SqliteDb *db) { + if (db->data) { free(db->data); db->data = NULL; } +} + +static size_t sq_page_base(SqliteDb *db, int page) { + return (size_t)(page - 1) * (size_t)db->page_size; +} + +static size_t sq_btree_off(SqliteDb *db, int page) { + return sq_page_base(db, page) + (page == 1 ? 100 : 0); +} + +static int sq_serial_size(long long st) { + switch (st) { + case 0: return 0; + case 1: return 1; + case 2: return 2; + case 3: return 3; + case 4: return 4; + case 5: return 6; + case 6: return 8; + case 7: return 8; + case 8: return 0; + case 9: return 0; + default: return (int)((st - 12) / 2); + } +} + +int sq_parse_header(const unsigned char *rec, int reclen, SqliteRecHeader *h) { + if (reclen < 1) return 0; + int vl; + long long hlen = sq_varint(rec, &vl); + if (hlen < 1 || hlen > reclen) return 0; + int pos = vl, i = 0; + while (pos < hlen && i < SQ_MAX_COLS) { + h->serial[i++] = sq_varint(rec + pos, &vl); + pos += vl; + } + if (pos != hlen) return 0; + h->n = i; + return 1; +} + +int sq_col_text(const SqliteRecHeader *h, const unsigned char *rec, int reclen, int col, char *out, int outsz) { + out[0] = 0; + if (col < 0 || col >= h->n) return 0; + long long st = h->serial[col]; + if (st == 0) return 0; + int vl; + long long hlen = sq_varint(rec, &vl); + int off = (int)hlen; + for (int j = 0; j < col; j++) off += sq_serial_size(h->serial[j]); + int sz = sq_serial_size(st); + if (off + sz > reclen) return 0; + const unsigned char *p = rec + off; + if (st >= 12) { + int n = sz; + if (n > outsz - 1) n = outsz - 1; + memcpy(out, p, (size_t)n); + out[n] = 0; + return 1; + } + if (st >= 1 && st <= 6) { + long long v = 0; + for (int i = 0; i < sz; i++) v = (v << 8) | p[i]; + int bits = sz * 8; + if (bits < 64) { long long sign = 1LL << (bits - 1); if (v & sign) v -= (1LL << bits); } + snprintf(out, (size_t)outsz, "%lld", v); + return 1; + } + if (st == 7) { snprintf(out, (size_t)outsz, "%g", (double)*((double*)p)); return 1; } + if (st == 8) { snprintf(out, (size_t)outsz, "0"); return 1; } + if (st == 9) { snprintf(out, (size_t)outsz, "1"); return 1; } + return 0; +} + +long long sq_col_int(const SqliteRecHeader *h, const unsigned char *rec, int reclen, int col) { + char buf[128]; + if (!sq_col_text(h, rec, reclen, col, buf, sizeof(buf))) return 0; + return atoll(buf); +} + +static int sq_get_payload(SqliteDb *db, const unsigned char *ppos, long long plen, unsigned char *out) { + int usable = db->page_size - db->reserved; + int X = usable - 35; + int M = ((usable - 12) * 32 / 255) - 23; + int local; + if (plen <= X) local = (int)plen; + else { + local = M + (int)((plen - M) % (usable - 4)); + if (local > X) local = X; + } + long long got = 0; + if (local > 0) { memcpy(out, ppos, (size_t)local); got = local; } + int next = 0; + if (got < plen) next = sq_get32(ppos + local); + int guard = 0; + while (got < plen && next && guard++ < 100000) { + size_t off = (size_t)(next - 1) * (size_t)db->page_size; + if (off + 4 > db->size) return 0; + const unsigned char *p = db->data + off; + int chunk = usable - 4; + long long rem = plen - got; + int n = (rem < chunk) ? (int)rem : chunk; + memcpy(out + got, p + 4, (size_t)n); + got += n; + next = sq_get32(p); + } + return got == plen; +} + +static int sq_walk_page(SqliteDb *db, int page, sq_row_cb cb, void *ctx, int depth) { + if (depth > 64) return 0; + size_t boff = sq_btree_off(db, page); + if (boff + 8 > db->size) return 0; + const unsigned char *bh = db->data + boff; + int type = bh[0]; + int ncell = sq_get16(bh + 3); + size_t base = sq_page_base(db, page); + if (type == 5) { + int right = sq_get32(bh + 8); + for (int i = 0; i < ncell; i++) { + if (boff + 12 + i * 2 + 2 > db->size) return 0; + int coff = sq_get16(db->data + boff + 12 + i * 2); + const unsigned char *cell = db->data + base + coff; + int child = sq_get32(cell); + if (!sq_walk_page(db, child, cb, ctx, depth + 1)) return 0; + } + if (right && !sq_walk_page(db, right, cb, ctx, depth + 1)) return 0; + return 1; + } + if (type == 13) { + for (int i = 0; i < ncell; i++) { + if (boff + 8 + i * 2 + 2 > db->size) return 0; + int coff = sq_get16(db->data + boff + 8 + i * 2); + const unsigned char *cell = db->data + base + coff; + int vl = 0, vl2 = 0; + long long plen = sq_varint(cell, &vl); + long long rowid = sq_varint(cell + vl, &vl2); + int hdr = vl + vl2; + if (plen < 0 || plen > (long long)db->size) return 0; + unsigned char *rec = malloc((size_t)plen + 1); + if (!rec) return 0; + int ok = sq_get_payload(db, cell + hdr, plen, rec); + rec[plen] = 0; + if (!ok) { free(rec); return 0; } + int r = cb(ctx, rowid, rec, (int)plen); + free(rec); + if (r) return 1; + } + return 1; + } + return 0; +} + +int sq_walk_table(SqliteDb *db, int rootpage, sq_row_cb cb, void *ctx) { + return sq_walk_page(db, rootpage, cb, ctx, 0); +} + +static int sq_is_kw(const char *w) { + static const char *kws[] = {"PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT", "CONSTRAINTS", NULL}; + for (int i = 0; kws[i]; i++) if (strcasecmp(w, kws[i]) == 0) return 1; + return 0; +} + +static void sq_add_col(SqliteTable *t, const char *start, const char *end) { + while (start < end && isspace((unsigned char)*start)) start++; + while (end > start && isspace((unsigned char)end[-1])) end--; + if (start >= end || t->ncols >= SQ_MAX_COLS) return; + char w[128]; + int n = 0; + const char *p = start; + if (*p == '"' || *p == '`' || *p == '[') { + char q = *p; + char close = (q == '[') ? ']' : q; + p++; + while (p < end && n < 127) { + if (*p == close) break; + w[n++] = *p++; + } + w[n] = 0; + if (n) { memcpy(t->colnames[t->ncols], w, (size_t)n + 1); t->ncols++; } + return; + } + while (p < end && n < 127 && !isspace((unsigned char)*p) && *p != '(' && *p != ',') w[n++] = *p++; + w[n] = 0; + if (n == 0) return; + if (sq_is_kw(w)) return; + memcpy(t->colnames[t->ncols], w, (size_t)n + 1); + t->ncols++; +} + +static int sq_parse_create(SqliteTable *t, const char *sql) { + t->ncols = 0; + const char *p = sql; + char q = 0; + int depth = 0; + const char *open = NULL; + for (; *p; p++) { + char c = *p; + if (q) { if (c == q) q = 0; continue; } + if (c == '\'' || c == '"' || c == '`') { q = c; continue; } + if (c == '(') { if (depth == 0 && !open) open = p; depth++; continue; } + if (c == ')') { if (depth > 0) depth--; if (depth == 0 && open) break; } + } + if (!open) return 0; + const char *end = p; + int d = 0; + char qq = 0; + const char *seg = open + 1; + for (const char *s = open + 1; s < end; s++) { + char c = *s; + if (qq) { if (c == qq) qq = 0; continue; } + if (c == '\'' || c == '"' || c == '`') { qq = c; continue; } + if (c == '(') { d++; continue; } + if (c == ')') { if (d > 0) d--; continue; } + if (c == ',' && d == 0) { sq_add_col(t, seg, s); seg = s + 1; } + } + sq_add_col(t, seg, end); + for (int i = 0; i < t->ncols; i++) { + if (strcmp(t->colnames[i], "id") == 0 && strcasestr(sql, "INTEGER PRIMARY KEY")) { + t->id_is_rowid = 1; + break; + } + } + return t->ncols > 0; +} + +typedef struct { + SqliteDb *db; + const char *name; + SqliteTable *out; + int found; +} FindTableCtx; + +static int find_table_cb(void *ctxp, long long rowid, const unsigned char *rec, int reclen) { + (void)rowid; + FindTableCtx *c = ctxp; + SqliteRecHeader h; + if (!sq_parse_header(rec, reclen, &h)) return 0; + char type[32], name[128]; + if (!sq_col_text(&h, rec, reclen, 0, type, sizeof(type))) return 0; + if (strcmp(type, "table") != 0) return 0; + if (!sq_col_text(&h, rec, reclen, 1, name, sizeof(name))) return 0; + if (strcmp(name, c->name) != 0) return 0; + long long rp = sq_col_int(&h, rec, reclen, 3); + char sql[8192]; + if (!sq_col_text(&h, rec, reclen, 4, sql, sizeof(sql))) return 0; + c->out->rootpage = (int)rp; + sq_parse_create(c->out, sql); + c->found = 1; + return 1; +} + +int sq_find_table(SqliteDb *db, const char *name, SqliteTable *out) { + FindTableCtx ctx = { db, name, out, 0 }; + sq_walk_page(db, 1, find_table_cb, &ctx, 0); + return ctx.found; +} + +int sq_column_index(const SqliteTable *t, const char *name) { + for (int i = 0; i < t->ncols; i++) { + if (strcmp(t->colnames[i], name) == 0) return i; + } + return -1; +} diff --git a/game-launcher/sqlite_reader.h b/game-launcher/sqlite_reader.h new file mode 100644 index 0000000..2a63730 --- /dev/null +++ b/game-launcher/sqlite_reader.h @@ -0,0 +1,40 @@ +#ifndef GAME_LAUNCHER_SQLITE_READER_H +#define GAME_LAUNCHER_SQLITE_READER_H + +#include + +#define SQ_MAX_COLS 64 + +typedef struct { + unsigned char *data; + size_t size; + int page_size; + int reserved; +} SqliteDb; + +typedef struct { + int rootpage; + int ncols; + char colnames[SQ_MAX_COLS][64]; + int id_is_rowid; +} SqliteTable; + +typedef struct { + long long serial[SQ_MAX_COLS]; + int n; +} SqliteRecHeader; + +int sq_open(SqliteDb *db, const char *path); +void sq_close(SqliteDb *db); + +int sq_find_table(SqliteDb *db, const char *name, SqliteTable *out); +int sq_column_index(const SqliteTable *t, const char *name); + +typedef int (*sq_row_cb)(void *ctx, long long rowid, const unsigned char *rec, int reclen); +int sq_walk_table(SqliteDb *db, int rootpage, sq_row_cb cb, void *ctx); + +int sq_parse_header(const unsigned char *rec, int reclen, SqliteRecHeader *h); +int sq_col_text(const SqliteRecHeader *h, const unsigned char *rec, int reclen, int col, char *out, int outsz); +long long sq_col_int(const SqliteRecHeader *h, const unsigned char *rec, int reclen, int col); + +#endif