server/src/confighttp_playnite.cpp
Rama main · réplica de lectura del repositorio original.
/**
* @file src/confighttp_playnite.cpp
* @brief Playnite-specific HTTP endpoints and helpers (Windows-only).
*/
#ifdef _WIN32
// standard includes
#include <algorithm>
#include <array>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cwctype>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <limits>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <vector>
// third-party includes
#include <boost/property_tree/json_parser.hpp>
#include <boost/property_tree/ptree.hpp>
#include <nlohmann/json.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <zlib.h>
// local includes
#include "config_playnite.h"
#include "confighttp.h"
#include "httpcommon.h"
#include "logging.h"
#include "log_export.h"
#include "src/platform/windows/ipc/misc_utils.h"
#include "src/platform/windows/playnite_integration.h"
#include "state_storage.h"
#include "uuid.h"
// Windows headers
#include <KnownFolders.h>
#include <ShlObj.h>
#include <windows.h>
#include <ws2tcpip.h>
// boost
#include <boost/crc.hpp>
namespace confighttp {
// Bring request/response types into scope to match confighttp.cpp usage
using resp_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
using req_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
// Forward declarations for helpers defined in confighttp.cpp
bool authenticate(resp_https_t response, req_https_t request);
void print_req(const req_https_t &request);
void send_response(resp_https_t response, const nlohmann::json &output_tree);
void bad_request(resp_https_t response, req_https_t request, const std::string &error_message = "Bad Request");
void conflict(resp_https_t response, const std::string &error_message);
bool check_content_type(resp_https_t response, req_https_t request, const std::string_view &contentType);
struct playnite_install_state_t {
std::optional<bool> installed;
std::filesystem::path extensions_dir;
};
// Helper: determine whether the Playnite plugin is installed.
// An active IPC connection is authoritative proof that the plugin is loaded,
// even if the current service context cannot resolve the user's extension path.
static playnite_install_state_t query_plugin_install_state(bool active) {
playnite_install_state_t state;
try {
std::string destPath;
if (platf::playnite::get_extension_target_dir(destPath)) {
state.extensions_dir = destPath;
state.installed =
std::filesystem::exists(state.extensions_dir / "extension.yaml") &&
std::filesystem::exists(state.extensions_dir / "SunshinePlaynite.psm1");
} else if (active) {
state.installed = true;
}
} catch (...) {
if (active) {
state.installed = true;
}
}
return state;
}
// Enhance app JSON with a Playnite-derived cover path when applicable.
void enhance_app_with_playnite_cover(nlohmann::json &input_tree) {
try {
if ((!input_tree.contains("image-path") || (input_tree["image-path"].is_string() && input_tree["image-path"].get<std::string>().empty())) &&
input_tree.contains("playnite-id") && input_tree["playnite-id"].is_string()) {
std::string cover;
if (platf::playnite::get_cover_png_for_playnite_game(input_tree["playnite-id"].get<std::string>(), cover)) {
input_tree["image-path"] = cover;
}
}
} catch (...) {
// Best-effort only
}
}
void enhance_app_with_playnite_icon(nlohmann::json &input_tree) {
try {
if ((!input_tree.contains("playnite-icon-path") || (input_tree["playnite-icon-path"].is_string() && input_tree["playnite-icon-path"].get<std::string>().empty())) &&
input_tree.contains("playnite-id") && input_tree["playnite-id"].is_string()) {
std::string icon;
if (platf::playnite::get_icon_png_for_playnite_game(input_tree["playnite-id"].get<std::string>(), icon)) {
input_tree["playnite-icon-path"] = icon;
}
}
} catch (...) {
// Best-effort only
}
}
// No longer needed: old fallback path resolver removed with AssocQueryString-based detection
void getPlayniteStatus(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
if (!config::playnite.enabled) {
send_response(response, nlohmann::json{{"active", false}, {"enabled", false}, {"available", false}, {"installed", false}, {"update_available", false}});
return;
}
// Keep the Playnite IPC client alive when the UI refreshes status.
// This updates the inactivity timer and ensures a fresh connection.
platf::playnite::ensure_client_for_api();
nlohmann::json out;
// Active reflects current pipe/server connection only
const bool active = platf::playnite::is_active();
out["active"] = active;
// Deprecated fields removed: playnite_running, installed_unknown
// Session requirement removed: IPC is available during RDP/lock; rely on
// active IPC first, then fall back to per-user extension path resolution.
const auto install_state = query_plugin_install_state(active);
const auto &dest = install_state.extensions_dir;
if (install_state.installed.has_value()) {
out["installed"] = *install_state.installed;
} else {
out["installed"] = nullptr;
}
out["extensions_dir"] = dest.string();
// Version info and update flag
auto normalize_ver = [](std::string s) {
// strip leading 'v' and whitespace
while (!s.empty() && (s[0] == ' ' || s[0] == '\t')) {
s.erase(s.begin());
}
if (!s.empty() && (s[0] == 'v' || s[0] == 'V')) {
s.erase(s.begin());
}
return s;
};
auto semver_cmp = [&](const std::string &a, const std::string &b) {
auto to_parts = [](const std::string &s) {
std::vector<int> parts;
int cur = 0;
bool have = false;
for (size_t i = 0; i <= s.size(); ++i) {
if (i == s.size() || s[i] == '.') {
parts.push_back(have ? cur : 0);
cur = 0;
have = false;
} else if (s[i] >= '0' && s[i] <= '9') {
have = true;
cur = cur * 10 + (s[i] - '0');
} else {
// stop at first non-digit/non-dot
break;
}
}
while (!parts.empty() && parts.back() == 0) {
parts.pop_back();
}
return parts;
};
auto pa = to_parts(normalize_ver(a));
auto pb = to_parts(normalize_ver(b));
size_t n = std::max(pa.size(), pb.size());
pa.resize(n, 0);
pb.resize(n, 0);
for (size_t i = 0; i < n; ++i) {
if (pa[i] < pb[i]) {
return -1;
}
if (pa[i] > pb[i]) {
return 1;
}
}
return 0;
};
std::string installed_ver, packaged_ver;
bool have_installed = platf::playnite::get_installed_plugin_version(installed_ver);
bool have_packaged = platf::playnite::get_packaged_plugin_version(packaged_ver);
if (have_installed) {
out["installed_version"] = installed_ver;
}
if (have_packaged) {
out["packaged_version"] = packaged_ver;
}
bool update_available = false;
if (out["installed"].is_boolean() && out["installed"].get<bool>() && have_installed && have_packaged) {
update_available = semver_cmp(installed_ver, packaged_ver) < 0;
}
out["update_available"] = update_available;
// No session readiness flag; IPC works through RDP/lock. Frontend derives readiness from installed/active.
// Reduce verbosity: this endpoint can be polled frequently by the UI.
// Log at debug level instead of info to avoid log spam while still
// keeping the line available when debugging.
BOOST_LOG(debug) << "Playnite status: active=" << out["active"]
<< ", dir=" << (dest.empty() ? std::string("(unknown)") : dest.string())
<< ", installed_version=" << (have_installed ? installed_ver : std::string(""))
<< ", packaged_version=" << (have_packaged ? packaged_ver : std::string(""))
<< ", update_available=" << (update_available ? "true" : "false");
send_response(response, out);
}
void getPlayniteGames(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
if (!query_plugin_install_state(platf::playnite::is_active()).installed.value_or(false)) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, "[]", headers);
return;
}
std::string json;
if (!platf::playnite::get_games_list_json(json)) {
// return empty array if not available
json = "[]";
}
BOOST_LOG(debug) << "Playnite games: json length=" << json.size();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, json, headers);
} catch (std::exception &e) {
bad_request(response, request, e.what());
}
}
void getPlayniteCategories(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
if (!query_plugin_install_state(platf::playnite::is_active()).installed.value_or(false)) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, "[]", headers);
return;
}
std::string json;
if (!platf::playnite::get_categories_list_json(json)) {
// return empty array if not available
json = "[]";
}
BOOST_LOG(debug) << "Playnite categories: json length=" << json.size();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, json, headers);
} catch (std::exception &e) {
bad_request(response, request, e.what());
}
}
void getPlaynitePlugins(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
if (!query_plugin_install_state(platf::playnite::is_active()).installed.value_or(false)) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, "[]", headers);
return;
}
std::string json;
if (!platf::playnite::get_plugins_list_json(json)) {
json = "[]";
}
BOOST_LOG(debug) << "Playnite plugins: json length=" << json.size();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, json, headers);
} catch (std::exception &e) {
bad_request(response, request, e.what());
}
}
void installPlaynite(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
print_req(request);
std::string err;
nlohmann::json out;
bool request_restart = false;
try {
std::stringstream ss;
ss << request->content.rdbuf();
if (ss.rdbuf()->in_avail() > 0) {
auto in = nlohmann::json::parse(ss);
request_restart = in.value("restart", false);
}
} catch (...) {
// ignore body parse errors; treat as no-restart
}
// Prefer same resolved dir as status
std::string target;
bool have_target = platf::playnite::get_extension_target_dir(target);
bool ok = false;
if (have_target) {
ok = platf::playnite::install_plugin_to(target, err);
} else {
ok = platf::playnite::install_plugin(err);
}
std::ostringstream log_msg;
log_msg << "Playnite install: " << (ok ? "success" : "failed");
if (have_target) {
log_msg << " target=" << target;
}
log_msg << " restart=" << (request_restart ? "true" : "false");
if (!ok && !err.empty()) {
log_msg << " error=" << err;
}
BOOST_LOG(info) << log_msg.str();
out["status"] = ok;
if (!ok) {
out["error"] = err;
}
// Optionally close and restart Playnite to pick up the new plugin
if (ok && request_restart) {
bool restarted = platf::playnite::restart_playnite();
out["restarted"] = restarted;
}
send_response(response, out);
}
void uninstallPlaynite(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
print_req(request);
std::string err;
nlohmann::json out;
bool request_restart = false;
try {
std::stringstream ss;
ss << request->content.rdbuf();
if (ss.rdbuf()->in_avail() > 0) {
auto in = nlohmann::json::parse(ss);
request_restart = in.value("restart", false);
}
} catch (...) {
// ignore body parse errors; treat as no-restart
}
bool ok = platf::playnite::uninstall_plugin(err);
{
std::ostringstream log_msg;
log_msg << "Playnite uninstall: " << (ok ? "success" : "failed")
<< " restart=" << (request_restart ? "true" : "false");
if (!ok && !err.empty()) {
log_msg << " error=" << err;
}
BOOST_LOG(info) << log_msg.str();
}
out["status"] = ok;
if (!ok) {
out["error"] = err;
}
if (ok && request_restart) {
bool restarted = platf::playnite::restart_playnite();
out["restarted"] = restarted;
}
send_response(response, out);
}
void postPlayniteForceSync(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
if (!config::playnite.enabled) {
send_response(response, nlohmann::json{{"status", false}, {"error", "Playnite integration is disabled"}});
return;
}
nlohmann::json out;
bool ok = platf::playnite::force_sync();
out["status"] = ok;
send_response(response, out);
}
void postPlayniteCover(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
std::stringstream stream;
stream << request->content.rdbuf();
const auto input = nlohmann::json::parse(stream);
const auto playnite_id = input.value("playnite_id", "");
const auto cover_key = input.value("cover_key", "");
if (playnite_id.empty() || cover_key.empty()) {
bad_request(response, request, "Playnite game ID and cover key are required");
return;
}
const auto cover_path = platf::appdata() / "covers" / (http::url_escape(cover_key) + ".png");
std::error_code error;
if (!std::filesystem::is_regular_file(cover_path, error) || error) {
bad_request(response, request, "Uploaded cover was not found");
return;
}
if (!platf::playnite::set_game_cover(playnite_id, cover_path.generic_string())) {
bad_request(response, request, "Playnite did not confirm the cover metadata update");
return;
}
if (!platf::playnite::force_sync()) {
bad_request(response, request, "Playnite did not confirm a refreshed metadata snapshot");
return;
}
const nlohmann::json output {
{"status", true},
{"path", cover_path.generic_string()}
};
send_response(response, output);
} catch (const std::exception &e) {
BOOST_LOG(warning) << "SetPlayniteCover: " << e.what();
bad_request(response, request, e.what());
}
}
void postPlayniteLaunch(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
nlohmann::json out;
// Use unified restart path: will start Playnite if not running
bool ok = platf::playnite::restart_playnite();
out["status"] = ok;
send_response(response, out);
}
using namespace log_export;
namespace {
namespace fs = std::filesystem;
namespace pt = boost::property_tree;
pt::ptree &ensure_pt_root(pt::ptree &tree) {
auto it = tree.find("root");
if (it == tree.not_found()) {
auto inserted = tree.insert(tree.end(), std::make_pair(std::string("root"), pt::ptree {}));
return inserted->second;
}
return it->second;
}
struct CrashDismissalState {
std::string filename;
std::string captured_at;
std::string dismissed_at;
};
std::optional<CrashDismissalState> load_crash_dismissal_state() {
statefile::migrate_recent_state_keys();
const std::string &path_str = statefile::vibeshine_state_path();
if (path_str.empty()) {
return std::nullopt;
}
std::lock_guard<std::mutex> lock(statefile::state_mutex());
fs::path path(path_str);
pt::ptree tree;
try {
if (statefile::load_json(path.string(), tree) != statefile::json_load_result_e::loaded) {
return std::nullopt;
}
} catch (const std::exception &e) {
BOOST_LOG(warning) << "Crash dismissal: failed to read state file: " << e.what();
return std::nullopt;
}
auto root_it = tree.find("root");
if (root_it == tree.not_found()) {
return std::nullopt;
}
auto dismissal_it = root_it->second.find("crashdump_dismissal");
if (dismissal_it == root_it->second.not_found()) {
return std::nullopt;
}
CrashDismissalState state;
state.filename = dismissal_it->second.get<std::string>("filename", "");
state.captured_at = dismissal_it->second.get<std::string>("captured_at", "");
state.dismissed_at = dismissal_it->second.get<std::string>("dismissed_at", "");
if (state.filename.empty()) {
return std::nullopt;
}
return state;
}
bool save_crash_dismissal_state(const CrashDismissalState &state) {
statefile::migrate_recent_state_keys();
const std::string &path_str = statefile::vibeshine_state_path();
if (path_str.empty()) {
return false;
}
std::lock_guard<std::mutex> lock(statefile::state_mutex());
fs::path path(path_str);
pt::ptree tree;
if (!statefile::load_json_for_update(path.string(), tree)) {
return false;
}
auto &root = ensure_pt_root(tree);
pt::ptree node;
node.put("filename", state.filename);
node.put("captured_at", state.captured_at);
node.put("dismissed_at", state.dismissed_at);
root.put_child("crashdump_dismissal", node);
try {
fs::path dir = path.parent_path();
if (!dir.empty() && !fs::exists(dir)) {
fs::create_directories(dir);
}
} catch (const std::exception &e) {
BOOST_LOG(error) << "Crash dismissal: failed to prepare directories: " << e.what();
}
try {
statefile::write_json_atomic(path.string(), tree);
} catch (const std::exception &e) {
BOOST_LOG(error) << "Crash dismissal: failed to write state file: " << e.what();
return false;
}
return true;
}
} // namespace
static bool read_file_if_exists(const std::filesystem::path &p, std::string &out, std::optional<std::filesystem::file_time_type> *write_time = nullptr) {
std::error_code ec {};
if (!std::filesystem::exists(p, ec) || std::filesystem::is_directory(p, ec)) {
return false;
}
try {
if (write_time) {
auto mtime = std::filesystem::last_write_time(p, ec);
if (!ec) {
*write_time = mtime;
} else {
*write_time = std::nullopt;
}
}
std::ifstream f(p, std::ios::binary);
if (!f) {
return false;
}
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
} catch (...) {
return false;
}
}
namespace {
struct log_candidate_t {
std::filesystem::path path;
std::filesystem::file_time_type mtime;
};
void add_log_candidate(const std::filesystem::path &path, std::vector<log_candidate_t> &out) {
std::error_code ec;
if (!std::filesystem::exists(path, ec) || std::filesystem::is_directory(path, ec)) {
return;
}
auto mtime = std::filesystem::last_write_time(path, ec);
if (ec) {
return;
}
out.push_back({path, mtime});
}
void add_session_log_candidates(const std::filesystem::path &dir, const std::string &prefix, std::vector<log_candidate_t> &out) {
std::error_code ec;
if (!std::filesystem::exists(dir, ec) || !std::filesystem::is_directory(dir, ec)) {
return;
}
for (std::filesystem::directory_iterator it(dir, ec); it != std::filesystem::directory_iterator(); ++it) {
if (ec) {
break;
}
std::error_code file_ec;
if (!it->is_regular_file(file_ec)) {
continue;
}
const auto filename = it->path().filename().string();
if (filename.rfind(prefix, 0) != 0) {
continue;
}
add_log_candidate(it->path(), out);
}
}
std::optional<std::filesystem::path> pick_latest_log(const std::vector<log_candidate_t> &candidates) {
if (candidates.empty()) {
return std::nullopt;
}
const auto it = std::max_element(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) {
return a.mtime < b.mtime;
});
if (it == candidates.end()) {
return std::nullopt;
}
return it->path;
}
void add_helper_log_roots(const std::filesystem::path &root, const std::string &base_name, std::vector<log_candidate_t> &candidates, bool include_temp = true) {
if (root.empty()) {
return;
}
const auto sunshine_base = root / L"Sunshine";
add_session_log_candidates(sunshine_base / L"logs", base_name + "-", candidates);
add_log_candidate(sunshine_base / (base_name + ".log"), candidates);
if (include_temp) {
const auto temp_base = root / L"Temp";
add_session_log_candidates(temp_base / L"Sunshine" / L"logs", base_name + "-", candidates);
add_log_candidate(temp_base / (base_name + ".log"), candidates);
}
}
std::optional<std::filesystem::path> find_latest_helper_log(const std::string &base_name) {
std::vector<log_candidate_t> candidates;
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
auto add_known_folder = [&](REFKNOWNFOLDERID id) {
PWSTR baseW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &baseW)) && baseW) {
std::filesystem::path base = baseW;
CoTaskMemFree(baseW);
add_helper_log_roots(base, base_name, candidates);
}
};
add_known_folder(FOLDERID_RoamingAppData);
add_known_folder(FOLDERID_LocalAppData);
} catch (...) {}
auto add_csidl_root = [&](int csidl) {
wchar_t baseW[MAX_PATH] = {};
if (!SUCCEEDED(SHGetFolderPathW(nullptr, csidl, nullptr, SHGFP_TYPE_CURRENT, baseW))) {
return;
}
std::filesystem::path base = std::filesystem::path(baseW);
add_helper_log_roots(base, base_name, candidates);
};
add_csidl_root(CSIDL_APPDATA);
add_csidl_root(CSIDL_LOCAL_APPDATA);
try {
wchar_t tmpPathW[MAX_PATH] = {};
DWORD n = GetTempPathW(_countof(tmpPathW), tmpPathW);
if (n > 0 && n < _countof(tmpPathW)) {
std::filesystem::path temp_base = std::filesystem::path(tmpPathW);
add_helper_log_roots(temp_base, base_name, candidates, false);
}
} catch (...) {}
return pick_latest_log(candidates);
}
} // namespace
bool is_helper_log_source(const std::string &source) {
return source == "display_helper" || source == "playnite" || source == "playnite_launcher" || source == "wgc";
}
bool read_helper_log(const std::string &source, std::string &out) {
std::string base_name;
if (source == "display_helper") {
base_name = "sunshine_display_helper";
} else if (source == "playnite") {
base_name = "sunshine_playnite";
} else if (source == "playnite_launcher") {
base_name = "sunshine_playnite_launcher";
} else if (source == "wgc") {
base_name = "sunshine_wgc_helper";
} else {
return false;
}
auto latest = find_latest_helper_log(base_name);
if (!latest) {
return false;
}
return read_file_if_exists(*latest, out);
}
constexpr std::uint64_t kCrashBundleMaxBytes = 30ull * 1024ull * 1024ull;
static std::uint64_t estimate_zip_entry_size(std::size_t name_len, std::uint64_t data_size);
static std::vector<ZipDataEntry> collect_support_logs() {
std::vector<ZipDataEntry> entries;
export_log_sanitizer_t sanitizer;
auto add_recent_logs = [&](const std::filesystem::path &dir, const std::string &prefix, const std::string &suffix, std::size_t limit, const std::string &zip_prefix) {
std::vector<log_candidate_t> candidates;
std::error_code ec;
if (!std::filesystem::exists(dir, ec) || !std::filesystem::is_directory(dir, ec)) {
return;
}
for (std::filesystem::directory_iterator it(dir, ec); it != std::filesystem::directory_iterator(); ++it) {
if (ec) {
break;
}
std::error_code file_ec;
if (!it->is_regular_file(file_ec)) {
continue;
}
const auto filename = it->path().filename().string();
if (filename.rfind(prefix, 0) != 0) {
continue;
}
if (filename.size() < suffix.size() || filename.compare(filename.size() - suffix.size(), suffix.size(), suffix) != 0) {
continue;
}
add_log_candidate(it->path(), candidates);
}
std::sort(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) {
return a.mtime > b.mtime;
});
if (candidates.size() > limit) {
candidates.resize(limit);
}
for (const auto &candidate : candidates) {
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(candidate.path, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, zip_prefix + candidate.path.filename().string(), std::move(data), mtime));
}
}
};
// ArtLight log directory (session logging). Retention keeps up to ~30
// sessions x 5 rollover files here; exporting all of them made the bundle
// hundreds of megabytes and blew past the HTTP content deadline. Newest
// files win, bounded by both a file count and a total byte budget.
constexpr std::size_t k_max_session_export_files = 32;
constexpr std::uintmax_t k_max_session_export_bytes = 64ull * 1024ull * 1024ull;
try {
bool collected_directory = false;
if (auto log_dir = logging::session_log_directory()) {
std::vector<log_candidate_t> candidates;
std::error_code ec;
for (std::filesystem::directory_iterator it(*log_dir, ec); it != std::filesystem::directory_iterator(); ++it) {
if (ec) {
break;
}
std::error_code file_ec;
if (!it->is_regular_file(file_ec)) {
continue;
}
add_log_candidate(it->path(), candidates);
}
std::sort(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) {
return a.mtime > b.mtime;
});
std::uintmax_t budget_used = 0;
std::size_t files_added = 0;
for (const auto &candidate : candidates) {
if (files_added >= k_max_session_export_files) {
break;
}
std::error_code size_ec;
const auto size = std::filesystem::file_size(candidate.path, size_ec);
if (size_ec || size > k_max_session_export_bytes - budget_used) {
continue;
}
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(candidate.path, data, &mtime)) {
if (data.size() > k_max_session_export_bytes - budget_used) {
continue;
}
budget_used += data.size();
++files_added;
entries.push_back(make_export_log_entry(sanitizer, candidate.path.filename().string(), std::move(data), mtime));
collected_directory = true;
}
}
}
if (!collected_directory) {
auto current_log = logging::current_log_file();
if (!current_log.empty()) {
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(current_log, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, current_log.filename().string(), std::move(data), mtime));
}
}
}
} catch (...) {}
// Playnite plugin log (Roaming\Sunshine\sunshine_playnite.log)
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
PWSTR roamingW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, user_token.get(), &roamingW)) && roamingW) {
std::filesystem::path p = std::filesystem::path(roamingW) / L"Sunshine" / L"sunshine_playnite.log";
CoTaskMemFree(roamingW);
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
} catch (...) {}
// Plugin fallback log: try user's LocalAppData\Temp then process TEMP
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
PWSTR localW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, user_token.get(), &localW)) && localW) {
std::filesystem::path p = std::filesystem::path(localW) / L"Temp" / L"sunshine_playnite.log";
CoTaskMemFree(localW);
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
} catch (...) {}
try {
wchar_t tmpPathW[MAX_PATH] = {};
DWORD n = GetTempPathW(_countof(tmpPathW), tmpPathW);
if (n > 0 && n < _countof(tmpPathW)) {
std::filesystem::path p = std::filesystem::path(tmpPathW) / L"sunshine_playnite.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
} catch (...) {}
auto add_playnite_from_base = [&](const std::filesystem::path &base) {
bool any = false;
{
std::string data;
auto p = base / L"playnite.log";
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
any = true;
}
}
{
std::string data;
auto p = base / L"extensions.log";
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
any = true;
}
}
{
std::string data;
auto p = base / L"launcher.log";
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
any = true;
}
}
return any;
};
bool got_playnite_logs = false;
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
auto add_from_known = [&](REFKNOWNFOLDERID id) {
PWSTR pathW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &pathW)) && pathW) {
std::filesystem::path base = std::filesystem::path(pathW) / L"Playnite";
CoTaskMemFree(pathW);
if (add_playnite_from_base(base)) {
got_playnite_logs = true;
}
}
};
add_from_known(FOLDERID_RoamingAppData);
add_from_known(FOLDERID_LocalAppData);
} catch (...) {}
if (!got_playnite_logs) {
try {
wchar_t buf[MAX_PATH] = {};
if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_APPDATA, nullptr, SHGFP_TYPE_CURRENT, buf))) {
got_playnite_logs |= add_playnite_from_base(std::filesystem::path(buf) / L"Playnite");
}
} catch (...) {}
try {
wchar_t buf[MAX_PATH] = {};
if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_LOCAL_APPDATA, nullptr, SHGFP_TYPE_CURRENT, buf))) {
got_playnite_logs |= add_playnite_from_base(std::filesystem::path(buf) / L"Playnite");
}
} catch (...) {}
}
// One scan per directory, newest few files per helper prefix. The previous
// shape re-enumerated the directory once per prefix and read every session
// file ever rotated, which multiplied into the export slowdown.
constexpr std::size_t k_max_helper_logs_per_prefix = 6;
auto add_session_logs_with_prefixes = [&](const std::filesystem::path &dir, const std::vector<std::string> &prefixes) {
std::map<std::string, std::vector<log_candidate_t>> per_prefix;
std::error_code ec;
for (std::filesystem::directory_iterator it(dir, ec); it != std::filesystem::directory_iterator(); ++it) {
if (ec) {
break;
}
std::error_code file_ec;
if (!it->is_regular_file(file_ec)) {
continue;
}
const auto filename = it->path().filename().string();
for (const auto &prefix : prefixes) {
if (filename.rfind(prefix, 0) == 0) {
add_log_candidate(it->path(), per_prefix[prefix]);
break;
}
}
}
for (auto &[prefix, candidates] : per_prefix) {
std::sort(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) {
return a.mtime > b.mtime;
});
if (candidates.size() > k_max_helper_logs_per_prefix) {
candidates.resize(k_max_helper_logs_per_prefix);
}
for (const auto &candidate : candidates) {
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(candidate.path, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, candidate.path.filename().string(), std::move(data), mtime));
}
}
}
};
// The known-folder and CSIDL passes below resolve to the same directories
// when ArtLight runs in a user session; without dedup every helper log was
// read from disk and regex-sanitized twice.
std::unordered_set<std::wstring> visited_helper_bases;
auto mark_base_visited = [&](const std::filesystem::path &base) {
std::error_code ec;
auto canonical = std::filesystem::weakly_canonical(base, ec);
std::wstring key = (ec ? base : canonical).native();
std::transform(key.begin(), key.end(), key.begin(), ::towlower);
return visited_helper_bases.insert(key).second;
};
auto add_user_helper_logs = [&](const std::filesystem::path &base) {
if (!mark_base_visited(base)) {
return;
}
// Legacy single-file helper logs (kept for backwards compatibility).
{
std::filesystem::path p = base / L"sunshine_playnite.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
{
std::filesystem::path p = base / L"sunshine_playnite_launcher.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
{
std::filesystem::path p = base / L"sunshine_launcher.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
{
std::filesystem::path p = base / L"sunshine_display_helper.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
{
std::filesystem::path p = base / L"sunshine_wgc_helper.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
}
// Session-mode helper logs live under Roaming/LocalAppData\\Sunshine\\logs.
static const std::vector<std::string> helper_log_prefixes {
"sunshine_playnite_launcher-",
"sunshine_playnite-",
"sunshine_launcher-",
"sunshine_display_helper-",
"sunshine_wgc_helper-",
};
add_session_logs_with_prefixes(base / L"logs", helper_log_prefixes);
};
try {
auto add_user_sunshine_logs = [&](REFKNOWNFOLDERID id) {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
PWSTR baseW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &baseW)) && baseW) {
std::filesystem::path base = std::filesystem::path(baseW) / L"Sunshine";
CoTaskMemFree(baseW);
add_user_helper_logs(base);
}
};
add_user_sunshine_logs(FOLDERID_RoamingAppData);
add_user_sunshine_logs(FOLDERID_LocalAppData);
} catch (...) {}
auto try_add_sunshine_logs = [&](int csidl) {
wchar_t baseW[MAX_PATH] = {};
if (!SUCCEEDED(SHGetFolderPathW(nullptr, csidl, nullptr, SHGFP_TYPE_CURRENT, baseW))) {
return;
}
std::filesystem::path base = std::filesystem::path(baseW) / L"Sunshine";
add_user_helper_logs(base);
};
try_add_sunshine_logs(CSIDL_APPDATA);
try_add_sunshine_logs(CSIDL_LOCAL_APPDATA);
try {
std::filesystem::path cfg = platf::appdata();
std::filesystem::path p = cfg / "sunshine_launcher.log";
std::string data;
std::optional<std::filesystem::file_time_type> mtime;
if (read_file_if_exists(p, data, &mtime)) {
entries.push_back(make_export_log_entry(sanitizer, p.filename().string(), std::move(data), mtime));
}
} catch (...) {}
// Installer logs persisted by the bootstrapper, plus recent MSI logs still in TEMP.
try {
std::filesystem::path install_log_dir = platf::appdata() / "logs" / "installer";
add_recent_logs(install_log_dir, "vibeshine_", ".log", 12, "installer/");
} catch (...) {}
try {
wchar_t tmpPathW[MAX_PATH] = {};
DWORD n = GetTempPathW(_countof(tmpPathW), tmpPathW);
if (n > 0 && n < _countof(tmpPathW)) {
std::filesystem::path temp_dir = std::filesystem::path(tmpPathW);
add_recent_logs(temp_dir, "vibeshine_install_", ".log", 8, "installer/temp/");
add_recent_logs(temp_dir, "vibeshine_preinstall_remove_", ".log", 8, "installer/temp/");
add_recent_logs(temp_dir, "vibeshine_uninstall_", ".log", 4, "installer/temp/");
add_recent_logs(temp_dir, "vibeshine_uninstall_remove_", ".log", 4, "installer/temp/");
add_recent_logs(temp_dir, "vibeshine_cli", ".log", 4, "installer/temp/");
}
} catch (...) {}
{
std::vector<ZipDataEntry> dedup;
std::unordered_set<std::string> seen;
dedup.reserve(entries.size());
for (auto &e : entries) {
if (seen.insert(e.name).second) {
dedup.emplace_back(std::move(e));
}
}
entries.swap(dedup);
}
// A crash-bundle part is capped at 30 MiB. Keep its in-memory log corpus
// within that exact uncompressed ZIP estimate, so logs never force an
// oversized first part before crash dumps are considered.
constexpr std::uint64_t kZipEndOfCentralDirectorySize = 22;
std::uint64_t estimated_log_size = kZipEndOfCentralDirectorySize;
std::size_t omitted_logs = 0;
std::vector<ZipDataEntry> bounded;
bounded.reserve(entries.size());
for (auto &entry : entries) {
const auto entry_size = estimate_zip_entry_size(entry.name.size(), static_cast<std::uint64_t>(entry.data.size()));
if (entry_size > kCrashBundleMaxBytes - estimated_log_size) {
++omitted_logs;
continue;
}
estimated_log_size += entry_size;
bounded.emplace_back(std::move(entry));
}
if (omitted_logs != 0) {
BOOST_LOG(warning) << "Crash bundle omitted " << omitted_logs << " log entries to keep each part below 30 MiB.";
}
entries.swap(bounded);
return entries;
}
void downloadPlayniteLogs(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
auto entries = collect_support_logs();
std::string zip = build_zip_from_entries(entries);
char fname[64];
std::time_t tt = std::time(nullptr);
std::tm tm {};
localtime_s(&tm, &tt);
std::snprintf(fname, sizeof(fname), "vibeshine_logs-%04d%02d%02d-%02d%02d%02d.zip", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec);
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/zip");
std::string cd = std::string("attachment; filename=\"") + fname + "\"";
headers.emplace("Content-Disposition", cd);
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, zip, headers);
} catch (std::exception &e) {
bad_request(response, request, e.what());
}
}
struct CrashDumpInfo {
std::string process;
std::filesystem::path path;
std::filesystem::file_time_type write_time;
std::uint64_t size;
};
struct ZipFileEntry {
std::string name;
std::filesystem::path path;
std::filesystem::file_time_type write_time;
std::uint64_t size;
};
struct CrashBundlePartPlan {
std::vector<ZipFileEntry> files;
bool include_logs = false;
std::string filename;
std::uint64_t estimated_size = 0;
};
struct CrashDumpTarget {
std::string process;
std::wstring prefix;
};
static const std::array<CrashDumpTarget, 4> kCrashDumpTargets = {{
{"sunshine.exe", L"sunshine.exe."},
{"sunshine_display_helper.exe", L"sunshine_display_helper.exe."},
{"sunshine_wgc_capture.exe", L"sunshine_wgc_capture.exe."},
{"playnite-launcher.exe", L"playnite-launcher.exe."},
}};
constexpr std::uint64_t kMinCrashDumpSunshineBytes = 10ull * 1024ull * 1024ull;
static std::wstring to_lower_wstring(std::wstring value) {
std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) {
return static_cast<wchar_t>(std::towlower(ch));
});
return value;
}
static std::string to_lower_ascii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
static bool has_dump_file_extension(const std::wstring &ext_lower) {
return ext_lower == L".dmp" || ext_lower == L".mdmp" || ext_lower == L".hdmp";
}
static bool ends_with_ascii(std::string_view value, std::string_view suffix) {
return value.size() >= suffix.size() && value.substr(value.size() - suffix.size()) == suffix;
}
static std::string trim_copy(std::string_view value);
static std::string filename_from_path(std::string value);
static bool has_numeric_instance_suffix(std::wstring_view value, std::size_t open_pos, std::size_t close_pos) {
if (open_pos == std::wstring_view::npos || close_pos == std::wstring_view::npos || close_pos <= open_pos + 1) {
return false;
}
for (std::size_t i = open_pos + 1; i < close_pos; ++i) {
if (!std::iswdigit(value[i])) {
return false;
}
}
return true;
}
static std::string normalize_process_name_for_matching(std::string value) {
value = to_lower_ascii(filename_from_path(trim_copy(value)));
if (value.empty()) {
return {};
}
const std::size_t close = value.rfind(')');
const std::size_t open = value.rfind('(');
if (open != std::string::npos && close != std::string::npos && close > open + 1) {
bool numeric_suffix = true;
for (std::size_t i = open + 1; i < close; ++i) {
if (!std::isdigit(static_cast<unsigned char>(value[i]))) {
numeric_suffix = false;
break;
}
}
if (numeric_suffix && close == value.size() - 1) {
value.erase(open);
}
}
return value;
}
static const CrashDumpTarget *find_crash_dump_target_by_process(std::string process_name) {
process_name = normalize_process_name_for_matching(std::move(process_name));
if (process_name.empty()) {
return nullptr;
}
const bool has_exe = ends_with_ascii(process_name, ".exe");
for (const auto &target : kCrashDumpTargets) {
if (process_name == target.process) {
return ⌖
}
if (!has_exe) {
if ((process_name + ".exe") == target.process) {
return ⌖
}
} else if (process_name.size() > 4) {
const std::string without_ext = process_name.substr(0, process_name.size() - 4);
if (without_ext == target.process.substr(0, target.process.size() - 4)) {
return ⌖
}
}
}
return nullptr;
}
static void add_unique_path(std::vector<std::filesystem::path> &paths, std::unordered_set<std::wstring> &seen, const std::filesystem::path &path) {
if (path.empty()) {
return;
}
std::filesystem::path normalized = path.lexically_normal();
std::wstring key = to_lower_wstring(normalized.wstring());
if (!seen.insert(key).second) {
return;
}
paths.push_back(normalized);
}
static std::vector<std::filesystem::path> crash_dump_roots() {
std::vector<std::filesystem::path> roots;
std::unordered_set<std::wstring> seen;
wchar_t sysDir[MAX_PATH] = {};
UINT len = GetSystemDirectoryW(sysDir, _countof(sysDir));
if (len > 0 && len < _countof(sysDir)) {
std::filesystem::path base(sysDir);
add_unique_path(roots, seen, base / L"config" / L"systemprofile" / L"AppData" / L"Local" / L"CrashDumps");
}
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
PWSTR localW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, user_token.get(), &localW)) && localW) {
std::filesystem::path base(localW);
CoTaskMemFree(localW);
add_unique_path(roots, seen, base / L"CrashDumps");
}
} catch (...) {}
wchar_t localBaseW[MAX_PATH] = {};
if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_LOCAL_APPDATA, nullptr, SHGFP_TYPE_CURRENT, localBaseW))) {
add_unique_path(roots, seen, std::filesystem::path(localBaseW) / L"CrashDumps");
}
return roots;
}
static std::vector<std::filesystem::path> wer_roots() {
std::vector<std::filesystem::path> roots;
std::unordered_set<std::wstring> seen;
auto add_wer_paths = [&](const std::filesystem::path &base) {
add_unique_path(roots, seen, base / L"Microsoft" / L"Windows" / L"WER" / L"ReportQueue");
add_unique_path(roots, seen, base / L"Microsoft" / L"Windows" / L"WER" / L"ReportArchive");
};
try {
platf::dxgi::safe_token user_token;
user_token.reset(platf::dxgi::retrieve_users_token(false));
PWSTR localW = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, user_token.get(), &localW)) && localW) {
std::filesystem::path base(localW);
CoTaskMemFree(localW);
add_wer_paths(base);
}
} catch (...) {}
wchar_t localBaseW[MAX_PATH] = {};
if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_LOCAL_APPDATA, nullptr, SHGFP_TYPE_CURRENT, localBaseW))) {
add_wer_paths(std::filesystem::path(localBaseW));
}
return roots;
}
static std::string to_iso8601(const std::chrono::system_clock::time_point &tp) {
std::time_t tt = std::chrono::system_clock::to_time_t(tp);
std::tm tm {};
gmtime_s(&tm, &tt);
char buf[32];
if (std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) {
return {};
}
return std::string(buf);
}
static const CrashDumpTarget *match_crash_dump_target(const std::wstring &filename_lower) {
for (const auto &target : kCrashDumpTargets) {
if (filename_lower.rfind(target.prefix, 0) == 0) {
return ⌖
}
if (!target.prefix.empty()) {
std::wstring base = target.prefix.substr(0, target.prefix.size() - 1);
if (filename_lower.rfind(base + L"(", 0) == 0) {
const std::size_t open = base.size();
const std::size_t close = filename_lower.find(L')', open + 1);
if (has_numeric_instance_suffix(filename_lower, open, close) &&
close + 1 < filename_lower.size() &&
filename_lower[close + 1] == L'.') {
return ⌖
}
}
}
}
return nullptr;
}
static bool is_sunshine_process(const std::string &process_lower) {
return process_lower == "sunshine.exe";
}
static bool read_text_file_best_effort(const std::filesystem::path &p, std::string &out_utf8) {
std::string raw;
if (!read_file_if_exists(p, raw)) {
return false;
}
if (raw.size() >= 2) {
const unsigned char b0 = static_cast<unsigned char>(raw[0]);
const unsigned char b1 = static_cast<unsigned char>(raw[1]);
const bool le_bom = (b0 == 0xFF && b1 == 0xFE);
const bool be_bom = (b0 == 0xFE && b1 == 0xFF);
if (le_bom || be_bom) {
const size_t byte_count = raw.size() - 2;
const size_t len16 = byte_count / 2;
std::wstring wide;
wide.resize(len16);
const unsigned char *data = reinterpret_cast<const unsigned char *>(raw.data() + 2);
for (size_t i = 0; i < len16; ++i) {
uint16_t code = 0;
if (be_bom) {
code = static_cast<uint16_t>((data[i * 2] << 8) | data[i * 2 + 1]);
} else {
code = static_cast<uint16_t>(data[i * 2] | (data[i * 2 + 1] << 8));
}
wide[i] = static_cast<wchar_t>(code);
}
while (!wide.empty() && wide.back() == L'\0') {
wide.pop_back();
}
if (!wide.empty()) {
int needed = WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), nullptr, 0, nullptr, nullptr);
if (needed > 0) {
out_utf8.assign(static_cast<size_t>(needed), '\0');
WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), out_utf8.data(), needed, nullptr, nullptr);
while (!out_utf8.empty() && out_utf8.back() == '\0') {
out_utf8.pop_back();
}
return true;
}
}
}
}
out_utf8 = raw;
return true;
}
static std::string trim_copy(std::string_view value) {
size_t start = 0;
while (start < value.size() && std::isspace(static_cast<unsigned char>(value[start]))) {
++start;
}
size_t end = value.size();
while (end > start && std::isspace(static_cast<unsigned char>(value[end - 1]))) {
--end;
}
std::string out(value.substr(start, end - start));
if (out.size() >= 2 && out.front() == '"' && out.back() == '"') {
out = out.substr(1, out.size() - 2);
}
return out;
}
static std::string filename_from_path(std::string value) {
const size_t pos = value.find_last_of("\\/");
if (pos != std::string::npos) {
value = value.substr(pos + 1);
}
return value;
}
static std::optional<std::string> wer_report_app_name(const std::string &content) {
std::string app_path;
std::string app_name;
std::string_view view(content);
size_t pos = 0;
while (pos < view.size()) {
size_t end = view.find_first_of("\r\n", pos);
if (end == std::string_view::npos) {
end = view.size();
}
std::string_view line = view.substr(pos, end - pos);
pos = end + 1;
if (end + 1 < view.size() && view[end] == '\r' && view[end + 1] == '\n') {
pos = end + 2;
}
const size_t eq = line.find('=');
if (eq == std::string_view::npos) {
continue;
}
std::string key = to_lower_ascii(trim_copy(line.substr(0, eq)));
std::string value = trim_copy(line.substr(eq + 1));
if (key == "apppath") {
app_path = value;
} else if (key == "appname") {
app_name = value;
}
}
if (!app_path.empty()) {
return normalize_process_name_for_matching(app_path);
}
if (!app_name.empty()) {
return normalize_process_name_for_matching(app_name);
}
return std::nullopt;
}
static std::optional<CrashDumpInfo> newest_dmp_in_dir(const std::filesystem::path &dir, const std::string &process_lower) {
std::error_code ec {};
if (!std::filesystem::exists(dir, ec) || !std::filesystem::is_directory(dir, ec)) {
return std::nullopt;
}
CrashDumpInfo best {};
std::filesystem::file_time_type best_time {};
bool have = false;
for (const auto &entry : std::filesystem::directory_iterator(dir, ec)) {
if (ec) {
break;
}
if (!entry.is_regular_file(ec)) {
continue;
}
auto ext = to_lower_wstring(entry.path().extension().wstring());
if (!has_dump_file_extension(ext)) {
continue;
}
auto write_time = entry.last_write_time(ec);
if (ec) {
ec.clear();
continue;
}
std::error_code size_ec {};
auto file_size_raw = std::filesystem::file_size(entry.path(), size_ec);
if (size_ec) {
continue;
}
if (file_size_raw > std::numeric_limits<std::uint64_t>::max()) {
continue;
}
const std::uint64_t file_size = static_cast<std::uint64_t>(file_size_raw);
if (!have || write_time > best_time) {
best_time = write_time;
best.process = process_lower;
best.path = entry.path();
best.write_time = write_time;
best.size = file_size;
have = true;
}
}
if (!have) {
return std::nullopt;
}
return best;
}
static bool is_recent_dump(const std::filesystem::file_time_type &write_time, std::chrono::hours max_age) {
auto sys_time = file_time_to_system_clock(write_time);
auto now = std::chrono::system_clock::now();
return sys_time + max_age >= now;
}
static bool should_accept_dump(const CrashDumpInfo &info) {
if (is_sunshine_process(info.process)) {
return info.size >= kMinCrashDumpSunshineBytes;
}
return true;
}
static std::vector<CrashDumpInfo> find_recent_crash_dumps(std::chrono::hours max_age) {
std::unordered_map<std::string, CrashDumpInfo> best;
auto consider = [&](CrashDumpInfo info) {
if (!is_recent_dump(info.write_time, max_age)) {
return;
}
if (!should_accept_dump(info)) {
return;
}
auto it = best.find(info.process);
if (it == best.end() || info.write_time > it->second.write_time) {
best[info.process] = std::move(info);
}
};
for (const auto &root : crash_dump_roots()) {
std::error_code ec {};
if (!std::filesystem::exists(root, ec) || !std::filesystem::is_directory(root, ec)) {
continue;
}
for (const auto &entry : std::filesystem::directory_iterator(root, ec)) {
if (ec) {
break;
}
if (!entry.is_regular_file(ec)) {
continue;
}
auto filename = entry.path().filename().wstring();
std::wstring lower = to_lower_wstring(filename);
const CrashDumpTarget *target = match_crash_dump_target(lower);
if (!target) {
continue;
}
auto ext = to_lower_wstring(entry.path().extension().wstring());
if (!has_dump_file_extension(ext)) {
continue;
}
auto write_time = entry.last_write_time(ec);
if (ec) {
ec.clear();
continue;
}
std::error_code size_ec {};
auto file_size_raw = std::filesystem::file_size(entry.path(), size_ec);
if (size_ec) {
continue;
}
if (file_size_raw > std::numeric_limits<std::uint64_t>::max()) {
continue;
}
CrashDumpInfo info;
info.process = target->process;
info.path = entry.path();
info.write_time = write_time;
info.size = static_cast<std::uint64_t>(file_size_raw);
consider(std::move(info));
}
}
for (const auto &root : wer_roots()) {
std::error_code ec {};
if (!std::filesystem::exists(root, ec) || !std::filesystem::is_directory(root, ec)) {
continue;
}
for (const auto &entry : std::filesystem::directory_iterator(root, ec)) {
if (ec) {
break;
}
if (!entry.is_directory(ec)) {
continue;
}
std::filesystem::path report_path = entry.path() / L"Report.wer";
std::string report;
if (!read_text_file_best_effort(report_path, report)) {
continue;
}
auto app_name = wer_report_app_name(report);
if (!app_name) {
continue;
}
const CrashDumpTarget *target = find_crash_dump_target_by_process(*app_name);
if (!target) {
continue;
}
auto dump = newest_dmp_in_dir(entry.path(), target->process);
if (!dump) {
continue;
}
consider(std::move(*dump));
}
}
std::vector<CrashDumpInfo> results;
results.reserve(best.size());
for (auto &kv : best) {
results.push_back(std::move(kv.second));
}
std::sort(results.begin(), results.end(), [](const CrashDumpInfo &a, const CrashDumpInfo &b) {
return a.write_time > b.write_time;
});
return results;
}
static std::uint64_t estimate_zip_entry_size(std::size_t name_len, std::uint64_t data_size) {
constexpr std::uint64_t kLocalHeaderSize = 30;
constexpr std::uint64_t kCentralHeaderSize = 46;
return kLocalHeaderSize + kCentralHeaderSize + (static_cast<std::uint64_t>(name_len) * 2) + data_size;
}
static std::uint64_t estimate_zip_size(const std::vector<ZipDataEntry> &data_entries, const std::vector<ZipFileEntry> &file_entries) {
constexpr std::uint64_t kEndOfCentralDirectory = 22;
std::uint64_t total = kEndOfCentralDirectory;
for (const auto &entry : data_entries) {
total += estimate_zip_entry_size(entry.name.size(), static_cast<std::uint64_t>(entry.data.size()));
}
for (const auto &entry : file_entries) {
total += estimate_zip_entry_size(entry.name.size(), entry.size);
}
return total;
}
static std::string crash_bundle_base_name() {
char fname[80];
std::time_t tt = std::time(nullptr);
std::tm tm {};
localtime_s(&tm, &tt);
std::snprintf(fname, sizeof(fname), "sunshine_crashbundle-%04d%02d%02d-%02d%02d%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec);
return std::string(fname);
}
static std::string crash_bundle_filename(const std::string &base, std::size_t part_index, std::size_t part_count) {
if (part_count <= 1) {
return base + ".zip";
}
return base + "-part" + std::to_string(part_index) + ".zip";
}
static std::vector<CrashBundlePartPlan> build_crash_bundle_plan(const std::vector<ZipDataEntry> &logs, const std::vector<CrashDumpInfo> &dumps) {
std::vector<CrashBundlePartPlan> parts;
const auto base = crash_bundle_base_name();
CrashBundlePartPlan current;
current.include_logs = true;
current.estimated_size = estimate_zip_size(logs, {});
parts.push_back(current);
auto add_new_part = [&](bool include_logs) {
CrashBundlePartPlan part;
part.include_logs = include_logs;
part.estimated_size = include_logs ? estimate_zip_size(logs, {}) : estimate_zip_size({}, {});
parts.push_back(part);
};
for (const auto &dump : dumps) {
ZipFileEntry entry {dump.path.filename().string(), dump.path, dump.write_time, dump.size};
const std::uint64_t entry_est = estimate_zip_entry_size(entry.name.size(), entry.size);
if (entry_est > kCrashBundleMaxBytes - estimate_zip_size({}, {})) {
BOOST_LOG(warning) << "Crash bundle skipped oversized dump " << entry.name << " to keep each part below 30 MiB.";
continue;
}
CrashBundlePartPlan *part = &parts.back();
const bool part_has_payload = (part->include_logs && !logs.empty()) || !part->files.empty();
if (part_has_payload && part->estimated_size > kCrashBundleMaxBytes - entry_est) {
add_new_part(false);
part = &parts.back();
}
part->files.push_back(entry);
part->estimated_size += entry_est;
}
for (std::size_t i = 0; i < parts.size(); ++i) {
parts[i].filename = crash_bundle_filename(base, i + 1, parts.size());
}
return parts;
}
struct CrashBundleSnapshot {
std::vector<ZipDataEntry> logs;
std::vector<CrashDumpInfo> dumps;
std::vector<CrashBundlePartPlan> plan;
std::chrono::steady_clock::time_point expires_at {};
std::string id;
};
struct CrashBundleSnapshotCache {
std::mutex mutex;
std::unordered_map<std::string, std::shared_ptr<const CrashBundleSnapshot>> snapshots;
};
constexpr auto kCrashBundleSnapshotTtl = std::chrono::minutes {5};
constexpr std::size_t kMaxCrashBundleSnapshots = 4;
static CrashBundleSnapshotCache &crash_bundle_snapshot_cache() {
static CrashBundleSnapshotCache cache;
return cache;
}
static void prune_crash_bundle_snapshots(CrashBundleSnapshotCache &cache, std::chrono::steady_clock::time_point now) {
for (auto it = cache.snapshots.begin(); it != cache.snapshots.end();) {
if (it->second->expires_at <= now) {
it = cache.snapshots.erase(it);
} else {
++it;
}
}
}
// The manifest freezes the sanitized logs, discovered dumps, part plan, and
// filenames. Download requests refer to its opaque id, so a newly-created
// dump cannot shift a later part onto different content.
static std::shared_ptr<const CrashBundleSnapshot> create_crash_bundle_snapshot() {
auto snapshot = std::make_shared<CrashBundleSnapshot>();
snapshot->logs = collect_support_logs();
snapshot->dumps = find_recent_crash_dumps(std::chrono::hours(24 * 7));
snapshot->plan = build_crash_bundle_plan(snapshot->logs, snapshot->dumps);
snapshot->id = uuid_util::uuid_t::generate().string();
snapshot->expires_at = std::chrono::steady_clock::now() + kCrashBundleSnapshotTtl;
auto &cache = crash_bundle_snapshot_cache();
std::lock_guard lock(cache.mutex);
const auto now = std::chrono::steady_clock::now();
prune_crash_bundle_snapshots(cache, now);
while (cache.snapshots.size() >= kMaxCrashBundleSnapshots) {
const auto oldest = std::min_element(cache.snapshots.begin(), cache.snapshots.end(), [](const auto &left, const auto &right) {
return left.second->expires_at < right.second->expires_at;
});
cache.snapshots.erase(oldest);
}
cache.snapshots.insert_or_assign(snapshot->id, snapshot);
return snapshot;
}
static std::shared_ptr<const CrashBundleSnapshot> find_crash_bundle_snapshot(std::string_view id) {
auto &cache = crash_bundle_snapshot_cache();
std::lock_guard lock(cache.mutex);
const auto now = std::chrono::steady_clock::now();
prune_crash_bundle_snapshots(cache, now);
const auto it = cache.snapshots.find(std::string(id));
return it == cache.snapshots.end() ? nullptr : it->second;
}
static bool crash_bundle_plan_has_dump(const CrashBundleSnapshot &snapshot) {
return std::any_of(snapshot.plan.begin(), snapshot.plan.end(), [](const auto &part) {
return !part.files.empty();
});
}
static inline void write_le16(std::ostream &out, uint16_t v) {
out.put(static_cast<char>(v & 0xFF));
out.put(static_cast<char>((v >> 8) & 0xFF));
}
static inline void write_le32(std::ostream &out, uint32_t v) {
out.put(static_cast<char>(v & 0xFF));
out.put(static_cast<char>((v >> 8) & 0xFF));
out.put(static_cast<char>((v >> 16) & 0xFF));
out.put(static_cast<char>((v >> 24) & 0xFF));
}
static bool write_zip_bundle_to_path(const std::filesystem::path &dest, const std::vector<ZipDataEntry> &data_entries, const std::vector<ZipFileEntry> &file_entries, std::string &error) {
std::ofstream out(dest, std::ios::binary | std::ios::trunc);
if (!out) {
error = "Failed to create crash bundle";
return false;
}
struct CdEntry {
std::string name;
uint32_t crc;
uint32_t comp_size;
uint32_t uncomp_size;
uint32_t offset;
uint16_t method;
uint16_t dostime;
uint16_t dosdate;
};
std::vector<CdEntry> cd;
auto add_entry = [&](const std::string &name, uint16_t method, uint32_t crc, uint32_t comp_size, uint32_t uncomp_size, uint16_t dostime, uint16_t dosdate, const std::string &payload) -> bool {
auto pos = out.tellp();
if (pos < 0 || static_cast<unsigned long long>(pos) > std::numeric_limits<uint32_t>::max()) {
error = "ZIP entry offset overflow";
return false;
}
uint32_t offset = static_cast<uint32_t>(pos);
write_le32(out, 0x04034b50u);
write_le16(out, 20);
write_le16(out, 0);
write_le16(out, method);
write_le16(out, dostime);
write_le16(out, dosdate);
write_le32(out, crc);
write_le32(out, comp_size);
write_le32(out, uncomp_size);
write_le16(out, static_cast<uint16_t>(name.size()));
write_le16(out, 0);
out.write(name.data(), name.size());
out.write(payload.data(), payload.size());
if (!out) {
error = "Failed writing ZIP entry";
return false;
}
cd.push_back(CdEntry {name, crc, comp_size, uncomp_size, offset, method, dostime, dosdate});
return true;
};
for (const auto &entry : data_entries) {
const std::string &name = entry.name;
const std::string &data = entry.data;
boost::crc_32_type crc;
crc.process_bytes(data.data(), data.size());
uint32_t checksum = crc.checksum();
std::string compressed;
bool use_deflate = deflate_buffer(data.data(), data.size(), compressed) && compressed.size() < data.size();
const std::string &payload = use_deflate ? compressed : data;
uint16_t method = use_deflate ? 8 : 0;
uint32_t comp_size = static_cast<uint32_t>(payload.size());
uint32_t uncomp_size = static_cast<uint32_t>(data.size());
uint16_t dostime = 0, dosdate = 0;
if (entry.write_time) {
to_dos_datetime(file_time_to_system_clock(*entry.write_time), dostime, dosdate);
} else {
current_dos_datetime(dostime, dosdate);
}
if (!add_entry(name, method, checksum, comp_size, uncomp_size, dostime, dosdate, payload)) {
return false;
}
}
for (const auto &entry : file_entries) {
std::error_code ec {};
if (!std::filesystem::exists(entry.path, ec) || !std::filesystem::is_regular_file(entry.path, ec)) {
error = "Crash dump no longer exists";
return false;
}
std::error_code metadata_ec {};
const auto current_size = std::filesystem::file_size(entry.path, metadata_ec);
if (metadata_ec) {
error = "Crash dump changed since the manifest was created";
return false;
}
const auto current_write_time = std::filesystem::last_write_time(entry.path, metadata_ec);
if (metadata_ec || current_size != entry.size || current_write_time != entry.write_time) {
error = "Crash dump changed since the manifest was created";
return false;
}
if (entry.size > std::numeric_limits<uint32_t>::max()) {
error = "Crash dump too large (over 4 GiB)";
return false;
}
std::ifstream in(entry.path, std::ios::binary);
if (!in) {
error = "Failed to open crash dump";
return false;
}
std::string raw;
raw.resize(static_cast<std::size_t>(entry.size));
in.read(raw.data(), static_cast<std::streamsize>(raw.size()));
if (in.gcount() != static_cast<std::streamsize>(raw.size())) {
error = "Failed to read crash dump";
return false;
}
boost::crc_32_type crc;
crc.process_bytes(raw.data(), raw.size());
uint32_t checksum = crc.checksum();
std::string compressed;
bool use_deflate = deflate_buffer(raw.data(), raw.size(), compressed) && compressed.size() < raw.size();
const std::string &payload = use_deflate ? compressed : raw;
uint16_t method = use_deflate ? 8 : 0;
uint32_t comp_size = static_cast<uint32_t>(payload.size());
uint32_t uncomp_size = static_cast<uint32_t>(raw.size());
uint16_t dostime = 0, dosdate = 0;
to_dos_datetime(file_time_to_system_clock(entry.write_time), dostime, dosdate);
if (!add_entry(entry.name, method, checksum, comp_size, uncomp_size, dostime, dosdate, payload)) {
return false;
}
}
auto cd_start_pos = out.tellp();
if (cd_start_pos < 0 || static_cast<unsigned long long>(cd_start_pos) > std::numeric_limits<uint32_t>::max()) {
error = "ZIP central directory offset overflow";
return false;
}
uint32_t cd_start = static_cast<uint32_t>(cd_start_pos);
for (const auto &e : cd) {
write_le32(out, 0x02014b50u);
write_le16(out, 20);
write_le16(out, 20);
write_le16(out, 0);
write_le16(out, e.method);
write_le16(out, e.dostime);
write_le16(out, e.dosdate);
write_le32(out, e.crc);
write_le32(out, e.comp_size);
write_le32(out, e.uncomp_size);
write_le16(out, static_cast<uint16_t>(e.name.size()));
write_le16(out, 0);
write_le16(out, 0);
write_le16(out, 0);
write_le16(out, 0);
write_le32(out, 0);
write_le32(out, e.offset);
out.write(e.name.data(), e.name.size());
if (!out) {
error = "Failed writing ZIP central directory";
return false;
}
}
auto cd_end_pos = out.tellp();
if (cd_end_pos < 0 || static_cast<unsigned long long>(cd_end_pos) > std::numeric_limits<uint32_t>::max()) {
error = "ZIP central directory size overflow";
return false;
}
uint32_t cd_size = static_cast<uint32_t>(cd_end_pos - cd_start_pos);
write_le32(out, 0x06054b50u);
write_le16(out, 0);
write_le16(out, 0);
write_le16(out, static_cast<uint16_t>(cd.size()));
write_le16(out, static_cast<uint16_t>(cd.size()));
write_le32(out, cd_size);
write_le32(out, cd_start);
write_le16(out, 0);
out.flush();
if (!out.good()) {
error = "Failed finalizing crash bundle";
return false;
}
return true;
}
void getCrashDumpStatus(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
auto dumps = find_recent_crash_dumps(std::chrono::hours(24 * 7));
nlohmann::json out;
if (dumps.empty()) {
out["available"] = false;
out["dismissed"] = false;
} else {
const auto &info = dumps.front();
auto captured = file_time_to_system_clock(info.write_time);
std::string captured_iso = to_iso8601(captured);
out["available"] = true;
out["process"] = info.process;
out["path"] = info.path.string();
out["filename"] = info.path.filename().string();
out["size_bytes"] = info.size;
out["captured_at"] = captured_iso;
auto age = std::chrono::system_clock::now() - captured;
out["age_seconds"] = std::chrono::duration_cast<std::chrono::seconds>(age).count();
out["age_hours"] = std::chrono::duration_cast<std::chrono::hours>(age).count();
if (auto dismissal = load_crash_dismissal_state()) {
bool matches = dismissal->filename == info.path.filename().string();
out["dismissed"] = matches;
if (matches && !dismissal->dismissed_at.empty()) {
out["dismissed_at"] = dismissal->dismissed_at;
}
if (matches && dismissal->captured_at.empty()) {
CrashDismissalState updated = *dismissal;
updated.captured_at = captured_iso;
save_crash_dismissal_state(updated);
}
} else {
out["dismissed"] = false;
}
}
send_response(response, out);
} catch (const std::exception &e) {
bad_request(response, request, e.what());
}
}
void postCrashDumpDismiss(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
const std::string body = request->content.string();
if (body.empty()) {
bad_request(response, request, "Missing request body");
return;
}
auto payload = nlohmann::json::parse(body, nullptr, true, true);
std::string filename = payload.value("filename", "");
std::string captured_at = payload.value("captured_at", "");
if (filename.empty()) {
bad_request(response, request, "Missing filename");
return;
}
auto dumps = find_recent_crash_dumps(std::chrono::hours(24 * 7));
if (dumps.empty()) {
bad_request(response, request, "No recent crash dumps found (within last 7 days)");
return;
}
const auto &info = dumps.front();
std::string current_iso = to_iso8601(file_time_to_system_clock(info.write_time));
if (filename != info.path.filename().string()) {
bad_request(response, request, "Crash dump metadata mismatch");
return;
}
CrashDismissalState state;
state.filename = std::move(filename);
state.captured_at = captured_at.empty() ? current_iso : std::move(captured_at);
state.dismissed_at = to_iso8601(std::chrono::system_clock::now());
if (!save_crash_dismissal_state(state)) {
bad_request(response, request, "Failed to persist crash dismissal");
return;
}
nlohmann::json out;
out["status"] = true;
out["dismissed_at"] = state.dismissed_at;
send_response(response, out);
} catch (const std::exception &e) {
bad_request(response, request, e.what());
}
}
void getCrashBundleManifest(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
auto snapshot = create_crash_bundle_snapshot();
if (snapshot->dumps.empty()) {
bad_request(response, request, "No recent crash dumps found (within last 7 days)");
return;
}
if (!crash_bundle_plan_has_dump(*snapshot)) {
bad_request(response, request, "Recent crash dumps exceed the 30 MiB crash-bundle limit");
return;
}
nlohmann::json out;
out["snapshot"] = snapshot->id;
out["parts"] = nlohmann::json::array();
for (std::size_t i = 0; i < snapshot->plan.size(); ++i) {
nlohmann::json part;
part["index"] = static_cast<int>(i + 1);
part["filename"] = snapshot->plan[i].filename;
part["estimated_size_bytes"] = snapshot->plan[i].estimated_size;
out["parts"].push_back(part);
}
send_response(response, out);
} catch (const std::exception &e) {
bad_request(response, request, e.what());
}
}
void downloadCrashBundle(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
std::size_t part_index = 1;
auto query = request->parse_query_string();
if (const auto it = query.find("part"); it != query.end()) {
try {
part_index = static_cast<std::size_t>(std::stoul(it->second));
} catch (...) {
bad_request(response, request, "Invalid crash bundle part index");
return;
}
if (part_index == 0) {
bad_request(response, request, "Invalid crash bundle part index");
return;
}
}
std::shared_ptr<const CrashBundleSnapshot> snapshot;
if (const auto snapshot_param = query.find("snapshot"); snapshot_param != query.end() && !snapshot_param->second.empty()) {
snapshot = find_crash_bundle_snapshot(snapshot_param->second);
if (!snapshot) {
conflict(response, "Crash bundle snapshot expired. Please request a new crash-bundle manifest.");
return;
}
} else {
// Preserve the legacy direct-download endpoint used by older Web UI
// bundles and bookmarked links. New clients always send a snapshot id.
snapshot = create_crash_bundle_snapshot();
}
if (snapshot->dumps.empty()) {
bad_request(response, request, "No recent crash dumps found (within last 7 days)");
return;
}
if (!crash_bundle_plan_has_dump(*snapshot)) {
bad_request(response, request, "Recent crash dumps exceed the 30 MiB crash-bundle limit");
return;
}
if (part_index > snapshot->plan.size()) {
bad_request(response, request, "Invalid crash bundle part index");
return;
}
const auto &selected = snapshot->plan[part_index - 1];
const std::vector<ZipDataEntry> empty_entries;
const auto &data_entries = selected.include_logs ? snapshot->logs : empty_entries;
wchar_t tmpDir[MAX_PATH] = {};
wchar_t tmpFile[MAX_PATH] = {};
if (GetTempPathW(_countof(tmpDir), tmpDir) == 0) {
bad_request(response, request, "Failed to resolve temporary directory");
return;
}
if (GetTempFileNameW(tmpDir, L"SNC", 0, tmpFile) == 0) {
bad_request(response, request, "Failed to create temporary file");
return;
}
std::filesystem::path bundle_path(tmpFile);
std::string error;
if (!write_zip_bundle_to_path(bundle_path, data_entries, selected.files, error)) {
std::error_code ec {};
std::filesystem::remove(bundle_path, ec);
if (error.empty()) {
error = "Failed to build crash bundle";
}
bad_request(response, request, error);
return;
}
std::ifstream in(bundle_path, std::ios::binary);
if (!in) {
std::error_code ec {};
std::filesystem::remove(bundle_path, ec);
bad_request(response, request, "Failed to open crash bundle");
return;
}
const std::string &fname = selected.filename.empty() ? crash_bundle_base_name() + ".zip" : selected.filename;
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/zip");
headers.emplace("Content-Disposition", std::string("attachment; filename=\"") + fname + "\"");
headers.emplace("X-Frame-Options", "DENY");
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
in.close();
std::error_code ec {};
std::filesystem::remove(bundle_path, ec);
} catch (const std::exception &e) {
bad_request(response, request, e.what());
}
}
} // namespace confighttp
#endif // _WIN32