server/src/steam_integration.cpp
Rama main · réplica de lectura del repositorio original.
/** @file src/steam_integration.cpp */
#include "steam_integration.h"
#if defined(__linux__)
#include "provider_scan_protocol.h"
#endif
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <iterator>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <unordered_map>
#include <unordered_set>
#ifdef _WIN32
// clang-format off
// windows.h must precede shellapi.h: the latter relies on its type macros.
#include <windows.h>
#include <shellapi.h>
// clang-format on
#elif defined(__APPLE__)
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#else
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#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<unsigned char>(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<std::string> 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<unsigned char>(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<std::uint64_t> 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<std::uint64_t>(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<const platf::provider_scan::steam_catalog_t> machine_steam_catalog() {
static std::mutex mutex;
static std::shared_ptr<const platf::provider_scan::steam_catalog_t> 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<const platf::provider_scan::steam_catalog_t>(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<fs::path> &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<fs::path> &dirs, std::uint32_t app_id, std::initializer_list<const char *> 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<fs::path> &dirs, std::uint32_t app_id, std::initializer_list<const char *> 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<fs::path> &dirs, std::uint32_t app_id, std::initializer_list<const char *> 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<fs::path> 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<fs::path> &steam_roots) {
std::vector<fs::path> cache_dirs;
std::vector<fs::path> 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:
// <appid>/<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<std::uint8_t> &data, std::size_t offset) {
if (offset + 4 > data.size()) {
throw std::out_of_range("Steam appinfo u32");
}
return static_cast<std::uint32_t>(data[offset]) |
(static_cast<std::uint32_t>(data[offset + 1]) << 8) |
(static_cast<std::uint32_t>(data[offset + 2]) << 16) |
(static_cast<std::uint32_t>(data[offset + 3]) << 24);
}
std::uint64_t read_u64(const std::vector<std::uint8_t> &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<std::uint64_t>(data[offset++]) << shift;
}
return value;
}
std::string read_cstring(const std::vector<std::uint8_t> &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<const char *>(data.data() + begin), offset - begin);
++offset;
return value;
}
vdf_node parse_binary_vdf(
const std::vector<std::uint8_t> &data,
std::size_t &offset,
std::size_t end,
const std::vector<std::string> &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<char>(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<char>(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<std::uint32_t, platf::steam::game_t> &games) {
std::ifstream input(path, std::ios::binary);
if (!input) {
return;
}
std::vector<std::uint8_t> data((std::istreambuf_iterator<char>(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<std::size_t>(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<std::string> 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<std::size_t>(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<std::uint32_t, platf::steam::game_t> &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<std::uint32_t>(id));
if (found == games.end()) {
if (!include_history || (last_played == 0 && playtime == 0)) {
continue;
}
platf::steam::game_t game;
game.app_id = static_cast<std::uint32_t>(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<fs::path> runtime_roots {
game.library_path / "steamapps",
steamapps,
};
std::unordered_set<std::string> 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<fs::path> default_library_roots() {
std::vector<fs::path> 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<wchar_t> 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<game_t> discover(const std::vector<fs::path> &requested_roots) {
#if defined(__linux__)
if (machine_host_mode()) {
const auto catalog = machine_steam_catalog();
if (!catalog || !catalog->available) return {};
std::vector<game_t> 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<std::uint32_t, game_t> found;
std::unordered_set<std::string> 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<std::uint32_t>(*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<std::uint32_t>(*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<std::string> 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<game_t> result;
result.reserve(found.size());
for (auto &[id, game] : found) {
result.push_back(std::move(game));
}
return result;
}
std::vector<game_t> discover_catalog(const std::vector<fs::path> &requested_roots) {
#if defined(__linux__)
if (machine_host_mode()) {
const auto catalog = machine_steam_catalog();
return catalog && catalog->available ? catalog->games : std::vector<game_t> {};
}
#endif
auto roots = requested_roots;
if (roots.empty()) {
roots = default_library_roots();
}
auto installed = discover(roots);
std::map<std::uint32_t, game_t> found;
for (auto &game : installed) {
found.emplace(game.app_id, std::move(game));
}
std::unordered_set<std::string> 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<game_t> 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<std::vector<std::string>> session_launch_arguments(
std::string_view command
) {
std::vector<std::string> 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<std::intptr_t>(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<char *>(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<char *>(nullptr));
} else {
execlp("steam", "steam", "-applaunch", app_id_string.c_str(), static_cast<char *>(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