From 310922556cd5e57d3691cc58673d2a0d8254e70d Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 15 Oct 2026 14:23:06 -0500 Subject: [PATCH] Launcher: a setting to test pre-releases Settings > Launcher > "Test pre-releases" (off by default). On, the update check takes the newest GitHub release that is not a draft and carries a launcher.json, pre-release or not, in place of the latest full release: a pre-release newer than the last release is installed, pinned by size and SHA-256 as any release is. Turned off, the next check goes back to the latest release, whose files then replace the pre-release's. - The update notice says "Pre-release" for one, and the log names which kind of release was taken. - The changelog shows the release the setting picks, marked as a pre-release, and is fetched again when the setting changes. - --no-gui launches follow the same setting. --- Launcher/gui.h | 2 ++ Launcher/gui_changelog.cpp | 9 ++++--- Launcher/gui_home.cpp | 5 +++- Launcher/gui_internal.h | 5 ++++ Launcher/gui_launcher.cpp | 9 +++++-- Launcher/gui_settings.cpp | 5 ++++ Launcher/main.cpp | 2 +- Launcher/updater.cpp | 52 +++++++++++++++++++++++++++----------- Launcher/updater.h | 13 +++++++--- 9 files changed, 77 insertions(+), 25 deletions(-) diff --git a/Launcher/gui.h b/Launcher/gui.h index 05cc068..5bc391b 100644 --- a/Launcher/gui.h +++ b/Launcher/gui.h @@ -15,6 +15,8 @@ int run(const launcher_app::Session& session, const std::vector& a // The Settings page's "Install ReSkate updates" choice; scripted (--no-gui) launches honour it too. bool updates_enabled(const launcher_app::Session& session); +// Whether the saved settings ask for pre-releases (the "Test pre-releases" setting). +bool prereleases_enabled(const launcher_app::Session& session); // What pressing Play would start the game with: the Settings page's saved choices. A scripted // launch that names none of its own (a bare --no-gui, as Discord runs for a join) uses these. diff --git a/Launcher/gui_changelog.cpp b/Launcher/gui_changelog.cpp index af891bc..f267990 100644 --- a/Launcher/gui_changelog.cpp +++ b/Launcher/gui_changelog.cpp @@ -10,11 +10,12 @@ void start_fetch(Changelog& log) { if (log.loading.exchange(true)) return; if (log.worker.joinable()) log.worker.join(); log.error.clear(); - log.worker = std::thread([&log] { + log.fetched_prereleases = log.prereleases; + log.worker = std::thread([&log, prereleases = log.prereleases] { update::ReleaseNote notes; std::string error; try { - notes = update::fetch_release_note(); + notes = update::fetch_release_note(prereleases); } catch (const std::exception& failure) { error = failure.what(); logging::write(logging::Level::warning, logging::Channel::launcher, @@ -45,6 +46,8 @@ void changelog_window(const Fonts& fonts, ImVec2 size, Ui& ui) { log.incoming = {}; } } + // (The pre-release setting changed since: the notes are fetched again for it.) + if (log.loaded && !log.loading && log.fetched_prereleases != log.prereleases) log.loaded = false; if (!log.loaded && !log.loading && log.error.empty()) start_fetch(log); const auto panel = begin_panel("##changelog_panel", size, ImVec2(S(880), S(680))); @@ -64,7 +67,7 @@ void changelog_window(const Fonts& fonts, ImVec2 size, Ui& ui) { ImGui::PushFont(fonts.heading); ImGui::TextUnformatted(label.c_str()); ImGui::PopFont(); - if (!note.date.empty()) ImGui::TextDisabled("Released %s", note.date.c_str()); + if (!note.date.empty()) ImGui::TextDisabled("%s %s", note.prerelease ? "Pre-release," : "Released", note.date.c_str()); ImGui::Dummy(ImVec2(0, S(6))); if (log.text.lines.empty()) ImGui::TextDisabled("This release has no patch notes."); else draw_readme(fonts, log.text); diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 6ffd6c6..9e2b2c4 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -531,7 +531,10 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane else if (qr_open) qr_window(launcher, fonts, size, state.qr); else if (ui.sign_in) sign_in_window(launcher, fonts, size, ui); else if (ui.settings) settings_window(launcher, fonts, size, ui, window); - else if (ui.changelog) changelog_window(fonts, size, ui); + else if (ui.changelog) { + ui.notes.prereleases = launcher.settings().prereleases; + changelog_window(fonts, size, ui); + } else if (ui.mods) mods_window(launcher, fonts, size, ui, mods_panel, window); else if (state.phase == Phase::mods_broken) mods_broken_window(launcher, fonts, size, ui, mods_panel, state.mod_problems); diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 56b754d..0bc5100 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -194,6 +194,9 @@ struct Settings { bool crash_reports{true}; // On: launches send how long their steps took (RESKATE_TRACING=1 for the launcher and the game). bool performance_timings{}; + // On: update to the newest pre-release on GitHub when there is one newer than the latest + // release. Turned off, the next check goes back to the latest release. + bool prereleases{}; std::string steam_username; // never the password bool steam_remember{true}; bool steam_prefer_code{}; @@ -318,6 +321,8 @@ struct Changelog { std::string error; // why the last fetch failed update::ReleaseNote note; thunderstore::Readme text; + // Whether pre-releases are wanted (the setting), and whether `note` was fetched with them. + bool prereleases{}, fetched_prereleases{}; ~Changelog() { if (worker.joinable()) worker.join(); diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index e745ace..2213aac 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -66,6 +66,7 @@ Settings load_settings(const fs::path& path) { settings.updates = json.value("updates", settings.updates); settings.crash_reports = json.value("crash_reports", settings.crash_reports); settings.performance_timings = json.value("performance_timings", settings.performance_timings); + settings.prereleases = json.value("prereleases", settings.prereleases); settings.steam_username = json.value("steam_username", std::string()); settings.steam_remember = json.value("steam_remember", settings.steam_remember); settings.steam_prefer_code = json.value("steam_prefer_code", settings.steam_prefer_code); @@ -92,6 +93,7 @@ void save_settings(const fs::path& path, const Settings& settings) { json["updates"] = settings.updates; json["crash_reports"] = settings.crash_reports; json["performance_timings"] = settings.performance_timings; + json["prereleases"] = settings.prereleases; json["steam_username"] = settings.steam_username; json["steam_remember"] = settings.steam_remember; json["steam_prefer_code"] = settings.steam_prefer_code; @@ -310,7 +312,7 @@ bool Launcher::runtime_outdated(const update::Config& config) const { void Launcher::run_check() { set(Phase::checking, "Checking for updates"); update::remove_previous_launcher(session_.self); - auto config = update::fetch_config(); + auto config = update::fetch_config(settings_.prereleases); { std::lock_guard lock(mutex_); state_.config = config; @@ -318,7 +320,7 @@ void Launcher::run_check() { const bool newer = config && (launcher_outdated(*config) || runtime_outdated(*config)); if (newer && update::binary_updates_enabled() && binaries_ && settings_.updates) { set(Phase::update_available, "A ReSkate update is ready", - std::format("Version {} will be downloaded and installed.", + std::format("{} {} will be downloaded and installed.", config->prerelease ? "Pre-release" : "Version", config->runtime.version.empty() ? config->launcher.version : config->runtime.version)); return; } @@ -521,6 +523,9 @@ namespace dingosdk::launcher_gui { bool updates_enabled(const launcher_app::Session& session) { return detail::load_settings(detail::settings_path(session)).updates; } +bool prereleases_enabled(const launcher_app::Session& session) { + return detail::load_settings(detail::settings_path(session)).prereleases; +} launcher::LaunchOptions saved_launch_options(const launcher_app::Session& session) { return detail::launch_options(detail::load_settings(detail::settings_path(session))); } diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 7d91c25..ce00232 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -435,6 +435,11 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui "Turn off to keep the ReSkate.dll and launcher you have, such as a test build someone gave you. " "Skate's own Steam updates are not affected.", settings.updates); + setting_check("Test pre-releases", + "Update to the newest pre-release on GitHub when there is one, to try what is coming before it " + "is released. Pre-releases are less tested. Turn off to go back to the latest release; the " + "launcher picks a change up when it next starts.", + settings.prereleases); ImGui::Spacing(); section_caption(fonts, "TROUBLESHOOTING"); ImGui::AlignTextToFramePadding(); diff --git a/Launcher/main.cpp b/Launcher/main.cpp index 5cd9838..36050a6 100644 --- a/Launcher/main.cpp +++ b/Launcher/main.cpp @@ -80,7 +80,7 @@ void run_headless(const app::Session& session, std::vector argumen if (!map.empty()) options.map = std::move(map); update::remove_previous_launcher(session.self); if (updates) { - const auto config = update::fetch_config(); + const auto config = update::fetch_config(dingosdk::launcher_gui::prereleases_enabled(session)); if (config && apply_headless_updates(session, *config, original, relaunched)) return; } if (!app::game_files_supported(session.paths)) diff --git a/Launcher/updater.cpp b/Launcher/updater.cpp index f917203..556b0e0 100644 --- a/Launcher/updater.cpp +++ b/Launcher/updater.cpp @@ -325,7 +325,30 @@ Config parse_config(std::string_view text) { return config; } -std::optional fetch_config() { +namespace { +// The release the launcher updates from: GitHub's latest full release, or with `prereleases` +// the newest one that is not a draft and carries a launcher.json, pre-release or not (GitHub +// lists them newest first). Only these lookups use GitHub's rate-limited API. +Json release_to_use(bool prereleases) { + const auto repo = std::wstring(L"https://api.github.com/repos/") + launcher_release_repo; + std::string text; + if (!prereleases) { + http_get(repo + L"/releases/latest", 4 * 1024 * 1024, 8000, [&](const char* data, DWORD count) { text.append(data, count); }); + return Json::parse(text); + } + http_get(repo + L"/releases?per_page=10", 4 * 1024 * 1024, 8000, [&](const char* data, DWORD count) { text.append(data, count); }); + auto releases = Json::parse(text); + if (!releases.is_array()) fail("GitHub's list of releases is not a list"); + for (const auto& release : releases) { + if (!release.is_object() || release.value("draft", false) || !release.contains("assets")) continue; + for (const auto& asset : release.at("assets")) + if (asset.value("name", "") == "launcher.json") return release; + } + fail("None of the newest releases has a launcher.json asset"); +} +} // namespace + +std::optional fetch_config(bool prereleases) { std::array local{}; const auto local_length = GetEnvironmentVariableW(L"RESKATE_LAUNCHER_CONFIG_FILE", local.data(), static_cast(local.size())); if (local_length && local_length < local.size()) { @@ -346,28 +369,29 @@ std::optional fetch_config() { // Public releases: the newest release carries launcher.json and the files it pins. Only // this lookup uses GitHub's rate-limited API; the files come from the release's download links. trace::Scope timing("launcher.update_check", "http.client"); - const auto latest = std::wstring(L"https://api.github.com/repos/") + launcher_release_repo + L"/releases/latest"; - logging::write(logging::Level::info, logging::Channel::launcher, L"Checking GitHub releases: " + std::wstring(launcher_release_repo)); - std::string text; - http_get(latest, 4 * 1024 * 1024, 8000, [&](const char* data, DWORD count) { text.append(data, count); }); - const auto release = Json::parse(text); + logging::write(logging::Level::info, logging::Channel::launcher, L"Checking GitHub releases: " + std::wstring(launcher_release_repo) + + (prereleases ? L" (pre-releases included)" : L"")); + const auto release = release_to_use(prereleases); std::map> assets; if (release.contains("assets")) for (const auto& asset : release.at("assets")) assets[asset.value("name", "")] = widen(asset.value("browser_download_url", "")); const auto manifest = assets.find("launcher.json"); - if (manifest == assets.end()) fail("The latest release has no launcher.json asset"); + if (manifest == assets.end()) fail("The release has no launcher.json asset"); std::string json; http_get(manifest->second, max_config_bytes, 8000, [&](const char* data, DWORD count) { json.append(data, count); }); auto config = parse_config(json); for (auto* file : {&config.launcher, &config.runtime, &config.depot_downloader, &config.server}) { if (!file->url.starts_with(L"asset:")) continue; const auto found = assets.find(utf8(std::wstring_view(file->url).substr(6))); - if (found == assets.end()) fail("The latest release is missing asset " + utf8(file->url.substr(6))); + if (found == assets.end()) fail("The release is missing asset " + utf8(file->url.substr(6))); file->url = found->second; } + config.tag = release.value("tag_name", ""); + config.prerelease = release.value("prerelease", false); logging::log(logging::Level::info, logging::Channel::launcher, - "Release {}: launcher {}, runtime {}, game depot {} manifest {}.", release.value("tag_name", "?"), + "{} {}: launcher {}, runtime {}, game depot {} manifest {}.", config.prerelease ? "Pre-release" : "Release", + config.tag.empty() ? "?" : config.tag, config.launcher.version.empty() ? "-" : config.launcher.version, config.runtime.version.empty() ? "-" : config.runtime.version, config.game.depot_id, config.game.manifest_id); @@ -480,18 +504,16 @@ std::vector parse_release_feed(std::string_view feed) { return notes; } -ReleaseNote fetch_release_note() { +ReleaseNote fetch_release_note(bool prereleases) { // The description as it was written, markdown, from GitHub's API. try { - const auto url = std::wstring(L"https://api.github.com/repos/") + launcher_release_repo + L"/releases/latest"; - std::string text; - http_get(url, 4 * 1024 * 1024, 8000, [&](const char* data, DWORD count) { text.append(data, count); }); - const auto release = Json::parse(text); + const auto release = release_to_use(prereleases); // (A release made without a title or a description has null there.) const auto field = [&](std::string_view key) { return release.contains(key) && release.at(key).is_string() ? release.at(key).string() : std::string(); }; - ReleaseNote note{field("tag_name"), field("name"), field("published_at").substr(0, 10), field("body")}; + ReleaseNote note{field("tag_name"), field("name"), field("published_at").substr(0, 10), field("body"), + release.value("prerelease", false)}; if (!note.tag.empty() && note.notes.find_first_not_of(" \t\r\n") != std::string::npos) return note; } catch (const std::exception& exception) { logging::log(logging::Level::info, logging::Channel::launcher, diff --git a/Launcher/updater.h b/Launcher/updater.h index f54257a..7352294 100644 --- a/Launcher/updater.h +++ b/Launcher/updater.h @@ -39,23 +39,30 @@ struct Config { // it: a server whose own exe differs is out of date. Empty when not published. RemoteFile server; std::string server_exe_sha256; + // The release it came from, and whether GitHub marks it a pre-release. + std::string tag; + bool prerelease{}; }; // Release builds replace ReSkateLauncher.exe and ReSkate.dll; local builds only report. bool binary_updates_enabled() noexcept; Config parse_config(std::string_view text); // Returns nothing when offline or the config is invalid; the reason is logged. -std::optional fetch_config(); +// `prereleases`: the newest release that carries a launcher.json, pre-release or not (the +// launcher's "Test pre-releases" setting); otherwise GitHub's latest full release. +std::optional fetch_config(bool prereleases = false); // One GitHub release, for the launcher's changelogs: its patch notes are what its page on // GitHub shows, the release's description or, without one, its commit's message. struct ReleaseNote { std::string tag, title, date, notes; // date: YYYY-MM-DD; notes: markdown + bool prerelease{}; }; // The releases page's Atom feed (/releases.atom), newest first as it comes. std::vector parse_release_feed(std::string_view feed); -// The newest release of the repo the launcher updates from. Throws when GitHub cannot be reached. -ReleaseNote fetch_release_note(); +// The newest release of the repo the launcher updates from (with `prereleases`, the newest +// pre-release when it is newer). Throws when GitHub cannot be reached. +ReleaseNote fetch_release_note(bool prereleases = false); // That repo's releases page on GitHub. std::string releases_page();