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.
This commit is contained in:
AyeZeeBB committed 2026-10-15 14:23:06 -05:00
1 parent 94a616feb9
commit 310922556c
9 files changed
+77 -25

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& 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.
+6 -3
View File
@@ -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);
+4 -1
View File
@@ -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);
+5
View File
@@ -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();
+7 -2
View File
@@ -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)));
}
+5
View File
@@ -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();
+1 -1
View File
@@ -80,7 +80,7 @@ void run_headless(const app::Session& session, std::vector<std::wstring> 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))
+37 -15
View File
@@ -325,7 +325,30 @@ Config parse_config(std::string_view text) {
return config;
}
std::optional<Config> 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<Config> fetch_config(bool prereleases) {
std::array<wchar_t, 32768> local{};
const auto local_length = GetEnvironmentVariableW(L"RESKATE_LAUNCHER_CONFIG_FILE", local.data(), static_cast<DWORD>(local.size()));
if (local_length && local_length < local.size()) {
@@ -346,28 +369,29 @@ std::optional<Config> 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<std::string, std::wstring, std::less<>> 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<ReleaseNote> 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,
+10 -3
View File
@@ -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<Config> 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<Config> 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 (<repo>/releases.atom), newest first as it comes.
std::vector<ReleaseNote> 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();