/** * @file src/confighttp_playnite.cpp * @brief Playnite-specific HTTP endpoints and helpers (Windows-only). */ #ifdef _WIN32 // standard includes #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // third-party includes #include #include #include #include #include // 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 #include #include #include // boost #include namespace confighttp { // Bring request/response types into scope to match confighttp.cpp usage using resp_https_t = std::shared_ptr::Response>; using req_https_t = std::shared_ptr::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 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().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(), 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().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(), 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 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() && 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 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 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("filename", ""); state.captured_at = dismissal_it->second.get("captured_at", ""); state.dismissed_at = dismissal_it->second.get("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 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 *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 &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 &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 pick_latest_log(const std::vector &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 &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 find_latest_helper_log(const std::string &base_name) { std::vector 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 collect_support_logs() { std::vector 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 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 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 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 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 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 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 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 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 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 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 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 &prefixes) { std::map> 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 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 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 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 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 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 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 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 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 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 dedup; std::unordered_set 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 bounded; bounded.reserve(entries.size()); for (auto &entry : entries) { const auto entry_size = estimate_zip_entry_size(entry.name.size(), static_cast(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 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 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(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(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(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 &paths, std::unordered_set &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 crash_dump_roots() { std::vector roots; std::unordered_set 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 wer_roots() { std::vector roots; std::unordered_set 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(raw[0]); const unsigned char b1 = static_cast(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(raw.data() + 2); for (size_t i = 0; i < len16; ++i) { uint16_t code = 0; if (be_bom) { code = static_cast((data[i * 2] << 8) | data[i * 2 + 1]); } else { code = static_cast(data[i * 2] | (data[i * 2 + 1] << 8)); } wide[i] = static_cast(code); } while (!wide.empty() && wide.back() == L'\0') { wide.pop_back(); } if (!wide.empty()) { int needed = WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(wide.size()), nullptr, 0, nullptr, nullptr); if (needed > 0) { out_utf8.assign(static_cast(needed), '\0'); WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(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(value[start]))) { ++start; } size_t end = value.size(); while (end > start && std::isspace(static_cast(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 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 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::max()) { continue; } const std::uint64_t file_size = static_cast(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 find_recent_crash_dumps(std::chrono::hours max_age) { std::unordered_map 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::max()) { continue; } CrashDumpInfo info; info.process = target->process; info.path = entry.path(); info.write_time = write_time; info.size = static_cast(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 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(name_len) * 2) + data_size; } static std::uint64_t estimate_zip_size(const std::vector &data_entries, const std::vector &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(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 build_crash_bundle_plan(const std::vector &logs, const std::vector &dumps) { std::vector 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 logs; std::vector dumps; std::vector plan; std::chrono::steady_clock::time_point expires_at {}; std::string id; }; struct CrashBundleSnapshotCache { std::mutex mutex; std::unordered_map> 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 create_crash_bundle_snapshot() { auto snapshot = std::make_shared(); 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 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(v & 0xFF)); out.put(static_cast((v >> 8) & 0xFF)); } static inline void write_le32(std::ostream &out, uint32_t v) { out.put(static_cast(v & 0xFF)); out.put(static_cast((v >> 8) & 0xFF)); out.put(static_cast((v >> 16) & 0xFF)); out.put(static_cast((v >> 24) & 0xFF)); } static bool write_zip_bundle_to_path(const std::filesystem::path &dest, const std::vector &data_entries, const std::vector &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 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(pos) > std::numeric_limits::max()) { error = "ZIP entry offset overflow"; return false; } uint32_t offset = static_cast(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(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(payload.size()); uint32_t uncomp_size = static_cast(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::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(entry.size)); in.read(raw.data(), static_cast(raw.size())); if (in.gcount() != static_cast(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(payload.size()); uint32_t uncomp_size = static_cast(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(cd_start_pos) > std::numeric_limits::max()) { error = "ZIP central directory offset overflow"; return false; } uint32_t cd_start = static_cast(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(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(cd_end_pos) > std::numeric_limits::max()) { error = "ZIP central directory size overflow"; return false; } uint32_t cd_size = static_cast(cd_end_pos - cd_start_pos); write_le32(out, 0x06054b50u); write_le16(out, 0); write_le16(out, 0); write_le16(out, static_cast(cd.size())); write_le16(out, static_cast(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(age).count(); out["age_hours"] = std::chrono::duration_cast(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(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::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 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 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