/** @file src/steam_integration.cpp */ #include "steam_integration.h" #if defined(__linux__) #include "provider_scan_protocol.h" #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 // clang-format off // windows.h must precede shellapi.h: the latter relies on its type macros. #include #include // clang-format on #elif defined(__APPLE__) #include #include #include #include #else #include #include #include #include #endif namespace fs = std::filesystem; namespace { using platf::steam::vdf_node; struct lexer { const std::string &s; size_t pos = 0; void whitespace() { while (pos < s.size()) { if (std::isspace(static_cast(s[pos]))) { ++pos; } else if (s[pos] == '/' && pos + 1 < s.size() && s[pos + 1] == '/') { pos += 2; while (pos < s.size() && s[pos] != '\n') { ++pos; } } else { break; } } } std::optional token() { whitespace(); if (pos >= s.size() || s[pos] == '{' || s[pos] == '}') { return std::nullopt; } if (s[pos] == '"') { ++pos; std::string out; while (pos < s.size()) { const char c = s[pos++]; if (c == '"') { break; } if (c == '\\' && pos < s.size()) { const char escaped = s[pos++]; switch (escaped) { case 'n': out += '\n'; break; case 't': out += '\t'; break; case 'r': out += '\r'; break; default: out += escaped; break; } } else { out += c; } } return out; } const auto begin = pos; while (pos < s.size() && !std::isspace(static_cast(s[pos])) && s[pos] != '{' && s[pos] != '}') { ++pos; } return s.substr(begin, pos - begin); } }; void parse_body(lexer &lex, vdf_node &out, bool stop_at_brace) { while (true) { lex.whitespace(); if (lex.pos >= lex.s.size()) { return; } if (stop_at_brace && lex.s[lex.pos] == '}') { ++lex.pos; return; } auto key = lex.token(); if (!key) { // A stray brace cannot form a valid pair. Always consume it so a // malformed localconfig cannot stall Steam discovery indefinitely. if (lex.pos < lex.s.size() && (lex.s[lex.pos] == '{' || lex.s[lex.pos] == '}')) { ++lex.pos; } continue; } lex.whitespace(); if (lex.pos < lex.s.size() && lex.s[lex.pos] == '{') { ++lex.pos; vdf_node child; parse_body(lex, child, true); out.children.emplace_back(*key, std::move(child)); } else if (auto value = lex.token()) { out.children.emplace_back(*key, vdf_node {*value, {}}); } else { out.children.emplace_back(*key, vdf_node {}); } } } std::optional number(const vdf_node *node) { if (!node) { return std::nullopt; } try { size_t used = 0; const auto n = std::stoull(node->value, &used, 10); return used == node->value.size() ? std::optional(n) : std::nullopt; } catch (...) { return std::nullopt; } } std::string env(const char *name) { const char *value = std::getenv(name); return value ? value : ""; } #if defined(__linux__) bool machine_host_mode() { const auto *value = std::getenv("ARTLIGHT_MACHINE_HOST"); return value && *value; } std::shared_ptr machine_steam_catalog() { static std::mutex mutex; static std::shared_ptr cached; static std::chrono::steady_clock::time_point refreshed_at {}; static bool initialized = false; constexpr auto cache_lifetime = std::chrono::seconds {2}; const auto now = std::chrono::steady_clock::now(); std::lock_guard lock {mutex}; if (!initialized || now - refreshed_at >= cache_lifetime) { auto scanned = platf::provider_scan::scan_steam_session(); cached = scanned ? std::make_shared(std::move(*scanned)) : nullptr; refreshed_at = now; initialized = true; } return cached; } #endif fs::path steamapps_for(fs::path root) { std::error_code ec; if (root.filename() == "steamapps" && fs::is_directory(root, ec)) { return root; } if (fs::is_regular_file(root / "libraryfolders.vdf", ec)) { return root; } if (fs::is_directory(root / "steamapps", ec)) { return root / "steamapps"; } return {}; } const vdf_node *library_path_node(const vdf_node &node) { if (const auto *path = node.find("path")) { return path; } // Legacy libraryfolders.vdf may use a direct path value. return node.value.empty() ? nullptr : &node; } void add_root(std::vector &roots, fs::path root) { if (root.empty()) { return; } std::error_code ec; root = fs::weakly_canonical(root, ec); if (ec || !fs::exists(root, ec)) { return; } if (std::find(roots.begin(), roots.end(), root) == roots.end()) { roots.push_back(std::move(root)); } } bool regular(const fs::path &path) { std::error_code ec; return fs::is_regular_file(path, ec); } fs::path first_existing(const std::vector &dirs, std::uint32_t app_id, std::initializer_list suffixes) { const auto id = std::to_string(app_id); for (const auto &dir : dirs) { for (const auto *suffix : suffixes) { const auto path = dir / (id + suffix); if (regular(path)) { return path; } } } return {}; } fs::path first_nested_existing(const std::vector &dirs, std::uint32_t app_id, std::initializer_list names) { const auto id = std::to_string(app_id); for (const auto &dir : dirs) { for (const auto *name : names) { const auto path = dir / id / name; if (regular(path)) { return path; } } } return {}; } fs::path first_hashed_nested_existing(const std::vector &dirs, std::uint32_t app_id, std::initializer_list names) { const auto id = std::to_string(app_id); for (const auto &dir : dirs) { const auto app_dir = dir / id; std::error_code ec; if (!fs::is_directory(app_dir, ec)) { continue; } std::vector asset_dirs; for (const auto &entry : fs::directory_iterator(app_dir, ec)) { if (ec) { break; } if (entry.is_directory(ec)) { asset_dirs.push_back(entry.path()); } } std::sort(asset_dirs.begin(), asset_dirs.end()); for (const auto &asset_dir : asset_dirs) { for (const auto *name : names) { const auto path = asset_dir / name; if (regular(path)) { return path; } } } } return {}; } struct artwork_t { fs::path portrait; fs::path header; fs::path icon; std::string format; }; artwork_t artwork_for(std::uint32_t app_id, const fs::path &library, const std::vector &steam_roots) { std::vector cache_dirs; std::vector grid_dirs; auto add_root_dirs = [&](const fs::path &root) { if (root.empty()) { return; } cache_dirs.push_back(root / "appcache/librarycache"); cache_dirs.push_back(root / "librarycache"); std::error_code ec; const auto userdata = root / "userdata"; if (fs::is_directory(userdata, ec)) { for (const auto &user : fs::directory_iterator(userdata, ec)) { if (!ec && user.is_directory(ec)) { grid_dirs.push_back(user.path() / "config/grid"); } } } }; add_root_dirs(library); for (const auto &root : steam_roots) { add_root_dirs(root); } // New librarycache artwork is preferred because it is Steam's canonical // portrait cover. Steam's _2x asset is the only local variant that is // normally 600x900; check every cache directory for it before considering // the older 300x450 files. User grid art is a useful fallback for custom // artwork. artwork_t out; out.portrait = first_existing(cache_dirs, app_id, {"_library_600x900_2x.jpg", "_library_600x900_2x.png", "_library_600x900_2x.webp"}); if (out.portrait.empty()) { out.portrait = first_nested_existing(cache_dirs, app_id, {"library_600x900_2x.jpg", "library_600x900_2x.png", "library_600x900_2x.webp"}); } if (out.portrait.empty()) { out.portrait = first_existing(cache_dirs, app_id, {"_library_600x900.jpg", "_library_600x900.png", "_library_600x900.webp"}); } if (out.portrait.empty()) { out.portrait = first_nested_existing(cache_dirs, app_id, {"library_600x900.jpg", "library_600x900.png", "library_600x900.webp"}); } if (out.portrait.empty()) { // Current Steam clients key cached store assets by their content hash: // //library_capsule.jpg. The capsule is the same portrait // cover as the legacy library_600x900 asset (normally 300x450 locally). out.portrait = first_hashed_nested_existing(cache_dirs, app_id, {"library_capsule.jpg", "library_capsule.png", "library_capsule.webp"}); } if (out.portrait.empty()) { out.portrait = first_existing(grid_dirs, app_id, {"p.png", "p.jpg", "_p.png", "_p.jpg"}); } out.header = first_existing(cache_dirs, app_id, {"_header.jpg", "_header.png", "_header.webp"}); if (out.header.empty()) { out.header = first_nested_existing(cache_dirs, app_id, {"header.jpg", "header.png", "header.webp"}); } if (out.header.empty()) { out.header = first_hashed_nested_existing(cache_dirs, app_id, {"library_header.jpg", "library_header.png", "library_header.webp"}); } if (out.header.empty()) { out.header = first_existing(grid_dirs, app_id, {"_hero.png", "_hero.jpg"}); } out.icon = first_existing(cache_dirs, app_id, {"_icon.jpg", "_icon.png", "_icon.webp"}); if (out.icon.empty()) { out.icon = first_nested_existing(cache_dirs, app_id, {"icon.jpg", "icon.png", "icon.webp"}); } if (out.icon.empty()) { out.icon = first_existing(grid_dirs, app_id, {"_icon.png", "_icon.jpg", ".png"}); } out.format = !out.portrait.empty() ? out.portrait.extension().string() : (!out.header.empty() ? out.header.extension().string() : (!out.icon.empty() ? out.icon.extension().string() : std::string {})); if (!out.format.empty() && out.format.front() == '.') { out.format.erase(0, 1); } return out; } std::uint32_t read_u32(const std::vector &data, std::size_t offset) { if (offset + 4 > data.size()) { throw std::out_of_range("Steam appinfo u32"); } return static_cast(data[offset]) | (static_cast(data[offset + 1]) << 8) | (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); } std::uint64_t read_u64(const std::vector &data, std::size_t offset) { if (offset + 8 > data.size()) { throw std::out_of_range("Steam appinfo u64"); } std::uint64_t value = 0; for (unsigned shift = 0; shift < 64; shift += 8) { value |= static_cast(data[offset++]) << shift; } return value; } std::string read_cstring(const std::vector &data, std::size_t &offset, std::size_t end) { const auto begin = offset; while (offset < end && data[offset] != 0) { ++offset; } if (offset >= end) { throw std::out_of_range("Steam appinfo string"); } std::string value(reinterpret_cast(data.data() + begin), offset - begin); ++offset; return value; } vdf_node parse_binary_vdf( const std::vector &data, std::size_t &offset, std::size_t end, const std::vector &keys ) { vdf_node result; while (offset < end) { const auto type = data[offset++]; if (type == 0x08) { return result; } const auto key_index = read_u32(data, offset); offset += 4; if (key_index >= keys.size()) { throw std::out_of_range("Steam appinfo key"); } vdf_node value; switch (type) { case 0x00: value = parse_binary_vdf(data, offset, end, keys); break; case 0x01: value.value = read_cstring(data, offset, end); break; case 0x02: case 0x03: case 0x04: case 0x06: value.value = std::to_string(read_u32(data, offset)); offset += 4; break; case 0x05: { const auto begin = offset; while (offset + 1 < end && (data[offset] != 0 || data[offset + 1] != 0)) { offset += 2; } if (offset + 1 >= end) { throw std::out_of_range("Steam appinfo wide string"); } // Launch metadata is UTF-8. Preserve a readable ASCII subset for // the uncommon wide-string values without adding a codec dependency. for (auto cursor = begin; cursor < offset; cursor += 2) { value.value.push_back(data[cursor] < 0x80 && data[cursor + 1] == 0 ? static_cast(data[cursor]) : '?'); } offset += 2; break; } case 0x07: case 0x0a: value.value = std::to_string(read_u64(data, offset)); offset += 8; break; default: throw std::runtime_error("Unsupported Steam appinfo value type"); } result.children.emplace_back(keys[key_index], std::move(value)); } throw std::out_of_range("Unterminated Steam appinfo object"); } std::string normalized_os(const vdf_node &launch) { const auto *config = launch.find("config"); const auto *os = config ? config->find("oslist") : nullptr; auto value = os ? os->value : std::string {}; std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); if (value.find("linux") != std::string::npos) { return "linux"; } if (value.empty() || value.find("windows") != std::string::npos) { return "windows"; } return {}; } void apply_launch_node(platf::steam::game_t &game, const vdf_node &appinfo) { const auto *config = appinfo.find("config"); const auto *launches = config ? config->find("launch") : nullptr; if (!launches) { return; } const vdf_node *selected = nullptr; int selected_score = -1; for (const auto &[index, launch] : launches->children) { const auto *executable = launch.find("executable"); const auto os = normalized_os(launch); if (!executable || executable->value.empty() || os.empty()) { continue; } int score = os == "linux" ? 200 : 100; if (index == "0") { score += 20; } if (const auto *type = launch.find("type"); type && (type->value == "default" || type->value == "none")) { score += 10; } if (score > selected_score) { selected = &launch; selected_score = score; } } if (!selected) { return; } auto relative = selected->find("executable")->value; std::replace(relative.begin(), relative.end(), '\\', '/'); game.launch_executable = game.install_dir / relative; game.launch_os = normalized_os(*selected); if (const auto *arguments = selected->find("arguments")) { game.launch_arguments = arguments->value; } if (const auto *working = selected->find("workingdir"); working && !working->value.empty()) { auto directory = working->value; std::replace(directory.begin(), directory.end(), '\\', '/'); game.launch_working_dir = game.install_dir / directory; } else { game.launch_working_dir = game.launch_executable.parent_path(); } } void apply_common_node(platf::steam::game_t &game, const vdf_node &appinfo) { const auto *common = appinfo.find("common"); if (!common) { return; } if (const auto *name = common->find("name"); name && !name->value.empty()) { game.name = name->value; } if (const auto *type = common->find("type"); type && !type->value.empty()) { game.app_type = type->value; } } void enrich_from_appinfo(const fs::path &path, std::map &games) { std::ifstream input(path, std::ios::binary); if (!input) { return; } std::vector data((std::istreambuf_iterator(input)), {}); if (data.size() < 20 || read_u32(data, 0) != 0x07564429) { return; } const auto string_table_offset = read_u64(data, 8); if (string_table_offset > data.size() - 4) { return; } std::size_t strings_offset = static_cast(string_table_offset); const auto string_count = read_u32(data, strings_offset); strings_offset += 4; if (string_count > data.size() - strings_offset) { return; } std::vector keys; keys.reserve(string_count); for (std::uint32_t i = 0; i < string_count; ++i) { keys.push_back(read_cstring(data, strings_offset, data.size())); } std::size_t offset = 16; while (offset + 4 <= string_table_offset) { const auto app_id = read_u32(data, offset); if (app_id == 0) { break; } if (offset + 68 > string_table_offset) { return; } const auto entry_size = read_u32(data, offset + 4); if (entry_size > string_table_offset - offset - 8) { return; } const auto entry_end = offset + 8 + static_cast(entry_size); const auto value_offset = offset + 68; if (entry_size < 60 || entry_end > string_table_offset || value_offset > entry_end) { return; } if (const auto found = games.find(app_id); found != games.end()) { try { auto cursor = value_offset; const auto root = parse_binary_vdf(data, cursor, entry_end, keys); const auto *appinfo = root.find("appinfo"); const auto &metadata = appinfo ? *appinfo : root; apply_common_node(found->second, metadata); apply_launch_node(found->second, metadata); } catch (...) { // A single malformed/cache-version entry must not suppress the rest // of Steam discovery; unresolved games keep the broker fallback. } } offset = entry_end; } } void apply_user_metadata(const fs::path &steam_root, std::map &games, bool include_history) { std::error_code ec; const auto userdata = steam_root / "userdata"; if (!fs::is_directory(userdata, ec)) { return; } for (const auto &user : fs::directory_iterator(userdata, ec)) { if (ec || !user.is_directory(ec)) { continue; } std::ifstream input(user.path() / "config/localconfig.vdf"); if (!input) { continue; } std::stringstream buffer; buffer << input.rdbuf(); const auto doc = platf::steam::parse_vdf(buffer.str()); const vdf_node *apps = doc.find("UserLocalConfigStore"); apps = apps ? apps->find("Software") : nullptr; apps = apps ? apps->find("Valve") : nullptr; apps = apps ? apps->find("Steam") : nullptr; apps = apps ? apps->find("apps") : nullptr; if (!apps) { continue; } for (const auto &[id_text, app] : apps->children) { std::uint64_t id = 0; try { std::size_t used = 0; id = std::stoull(id_text, &used, 10); if (used != id_text.size() || id == 0 || id > UINT32_MAX) { continue; } } catch (...) { continue; } const auto last_played = number(app.find("LastPlayed")).value_or(0); const auto playtime = number(app.find("Playtime")).value_or(0); auto found = games.find(static_cast(id)); if (found == games.end()) { if (!include_history || (last_played == 0 && playtime == 0)) { continue; } platf::steam::game_t game; game.app_id = static_cast(id); game.stable_id = "steam:" + id_text; found = games.emplace(game.app_id, std::move(game)).first; } auto &game = found->second; game.last_played = std::max(game.last_played, last_played); game.playtime_minutes = std::max(game.playtime_minutes, playtime); if (const auto *options = app.find("LaunchOptions"); options && !options->value.empty()) { game.launch_options = options->value; } } } } [[maybe_unused]] void apply_proton_metadata(platf::steam::game_t &game) { if (game.launch_os != "windows") { return; } game.compatdata_path = game.library_path / "steamapps" / "compatdata" / std::to_string(game.app_id); std::ifstream input(game.compatdata_path / "config_info"); std::string version; std::string files_path; std::string library_path; std::string steam_client_path; if (!std::getline(input, version) || !std::getline(input, files_path)) { return; } std::getline(input, library_path); std::getline(input, steam_client_path); const auto marker = files_path.find("/files/"); if (marker == std::string::npos) { return; } game.proton_path = files_path.substr(0, marker); fs::path steamapps; std::error_code ec; if (!steam_client_path.empty() && fs::is_directory(fs::path(steam_client_path) / "steamapps", ec)) { game.steam_client_path = steam_client_path; steamapps = game.steam_client_path / "steamapps"; } else { steamapps = game.proton_path; while (!steamapps.empty() && steamapps.filename() != "steamapps") { const auto parent = steamapps.parent_path(); if (parent == steamapps) { steamapps.clear(); break; } steamapps = parent; } if (steamapps.empty() || steamapps.filename() != "steamapps") { return; } game.steam_client_path = steamapps.parent_path(); } std::ifstream tool_manifest(game.proton_path / "toolmanifest.vdf"); if (!tool_manifest) { return; } std::stringstream tool_buffer; tool_buffer << tool_manifest.rdbuf(); const auto tool_doc = platf::steam::parse_vdf(tool_buffer.str()); const auto *manifest = tool_doc.find("manifest"); const auto *required = manifest ? manifest->find("require_tool_appid") : nullptr; if (!required || required->value.empty()) { return; } const auto required_id = number(required); if (!required_id || *required_id == 0 || *required_id > UINT32_MAX) { return; } const auto required_text = std::to_string(*required_id); // Steam can install the pressure-vessel runtime beside the game even when // a compatibility tool records a different client root. Prefer that // authoritative game library, then the client library. This is the layout // used by current UMU-Proton installs and avoids silently falling back to // an already-running Steam process that cannot inherit the stream limit. std::vector runtime_roots { game.library_path / "steamapps", steamapps, }; std::unordered_set visited; for (const auto &runtime_steamapps : runtime_roots) { ec.clear(); const auto canonical = fs::weakly_canonical(runtime_steamapps, ec).generic_string(); if (ec || !visited.insert(canonical).second) { continue; } std::ifstream runtime_manifest( runtime_steamapps / ("appmanifest_" + required_text + ".acf") ); if (!runtime_manifest) { continue; } std::stringstream runtime_buffer; runtime_buffer << runtime_manifest.rdbuf(); const auto runtime_doc = platf::steam::parse_vdf(runtime_buffer.str()); const auto *app_state = runtime_doc.find("AppState"); const auto app_id = app_state ? number(app_state->find("appid")) : std::nullopt; const auto *install_dir = app_state ? app_state->find("installdir") : nullptr; if (!app_id || *app_id != *required_id || !install_dir || install_dir->value.empty() || install_dir->value == "." || install_dir->value == ".." || install_dir->value.size() > 255 || install_dir->value.find('/') != std::string::npos || install_dir->value.find('\\') != std::string::npos) { continue; } const auto runtime = runtime_steamapps / "common" / install_dir->value; ec.clear(); if (fs::is_regular_file(runtime / "_v2-entry-point", ec)) { game.proton_runtime_path = runtime; break; } } } [[maybe_unused]] std::string shell_quote(std::string_view value) { std::string result = "'"; for (const char ch : value) { if (ch == '\'') { result += "'\\''"; } else { result.push_back(ch); } } result.push_back('\''); return result; } } // namespace namespace platf::steam { const vdf_node *vdf_node::find(const std::string &key) const { for (const auto &[name, node] : children) { if (name == key) { return &node; } } return nullptr; } vdf_node parse_vdf(const std::string &contents) { lexer lex {contents}; vdf_node root; parse_body(lex, root, false); return root; } std::vector default_library_roots() { std::vector roots; #ifdef _WIN32 DWORD steam_path_size = 0; if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath", RRF_RT_REG_SZ, nullptr, nullptr, &steam_path_size) == ERROR_SUCCESS && steam_path_size > sizeof(wchar_t)) { std::vector steam_path(steam_path_size / sizeof(wchar_t)); if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath", RRF_RT_REG_SZ, nullptr, steam_path.data(), &steam_path_size) == ERROR_SUCCESS) { add_root(roots, fs::path(steam_path.data())); } } if (!env("PROGRAMFILES(X86)").empty()) { add_root(roots, fs::path(env("PROGRAMFILES(X86)")) / "Steam"); } if (!env("PROGRAMFILES").empty()) { add_root(roots, fs::path(env("PROGRAMFILES")) / "Steam"); } if (!env("LOCALAPPDATA").empty()) { add_root(roots, fs::path(env("LOCALAPPDATA")) / "Steam"); } #elif defined(__APPLE__) const auto home = env("HOME"); if (!home.empty()) { add_root(roots, fs::path(home) / "Library/Application Support/Steam"); } #else const bool machine_host = machine_host_mode(); // The CAP_SYS_ADMIN machine daemon must not parse desktop-user-controlled // provider data. A session-side scanner can populate the machine catalog; // until then, retain the already migrated catalog and expose no home roots. if (machine_host) { return roots; } const auto home = env("HOME"); const auto xdg = env("XDG_DATA_HOME"); if (!home.empty()) { add_root(roots, fs::path(home) / ".local/share/Steam"); add_root(roots, fs::path(home) / ".steam/steam"); add_root(roots, fs::path(home) / ".steam/root"); add_root(roots, fs::path(home) / ".steam/debian-installation"); // Flatpak Steam keeps its native library metadata in the sandbox data // directory. Libraries added on other mounts are then picked up from // its libraryfolders.vdf just like a native installation. add_root(roots, fs::path(home) / ".var/app/com.valvesoftware.Steam/data/Steam"); add_root(roots, fs::path(home) / ".var/app/com.valvesoftware.Steam/.local/share/Steam"); add_root(roots, fs::path(home) / "snap/steam/common/.local/share/Steam"); } if (!xdg.empty()) { add_root(roots, fs::path(xdg) / "Steam"); } #endif return roots; } bool available() { #if defined(__linux__) if (machine_host_mode()) { const auto catalog = machine_steam_catalog(); return catalog && catalog->available; } #endif return !default_library_roots().empty(); } std::vector discover(const std::vector &requested_roots) { #if defined(__linux__) if (machine_host_mode()) { const auto catalog = machine_steam_catalog(); if (!catalog || !catalog->available) return {}; std::vector installed; std::copy_if(catalog->games.begin(), catalog->games.end(), std::back_inserter(installed), [](const auto &game) { return game.installed; }); return installed; } #endif auto roots = requested_roots; if (roots.empty()) { roots = default_library_roots(); } std::map found; std::unordered_set parsed_libraries; auto scan_library = [&](const fs::path &library) { const auto apps = steamapps_for(library); if (apps.empty()) { return; } std::error_code ec; const auto key = fs::weakly_canonical(apps, ec).generic_string(); if (!parsed_libraries.insert(key).second) { return; } for (const auto &entry : fs::directory_iterator(apps, ec)) { if (ec || !entry.is_regular_file(ec)) { continue; } const auto filename = entry.path().filename().string(); if (filename.rfind("appmanifest_", 0) != 0 || entry.path().extension() != ".acf") { continue; } std::ifstream input(entry.path()); if (!input) { continue; } std::stringstream buffer; buffer << input.rdbuf(); const auto doc = parse_vdf(buffer.str()); const auto *app = doc.find("AppState"); if (!app) { app = &doc; } const auto id = number(app->find("appid")); if (!id || *id == 0 || *id > UINT32_MAX) { continue; } game_t game; game.app_id = static_cast(*id); game.stable_id = "steam:" + std::to_string(game.app_id); game.installed = true; if (const auto *name = app->find("name")) { game.name = name->value; } if (const auto *type = app->find("type")) { game.app_type = type->value; } if (const auto *dir = app->find("installdir")) { game.install_dir = apps / "common" / dir->value; } game.library_path = apps.parent_path(); if (const auto flags = number(app->find("StateFlags"))) { game.state_flags = static_cast(*flags); } if (const auto updated = number(app->find("LastUpdated"))) { game.last_updated = *updated; } // A manifest can linger while an uninstall/move is in progress. Do // not advertise or launch it unless Steam's declared install folder // currently exists. StateFlags alone is not reliable during updates. if (game.install_dir.empty() || !fs::is_directory(game.install_dir, ec)) { ec.clear(); continue; } const auto art = artwork_for(game.app_id, game.library_path, roots); game.portrait_path = art.portrait; game.boxart_path = art.portrait; game.artwork_path = !art.portrait.empty() ? art.portrait : (!art.header.empty() ? art.header : art.icon); game.artwork_format = art.format; game.icon_path = art.icon; game.header_path = art.header; auto [existing, inserted] = found.emplace(game.app_id, game); if (!inserted) { // A duplicate manifest can be reached through multiple roots. Keep // the first deterministic record but fill artwork/type gaps from // the later record. auto &candidate = existing->second; const auto &other = game; if (candidate.portrait_path.empty()) { candidate.portrait_path = other.portrait_path; } if (candidate.boxart_path.empty()) { candidate.boxart_path = other.boxart_path; } if (candidate.artwork_path.empty()) { candidate.artwork_path = other.artwork_path; } if (candidate.artwork_format.empty()) { candidate.artwork_format = other.artwork_format; } if (candidate.header_path.empty()) { candidate.header_path = other.header_path; } if (candidate.icon_path.empty()) { candidate.icon_path = other.icon_path; } if (candidate.app_type.empty()) { candidate.app_type = other.app_type; } } } }; for (const auto &root : roots) { const auto apps = steamapps_for(root); if (!apps.empty()) { scan_library(apps); } const auto file = apps.empty() ? root / "steamapps/libraryfolders.vdf" : apps / "libraryfolders.vdf"; std::ifstream input(file); if (!input) { continue; } std::stringstream buffer; buffer << input.rdbuf(); const auto doc = parse_vdf(buffer.str()); const auto *libraries = doc.find("libraryfolders"); if (!libraries) { libraries = &doc; } for (const auto &[index, node] : libraries->children) { (void) index; if (const auto *path = library_path_node(node)) { scan_library(fs::path(path->value)); } } } #ifndef _WIN32 std::unordered_set metadata_roots; for (const auto &root : roots) { const auto apps = steamapps_for(root); const auto steam_root = apps.empty() ? root : apps.parent_path(); std::error_code ec; const auto key = fs::weakly_canonical(steam_root, ec).generic_string(); if (!ec && metadata_roots.insert(key).second) { try { enrich_from_appinfo(steam_root / "appcache/appinfo.vdf", found); apply_user_metadata(steam_root, found, false); } catch (...) { // Local launch metadata is an optimization. Steam's broker remains // the safe fallback for an unreadable or newly-versioned cache. } } } for (auto &[app_id, game] : found) { (void) app_id; apply_proton_metadata(game); } #endif std::vector result; result.reserve(found.size()); for (auto &[id, game] : found) { result.push_back(std::move(game)); } return result; } std::vector discover_catalog(const std::vector &requested_roots) { #if defined(__linux__) if (machine_host_mode()) { const auto catalog = machine_steam_catalog(); return catalog && catalog->available ? catalog->games : std::vector {}; } #endif auto roots = requested_roots; if (roots.empty()) { roots = default_library_roots(); } auto installed = discover(roots); std::map found; for (auto &game : installed) { found.emplace(game.app_id, std::move(game)); } std::unordered_set metadata_roots; for (const auto &root : roots) { const auto apps = steamapps_for(root); const auto steam_root = apps.empty() ? root : apps.parent_path(); std::error_code ec; const auto key = fs::weakly_canonical(steam_root, ec).generic_string(); if (ec || !metadata_roots.insert(key).second) { continue; } try { apply_user_metadata(steam_root, found, true); enrich_from_appinfo(steam_root / "appcache/appinfo.vdf", found); } catch (...) { // Preserve installed discovery if Steam is rewriting either cache. } } std::vector result; for (auto &[id, game] : found) { (void) id; if (game.name.empty()) { continue; } if (!game.installed) { const auto art = artwork_for(game.app_id, {}, roots); game.portrait_path = art.portrait; game.boxart_path = art.portrait; game.artwork_path = !art.portrait.empty() ? art.portrait : (!art.header.empty() ? art.header : art.icon); game.artwork_format = art.format; game.icon_path = art.icon; game.header_path = art.header; } result.push_back(std::move(game)); } return result; } std::string launch_uri(std::uint32_t app_id) { return app_id == 0 ? std::string {} : "steam://rungameid/" + std::to_string(app_id); } std::string launch_command(std::uint32_t app_id) { if (app_id == 0) { return {}; } #ifdef _WIN32 return "cmd /c start \"\" " + launch_uri(app_id); #elif defined(__APPLE__) return "open " + launch_uri(app_id); #else #if defined(__linux__) // The machine host has no desktop of its own; the session broker runs // `steam -applaunch` inside the selected desktop session for it. if (machine_host_mode()) { return "/usr/libexec/vibeshine/artlight-session-exec steam " + std::to_string(app_id); } #endif // Send the request directly to Steam. Desktop URI openers can exit // successfully even when KDE drops the handoff during an output switch. return "steam -applaunch " + std::to_string(app_id); #endif } #ifdef __linux__ namespace { constexpr std::string_view session_exec_path = "/usr/libexec/vibeshine/artlight-session-exec"; bool valid_session_launch_policy(const session_launch_policy_t &policy) { const bool overlay = policy.provider == "mangohud" || policy.provider == "mangohud-proton"; const bool limited = overlay || policy.provider == "proton"; if ((policy.playstation_controller_attached && !policy.proton_dualsense_compatibility) || (!limited && policy.provider != "disabled") || (limited && (policy.limit_millihz < 1000 || policy.limit_millihz > 1000000)) || (!limited && policy.limit_millihz != 0) || (policy.preset != "custom" && policy.preset != "1" && policy.preset != "2" && policy.preset != "3" && policy.preset != "4") || (!overlay && (policy.preset != "custom" || policy.always_show_graph)) || (policy.limiter_method != "early" && policy.limiter_method != "late") || (policy.provider != "mangohud" && policy.limiter_method != "late") || (!policy.smooth_motion && policy.smooth_motion_graphics_queue) || (policy.wayland_hdr_compatibility && !policy.hdr)) { return false; } return limited || policy.smooth_motion || policy.hdr || policy.proton_dualsense_compatibility; } bool parse_u32_token(std::string_view token, std::uint32_t &value) { if (token.empty() || token.front() == '+' || token.front() == '-') { return false; } const auto parsed = std::from_chars(token.data(), token.data() + token.size(), value); return parsed.ec == std::errc {} && parsed.ptr == token.data() + token.size(); } } // namespace std::string session_launch_command(std::uint32_t app_id, const session_launch_policy_t &policy) { if (app_id == 0 || !valid_session_launch_policy(policy)) { return {}; } return std::string(session_exec_path) + " steam-direct " + std::to_string(app_id) + " " + policy.provider + " " + std::to_string(policy.limit_millihz) + " " + policy.preset + " " + (policy.always_show_graph ? "1" : "0") + " " + policy.limiter_method + " " + (policy.smooth_motion ? "1" : "0") + " " + (policy.smooth_motion_graphics_queue ? "1" : "0") + " " + (policy.hdr ? "1" : "0") + " " + (policy.wayland_hdr_compatibility ? "1" : "0") + " " + (policy.proton_dualsense_compatibility ? "1" : "0") + " " + (policy.playstation_controller_attached ? "1" : "0"); } std::optional> session_launch_arguments( std::string_view command ) { std::vector tokens; std::size_t offset = 0; while (offset < command.size()) { const auto separator = command.find(' ', offset); const auto token = command.substr( offset, separator == std::string_view::npos ? command.size() - offset : separator - offset ); if (token.empty()) { return std::nullopt; } tokens.emplace_back(token); if (separator == std::string_view::npos) { break; } offset = separator + 1; } // The unresolved catalog fallback is a trusted session-exec Steam verb. // Passing it through the generic app wrapper would make the broker reject it. if (tokens.size() == 3 && tokens[0] == session_exec_path && tokens[1] == "steam") { std::uint32_t app_id = 0; if (!parse_u32_token(tokens[2], app_id) || app_id == 0 || command != std::string(session_exec_path) + " steam " + std::to_string(app_id)) { return std::nullopt; } tokens.erase(tokens.begin()); return tokens; } if (tokens.size() != 14 || tokens[0] != session_exec_path || tokens[1] != "steam-direct") { return std::nullopt; } std::uint32_t app_id = 0; session_launch_policy_t policy; if (!parse_u32_token(tokens[2], app_id) || app_id == 0 || !parse_u32_token(tokens[4], policy.limit_millihz) || (tokens[6] != "0" && tokens[6] != "1") || (tokens[8] != "0" && tokens[8] != "1") || (tokens[9] != "0" && tokens[9] != "1") || (tokens[10] != "0" && tokens[10] != "1") || (tokens[11] != "0" && tokens[11] != "1") || (tokens[12] != "0" && tokens[12] != "1") || (tokens[13] != "0" && tokens[13] != "1")) { return std::nullopt; } policy.provider = tokens[3]; policy.preset = tokens[5]; policy.always_show_graph = tokens[6] == "1"; policy.limiter_method = tokens[7]; policy.smooth_motion = tokens[8] == "1"; policy.smooth_motion_graphics_queue = tokens[9] == "1"; policy.hdr = tokens[10] == "1"; policy.wayland_hdr_compatibility = tokens[11] == "1"; policy.proton_dualsense_compatibility = tokens[12] == "1"; policy.playstation_controller_attached = tokens[13] == "1"; if (session_launch_command(app_id, policy) != command) { return std::nullopt; } tokens.erase(tokens.begin()); return tokens; } #endif std::string runtime_launch_command(std::string_view app_id, const std::string &configured_command, bool gamescope_session) { if (!gamescope_session || app_id.empty()) { return configured_command; } std::uint32_t id = 0; const auto parsed = std::from_chars(app_id.data(), app_id.data() + app_id.size(), id); if (parsed.ec != std::errc {} || parsed.ptr != app_id.data() + app_id.size() || id == 0) { return {}; } return launch_command(id); } std::string launch_command(const game_t &game) { #ifdef __linux__ if (game.app_id == 0 || game.launch_executable.empty()) { return launch_command(game.app_id); } std::string wrapper = "/usr/bin/artlight-mangohud"; // Relocatable installations ship the helper beside the running host. // Resolve the executable itself so switching a bundle's "current" // symlink cannot send an older host through a different release's helper. std::error_code wrapper_error; const auto executable = fs::read_symlink("/proc/self/exe", wrapper_error); if (!wrapper_error) { const auto bundled_wrapper = executable.parent_path() / "artlight-mangohud"; if (bundled_wrapper != wrapper && fs::is_regular_file(bundled_wrapper, wrapper_error) && access(bundled_wrapper.c_str(), X_OK) == 0) { wrapper = shell_quote(bundled_wrapper.generic_string()); } } std::string command = wrapper + " --appid " + std::to_string(game.app_id) + " -- env "; command += "SteamAppId=" + std::to_string(game.app_id) + " "; command += "SteamGameId=" + std::to_string(game.app_id) + " "; if (game.launch_os == "windows") { if (game.compatdata_path.empty() || game.proton_path.empty() || game.proton_runtime_path.empty() || game.steam_client_path.empty()) { return launch_command(game.app_id); } const auto app_id = std::to_string(game.app_id); const auto steamapps = game.library_path / "steamapps"; const auto client_steamapps = game.steam_client_path / "steamapps"; const auto shader_cache = steamapps / "shadercache" / app_id; std::string library_paths = steamapps.generic_string(); if (client_steamapps != steamapps) { library_paths += ":" + client_steamapps.generic_string(); } const auto tool_paths = game.proton_path.generic_string() + ":" + game.proton_runtime_path.generic_string(); command += "STEAM_COMPAT_APP_ID=" + app_id + " SteamOverlayGameId=" + app_id + " "; command += "STEAM_COMPAT_DATA_PATH=" + shell_quote(game.compatdata_path.generic_string()) + " "; command += "STEAM_COMPAT_CLIENT_INSTALL_PATH=" + shell_quote(game.steam_client_path.generic_string()) + " "; command += "STEAM_COMPAT_INSTALL_PATH=" + shell_quote(game.install_dir.generic_string()) + " "; command += "STEAM_COMPAT_LIBRARY_PATHS=" + shell_quote(library_paths) + " "; command += "STEAM_COMPAT_TOOL_PATHS=" + shell_quote(tool_paths) + " "; command += "STEAM_COMPAT_MOUNTS=" + shell_quote(tool_paths) + " "; command += "STEAM_COMPAT_SHADER_PATH=" + shell_quote(shader_cache.generic_string()) + " "; command += "STEAM_COMPAT_MEDIA_PATH=" + shell_quote((shader_cache / "fozmediav1").generic_string()) + " "; command += "STEAM_COMPAT_TRANSCODED_MEDIA_PATH=" + shell_quote(shader_cache.generic_string()) + " "; command += "DXVK_STATE_CACHE_PATH=" + shell_quote((shader_cache / "DXVK_state_cache").generic_string()) + " "; command += "__GL_SHADER_DISK_CACHE_PATH=" + shell_quote((shader_cache / "nvidiav1").generic_string()) + " "; command += "__GL_SHADER_DISK_CACHE_APP_NAME=steamapp_shader_cache "; command += "__GL_SHADER_DISK_CACHE_READ_ONLY_APP_NAME=" + shell_quote("steam_shader_cache;steamapp_merged_shader_cache") + " "; command += "__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1 "; command += shell_quote((game.proton_runtime_path / "_v2-entry-point").generic_string()); command += " --verb=waitforexitandrun -- "; command += shell_quote((game.proton_path / "proton").generic_string()); command += " waitforexitandrun " + shell_quote(game.launch_executable.generic_string()); } else { command += shell_quote(game.launch_executable.generic_string()); } if (!game.launch_arguments.empty()) { command.push_back(' '); command += game.launch_arguments; } std::string shell_command; if (game.launch_options.empty()) { shell_command = std::move(command); } else { shell_command = game.launch_options; constexpr std::string_view placeholder = "%command%"; if (const auto position = shell_command.find(placeholder); position != std::string::npos) { shell_command.replace(position, placeholder.size(), command); } else { shell_command = command + " " + shell_command; } } // Steam Launch Options are shell expressions, not argv fragments. They // commonly begin with environment assignments and may contain wrapper // commands or operators, so preserve those semantics explicitly. return "/bin/sh -c " + shell_quote(shell_command); #else return launch_command(game.app_id); #endif } bool launch(std::uint32_t app_id) { const auto uri = launch_uri(app_id); if (uri.empty()) { return false; } #ifdef _WIN32 return reinterpret_cast(ShellExecuteW(nullptr, L"open", std::filesystem::path(uri).c_str(), nullptr, nullptr, SW_SHOWNORMAL)) > 32; #else // The opener must be detached, but the caller still needs to know whether // it was actually executed. A close-on-exec pipe reports only pre-exec // failures: successful exec closes the write end and produces EOF. int exec_error_pipe[2] {-1, -1}; if (pipe(exec_error_pipe) != 0 || fcntl(exec_error_pipe[1], F_SETFD, FD_CLOEXEC) == -1) { if (exec_error_pipe[0] >= 0) { close(exec_error_pipe[0]); } if (exec_error_pipe[1] >= 0) { close(exec_error_pipe[1]); } return false; } const auto pid = fork(); if (pid < 0) { close(exec_error_pipe[0]); close(exec_error_pipe[1]); return false; } if (pid == 0) { // Detach the desktop opener so a long-lived xdg-open process cannot // become a Sunshine child; the intermediate child is reaped below. const auto grandchild = fork(); if (grandchild > 0) { close(exec_error_pipe[0]); close(exec_error_pipe[1]); _exit(0); } if (grandchild < 0) { const int error = errno; close(exec_error_pipe[0]); const auto ignored = write(exec_error_pipe[1], &error, sizeof(error)); (void) ignored; close(exec_error_pipe[1]); _exit(127); } close(exec_error_pipe[0]); #ifdef __APPLE__ execlp("open", "open", uri.c_str(), static_cast(nullptr)); #else const auto app_id_string = std::to_string(app_id); if (std::getenv("ARTLIGHT_MACHINE_HOST")) { execl("/usr/libexec/vibeshine/artlight-session-exec", "artlight-session-exec", "steam", app_id_string.c_str(), static_cast(nullptr)); } else { execlp("steam", "steam", "-applaunch", app_id_string.c_str(), static_cast(nullptr)); } #endif const int error = errno; const auto ignored = write(exec_error_pipe[1], &error, sizeof(error)); (void) ignored; close(exec_error_pipe[1]); _exit(127); } close(exec_error_pipe[1]); int child_status = 0; while (waitpid(pid, &child_status, 0) < 0 && errno == EINTR) { } int exec_error = 0; ssize_t received = 0; do { received = read(exec_error_pipe[0], &exec_error, sizeof(exec_error)); } while (received < 0 && errno == EINTR); close(exec_error_pipe[0]); return received == 0 && WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0; #endif } } // namespace platf::steam