Ctrl K
ComunidadGuías…

server/src/steam_sync_policy.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#include "steam_sync_policy.h"

#include <algorithm>
#include <cctype>
#include <charconv>
#include <chrono>
#include <iomanip>
#include <limits>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>

namespace platf::steam::sync::policy {
  namespace {
    nlohmann::json &apps(nlohmann::json &root) {
      if (!root.contains("apps") || !root["apps"].is_array()) {
        root["apps"] = nlohmann::json::array();
      }
      return root["apps"];
    }

    std::string lower(std::string value) {
      std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
        return static_cast<char>(std::tolower(c));
      });
      return value;
    }

    std::optional<std::uint32_t> parse_app_id(const std::string &value, int base = 10) {
      if (value.empty()) {
        return std::nullopt;
      }
      std::uint32_t result = 0;
      const auto *begin = value.data();
      const auto *end = begin + value.size();
      const auto parsed = std::from_chars(begin, end, result, base);
      if (parsed.ec != std::errc {} || parsed.ptr != end) {
        return std::nullopt;
      }
      return result;
    }

    std::optional<std::uint32_t> app_id_of(const nlohmann::json &app) {
      if (app.contains("steam-id")) {
        if (app["steam-id"].is_number_unsigned()) {
          const auto id = app["steam-id"].get<std::uint64_t>();
          if (id <= std::numeric_limits<std::uint32_t>::max()) {
            return static_cast<std::uint32_t>(id);
          }
        } else if (app["steam-id"].is_string()) {
          const auto raw = app["steam-id"].get<std::string>();
          if (const auto id = parse_app_id(raw)) {
            return id;
          }
          const auto normalized = lower(raw);
          if (normalized.starts_with("steam-")) {
            return parse_app_id(raw.substr(6));
          }
        }
      }
      if (app.contains("uuid") && app["uuid"].is_string()) {
        const auto uuid = app["uuid"].get<std::string>();
        const auto normalized = lower(uuid);
        if (normalized.starts_with("steam-")) {
          return parse_app_id(uuid.substr(6));
        }
        constexpr std::string_view canonical_prefix = "53544541-4d00-5000-8000-";
        if (normalized.starts_with(canonical_prefix) && normalized.size() == canonical_prefix.size() + 12) {
          return parse_app_id(normalized.substr(canonical_prefix.size()), 16);
        }
      }
      return std::nullopt;
    }

    std::string id_of(const nlohmann::json &app) {
      if (const auto id = app_id_of(app)) {
        return std::to_string(*id);
      }
      if (app.contains("steam-id") && app["steam-id"].is_string()) {
        return app["steam-id"].get<std::string>();
      }
      return {};
    }

    bool has_canonical_steam_identity(const nlohmann::json &app,
                                      std::uint32_t app_id) {
      return app.contains("uuid") && app["uuid"].is_string() &&
             lower(app["uuid"].get<std::string>()) ==
               canonical_steam_app_uuid(app_id);
    }

    bool excluded(const game_t &game, const std::vector<config::id_name_t> &entries) {
      const auto id = std::to_string(game.app_id);
      for (const auto &entry : entries) {
        if (!entry.id.empty() && entry.id == id) {
          return true;
        }
        if (!entry.name.empty() && lower(entry.name) == lower(game.name)) {
          return true;
        }
      }
      return false;
    }

    bool excluded(const std::string &steam_id, const std::string &name, const std::vector<config::id_name_t> &entries) {
      for (const auto &entry : entries) {
        if (!entry.id.empty() && entry.id == steam_id) {
          return true;
        }
        if (!entry.name.empty() && lower(entry.name) == lower(name)) {
          return true;
        }
      }
      return false;
    }

    void set_or_erase(nlohmann::json &app, const char *key, const std::string &value) {
      if (value.empty()) {
        app.erase(key);
      } else {
        app[key] = value;
      }
    }

    void set_or_erase(nlohmann::json &app, const char *key, const std::filesystem::path &value) {
      set_or_erase(app, key, value.empty() ? std::string {} : value.generic_string());
    }

    void update(nlohmann::json &app, const game_t &game, const std::string &source) {
      app["name"] = game.name.empty() ? "Steam " + std::to_string(game.app_id) : game.name;
      app["uuid"] = canonical_steam_app_uuid(game.app_id);
      app["steam-id"] = std::to_string(game.app_id);
      app["steam-managed"] = "auto";
      app["steam-source"] = source;
      set_or_erase(app, "steam-app-type", game.app_type);
      app["cmd"] = launch_command(game);
      app["auto-detach"] = true;
      app["wait-all"] = false;
      set_or_erase(app, "steam-install-dir", game.install_dir);
      set_or_erase(app, "steam-library-path", game.library_path);
      set_or_erase(app, "working-dir", game.launch_working_dir);
      set_or_erase(app, "steam-icon-path", game.icon_path);
      set_or_erase(app, "steam-header-path", game.header_path);
      const auto &boxart = !game.boxart_path.empty() ? game.boxart_path : game.portrait_path;
      if (!boxart.empty()) {
        app["steam-boxart-path"] = boxart.generic_string();
      } else {
        app.erase("steam-boxart-path");
      }
      if (!game.artwork_path.empty()) {
        app["steam-artwork-path"] = game.artwork_path.generic_string();
        set_or_erase(app, "steam-artwork-format", game.artwork_format);
      } else {
        app.erase("steam-artwork-path");
        app.erase("steam-artwork-format");
      }
      if (!game.artwork_client_path.empty()) {
        app["steam-artwork-client-path"] = game.artwork_client_path.generic_string();
        app["steam-artwork-client-compatible"] = true;
        app["image-path"] = game.artwork_client_path.generic_string();
      } else {
        // Conversion failures and disappearing Steam cache files must not
        // leave stale compatibility metadata. If the prior image was our
        // managed cache, return to the built-in fallback; otherwise preserve
        // a user-selected custom image path.
        const auto previous_client = app.value("steam-artwork-client-path", std::string {});
        app.erase("steam-artwork-client-path");
        app.erase("steam-artwork-client-compatible");
        if (!previous_client.empty() && app.value("image-path", std::string {}) == previous_client) {
          app["image-path"] = "./assets/steam.png";
        } else if (!app.contains("image-path") || !app["image-path"].is_string() || app["image-path"].get<std::string>().empty()) {
          app["image-path"] = "./assets/steam.png";
        }
      }
    }

    void normalize_stale_identity(nlohmann::json &app, std::uint32_t app_id) {
      app["uuid"] = canonical_steam_app_uuid(app_id);
      app["steam-id"] = std::to_string(app_id);
      app["steam-managed"] = "auto";
    }
  }  // namespace

  std::string canonical_steam_app_uuid(std::uint32_t app_id) {
    // UUID-shaped, deterministic namespace for Steam app IDs. The first
    // fields encode "STEAM" while the final 48 bits carry the app ID.
    std::ostringstream suffix;
    suffix << std::hex << std::nouppercase << std::setw(12) << std::setfill('0') << app_id;
    return "53544541-4d00-5000-8000-" + suffix.str();
  }

  bool is_importable(const game_t &game, bool include_tools) {
    if (include_tools) {
      return true;
    }
    // Steam's manifest type is authoritative when present; these are not
    // games (redistributables, runtimes, configuration and DLC). Keep a small
    // ID allowlist for old manifests that omit `type`.
    const auto type = lower(game.app_type);
    if (type == "tool" || type == "config" || type == "dlc" || type == "driver" || type == "music" || type == "video") {
      return false;
    }
    static const std::unordered_set<std::uint32_t> runtime_ids {
      228980,  // Steamworks Common Redistributables
      1070560,  // Steam Linux Runtime
      1391110,
      1628350,
      1824220,
      1493710,
      4183110  // Soldier/Proton/runtime generations
    };
    if (runtime_ids.contains(game.app_id)) {
      return false;
    }
    // Older compatibility manifests commonly omit `type`. These narrow
    // provider-owned prefixes are a fallback, and steam_include_tools lets a
    // user explicitly override the classification.
    const auto name = lower(game.name);
    return !name.starts_with("proton ") &&
           !name.starts_with("steam linux runtime") &&
           name != "steamworks common redistributables";
  }

  std::vector<game_t> filter_games(const std::vector<game_t> &games, const std::vector<config::id_name_t> &exclusions, bool include_tools) {
    std::vector<game_t> result;
    for (const auto &game : games) {
      if (is_importable(game, include_tools) && !excluded(game, exclusions)) {
        result.push_back(game);
      }
    }
    return result;
  }

  std::vector<game_t> select_games(const std::vector<game_t> &games, bool sync_all_installed, int recent_games, int recent_max_age_days, const std::vector<config::id_name_t> &exclusions, bool include_tools, std::uint64_t now) {
    auto selected = filter_games(games, exclusions, include_tools);
    std::erase_if(selected, [sync_all_installed](const auto &game) {
      return !game.installed || (!sync_all_installed && game.last_played == 0);
    });
    if (sync_all_installed) {
      return selected;
    }
    if (recent_games <= 0) {
      return {};
    }
    if (now == 0) {
      now = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
                                         std::chrono::system_clock::now().time_since_epoch()
      )
                                         .count());
    }
    if (recent_max_age_days > 0) {
      const auto max_age = static_cast<std::uint64_t>(recent_max_age_days) * 24 * 60 * 60;
      selected.erase(std::remove_if(selected.begin(), selected.end(), [now, max_age](const auto &game) {
                       return game.last_played > now || now - game.last_played > max_age;
                     }),
                     selected.end());
    }
    std::stable_sort(selected.begin(), selected.end(), [](const auto &left, const auto &right) {
      if (left.last_played != right.last_played) {
        return left.last_played > right.last_played;
      }
      return left.app_id < right.app_id;
    });
    if (selected.size() > static_cast<std::size_t>(recent_games)) {
      selected.resize(static_cast<std::size_t>(recent_games));
    }
    return selected;
  }

  bool reconcile(nlohmann::json &root, const std::vector<game_t> &input_games, bool remove_uninstalled, const std::vector<config::id_name_t> &exclusions, bool include_tools, const std::string &source) {
    auto &entries = apps(root);
    bool changed = false;
    const auto games = filter_games(input_games, exclusions, include_tools);
    std::unordered_map<std::string, const game_t *> by_id;
    for (const auto &game : games) {
      by_id.emplace(std::to_string(game.app_id), &game);
    }
    std::unordered_set<std::string> seen;
    for (auto it = entries.begin(); it != entries.end();) {
      auto &app = *it;
      const auto steam_id = id_of(app);
      const auto parsed_id = app_id_of(app);
      const bool managed = app.contains("steam-managed") &&
                           app["steam-managed"] == "auto";
      // Early auto-imported entries already received ArtLight's reserved,
      // deterministic Steam UUID but predate the steam-managed marker.  That
      // UUID is an ownership marker strong enough to adopt the entry.  Keep
      // shortcuts identified only by steam-id untouched as manual entries.
      const bool legacy_managed = !app.contains("steam-managed") && parsed_id &&
                                  has_canonical_steam_identity(app, *parsed_id);
      if (steam_id.empty() || (!managed && !legacy_managed)) {
        // A manually added game may have been saved while artwork was offline.
        // Repair only provider-owned covers; leave its launch settings intact.
        if (const auto found = by_id.find(steam_id); found != by_id.end() &&
            !found->second->artwork_client_path.empty()) {
          const auto image = app.value("image-path", std::string {});
          const auto previous = app.value("steam-artwork-client-path", std::string {});
          if (image.empty() || image == "./assets/steam.png" || (!previous.empty() && image == previous)) {
            const auto before = app;
            const auto path = found->second->artwork_client_path.generic_string();
            app["image-path"] = path;
            app["steam-artwork-client-path"] = path;
            app["steam-artwork-client-compatible"] = true;
            changed = changed || app != before;
          }
        }
        ++it;
        continue;
      }
      const auto name = app.value("name", std::string {});
      const bool is_excluded = excluded(steam_id, name, exclusions);
      if (is_excluded) {
        // Exclusions are an explicit catalog policy and remove stale
        // auto-managed entries even when uninstall removal is disabled.
        it = entries.erase(it);
        changed = true;
        continue;
      }
      if (const auto found = by_id.find(steam_id); found != by_id.end()) {
        if (seen.contains(steam_id)) {
          it = entries.erase(it);
          changed = true;
          continue;
        }
        const auto before = app;
        update(app, *found->second, source);
        seen.insert(steam_id);
        changed = changed || before != app;
        ++it;
      } else {
        if (seen.contains(steam_id)) {
          it = entries.erase(it);
          changed = true;
          continue;
        }
        seen.insert(steam_id);
        if (const auto stale_id = app_id_of(app)) {
          const auto before = app;
          normalize_stale_identity(app, *stale_id);
          changed = changed || before != app;
        }
        if (remove_uninstalled) {
          it = entries.erase(it);
          changed = true;
          continue;
        }
        ++it;
      }
    }
    for (const auto &game : games) {
      const auto key = std::to_string(game.app_id);
      if (seen.contains(key)) {
        continue;
      }
      // Do not duplicate a manually managed entry that has the same provider ID.
      const auto existing = std::find_if(entries.begin(), entries.end(), [&key](const auto &app) {
        return id_of(app) == key;
      });
      if (existing != entries.end()) {
        continue;
      }
      nlohmann::json app;
      update(app, game, source);
      entries.push_back(std::move(app));
      changed = true;
    }
    return changed;
  }
}  // namespace platf::steam::sync::policy