Launcher: the privacy policy and terms of service, and an "I agree" before playing

Both documents come from the ReSkate backend (GET /api/v1/legal/privacy and /terms, edited on its
Legal page), so a change reaches players without a release. Settings has a button for each, opening
a panel like the changelogs': title, version and date, the Markdown drawn as the README viewer draws
it, and a switch to the other one.

Each launch looks up the current versions (GET /api/v1/legal) in the background. One newer than
the player agreed to, or none agreed to yet, puts up an agreement over the home screen: a short
summary, a way into each document, QUIT and I AGREE, which saves the versions agreed to
(accepted_privacy_version, accepted_terms_version in the launcher's settings). A backend that
cannot be reached holds nobody up. Launches that skip the window (--no-gui, Discord joins) are
not asked.
This commit is contained in:
AyeZeeBB committed 2026-10-17 18:25:22 -05:00
1 parent d15e51e558
commit 4f1f5a882e
6 files changed
+260 -2

No files matched your search

+6 -1
View File
@@ -407,6 +407,8 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane
// MODS tile can say when installed mods have updates.
collect_listing(mods_panel);
refresh_listing(mods_panel, time);
// Whether the privacy policy or terms changed since the player agreed (looked up once).
check_legal_versions(ui, launcher.settings());
// A mod dropped on the window opens the Mods panel and installs it.
{
std::lock_guard lock(g_dropped_mutex);
@@ -429,7 +431,7 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane
ui.steam_offline = !launcher.settings().offline && !launcher_app::steam_signed_in();
}
}
const bool modal = ui.settings || ui.changelog || ui.mods || ui.sign_in || qr_open || state.prompt.has_value() ||
const bool modal = ui.settings || ui.changelog || ui.legal.open >= 0 || ui.legal.agreement || ui.mods || ui.sign_in || qr_open || state.prompt.has_value() ||
ui.steam_offline || state.phase == Phase::mods_broken || ui.mods_update_prompt;
ImGui::SetNextWindowPos(ImVec2(0, 0));
@@ -563,6 +565,9 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane
if (state.prompt) prompt_window(launcher, fonts, size, *state.prompt, ui);
else if (qr_open) qr_window(launcher, fonts, size, state.qr);
else if (ui.sign_in) sign_in_window(launcher, fonts, size, ui);
// Opened from Settings or the agreement, and back to it when closed.
else if (ui.legal.open >= 0) legal_window(fonts, size, ui);
else if (ui.legal.agreement) legal_agreement_window(launcher, fonts, size, ui, window);
else if (ui.settings) settings_window(launcher, fonts, size, ui, window);
else if (ui.changelog) {
ui.notes.prereleases = launcher.settings().prereleases;
+49
View File
@@ -210,6 +210,8 @@ struct Settings {
std::string steam_username; // never the password
bool steam_remember{true};
bool steam_prefer_code{};
// The versions of the privacy policy and the terms of service the player agreed to (0: none).
std::uint64_t accepted_privacy{}, accepted_terms{};
};
inline constexpr std::array<const char*, 7> log_levels{"trace", "debug", "info", "warning", "error", "critical", "off"};
@@ -348,6 +350,45 @@ struct Changelog {
}
};
// The privacy policy and terms of service, from the ReSkate backend (GET /api/v1/legal/<kind>):
// each fetched on a worker the first time it is shown and kept while the launcher runs.
// Everything but the worker's hand-over belongs to the UI thread.
struct Legal {
enum Kind { privacy, terms, count };
struct Document {
std::string title, updated, markdown; // updated: YYYY-MM-DD
std::uint64_t version{};
};
struct Slot {
std::thread worker;
std::atomic<bool> loading{};
bool arrived{}; // worker -> UI, under the mutex
Document incoming;
std::string incoming_error;
bool loaded{};
std::string error; // why the last fetch failed
Document document;
thunderstore::Readme text;
};
std::mutex mutex;
std::array<Slot, count> slots;
int open{-1}; // the Kind shown, or -1 when the window is closed
// The versions the backend has now (GET /api/v1/legal), looked up once a launch. One newer
// than the player agreed to (Settings::accepted_*) puts up the agreement before anything else.
std::thread versions_worker;
bool versions_started{}, versions_arrived{}; // arrived: worker -> UI, under the mutex
std::array<std::uint64_t, count> current{};
bool agreement{}; // the agreement window is up
~Legal() {
for (auto& slot : slots)
if (slot.worker.joinable()) slot.worker.join();
if (versions_worker.joinable()) versions_worker.join();
}
};
// Settings > GRAPHICS, AUDIO, CAMERA, CONTROLS, REPLAY: skate.'s own settings, read from its
// save each time Settings opens (game_settings.h).
struct GamePages {
@@ -364,6 +405,7 @@ struct Ui {
GamePages game;
bool changelog{};
Changelog notes;
Legal legal;
int settings_tab{}; // GAME, DISPLAY, KEYS, ADVANCED, then the game's own pages
int binding{}; // 1 = menu key, 2 = console key, while waiting for a press
std::string key_error;
@@ -614,6 +656,13 @@ void qr_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, const std::v
void steam_offline_window(const Fonts& fonts, ImVec2 size, Ui& ui);
// What the newest release changed: its notes on GitHub.
void changelog_window(const Fonts& fonts, ImVec2 size, Ui& ui);
// The privacy policy or the terms of service (ui.legal.open), with a switch between them.
void legal_window(const Fonts& fonts, ImVec2 size, Ui& ui);
// Each frame: the backend's versions of both, looked up once; a newer one than the player
// agreed to raises ui.legal.agreement.
void check_legal_versions(Ui& ui, const Settings& settings);
// "I agree" to the current versions, or quit.
void legal_agreement_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window);
void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window);
void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel, HWND window);
// Shown instead of launching when the merge left mods out.
+4
View File
@@ -71,6 +71,8 @@ Settings load_settings(const fs::path& path) {
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);
settings.accepted_privacy = json.value("accepted_privacy_version", settings.accepted_privacy);
settings.accepted_terms = json.value("accepted_terms_version", settings.accepted_terms);
} catch (const std::exception& exception) {
logging::log(logging::Level::warning, logging::Channel::launcher, "Ignoring launcher settings: {}", exception.what());
}
@@ -98,6 +100,8 @@ void save_settings(const fs::path& path, const Settings& settings) {
json["steam_username"] = settings.steam_username;
json["steam_remember"] = settings.steam_remember;
json["steam_prefer_code"] = settings.steam_prefer_code;
json["accepted_privacy_version"] = settings.accepted_privacy;
json["accepted_terms_version"] = settings.accepted_terms;
std::ofstream output(path, std::ios::binary | std::ios::trunc);
output << json.dump(2) << '\n';
}
+195
View File
@@ -0,0 +1,195 @@
#include "gui_internal.h"
#include "text_encoding.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include <algorithm>
#include <format>
// The LEGAL panel: the privacy policy and terms of service as the ReSkate backend has them now
// (GET https://api.reskate.dev/api/v1/legal/<kind>), so a change reaches players without a release.
namespace dingosdk::launcher_gui::detail {
namespace {
constexpr const char* kinds[Legal::count]{"privacy", "terms"};
constexpr const char* names[Legal::count]{"PRIVACY POLICY", "TERMS OF SERVICE"};
constexpr std::uint64_t answer_limit = 256 * 1024;
constexpr int timeout_ms = 15000;
void start_fetch(Legal& legal, int kind) {
auto& slot = legal.slots[static_cast<std::size_t>(kind)];
if (slot.loading.exchange(true)) return;
if (slot.worker.joinable()) slot.worker.join();
slot.error.clear();
slot.worker = std::thread([&legal, &slot, kind] {
Legal::Document document;
std::string error;
try {
std::string body;
const auto url = std::string("https://api.reskate.dev/api/v1/legal/") + kinds[kind];
launcher_update::http_stream(launcher_text::wide(url), answer_limit, timeout_ms,
[&](const char* data, DWORD size) { body.append(data, size); });
const auto root = Json::parse(body);
document.title = root.at("title").string();
document.markdown = root.at("markdown").string();
document.version = root.at("version").get<std::uint64_t>();
document.updated = root.at("updated_at").string().substr(0, 10);
if (document.markdown.empty()) throw std::runtime_error("the document came back empty");
} catch (const std::exception& failure) {
error = failure.what();
logging::write(logging::Level::warning, logging::Channel::launcher,
std::format("Could not get the {}: {}", kinds[kind], error));
}
std::lock_guard lock(legal.mutex);
slot.incoming = std::move(document);
slot.incoming_error = std::move(error);
slot.arrived = true;
slot.loading = false;
});
}
} // namespace
void legal_window(const Fonts& fonts, ImVec2 size, Ui& ui) {
auto& legal = ui.legal;
const int kind = legal.open;
auto& slot = legal.slots[static_cast<std::size_t>(kind)];
{
std::lock_guard lock(legal.mutex);
for (auto& each : legal.slots)
if (each.arrived) {
each.arrived = false;
each.error = std::move(each.incoming_error);
if (each.error.empty()) {
each.document = std::move(each.incoming);
each.text = thunderstore::readme_lines(each.document.markdown, 2000);
each.loaded = true;
}
each.incoming = {};
}
}
if (!slot.loaded && !slot.loading && slot.error.empty()) start_fetch(legal, kind);
const auto panel = begin_panel("##legal_panel", size, ImVec2(S(880), S(680)));
panel_title(fonts, names[kind]);
const float footer = ImGui::GetFrameHeight() + S(24) + S(12);
ImGui::BeginChild("##legal_body", ImVec2(0, panel.y - ImGui::GetCursorPosY() - footer), ImGuiChildFlags_NavFlattened);
if (slot.loading && !slot.loaded) {
ImGui::TextDisabled("Loading...");
} else if (!slot.loaded) {
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("Could not load it: %s", slot.error.c_str());
ImGui::PopTextWrapPos();
if (ImGui::Button("Try again", ImVec2(S(120), 0))) start_fetch(legal, kind);
} else {
const auto& document = slot.document;
ImGui::PushFont(fonts.heading);
ImGui::TextUnformatted(document.title.c_str());
ImGui::PopFont();
ImGui::TextDisabled("Version %llu, updated %s", static_cast<unsigned long long>(document.version), document.updated.c_str());
ImGui::Dummy(ImVec2(0, S(6)));
draw_readme(fonts, slot.text);
}
ImGui::EndChild();
ImGui::SetCursorPosY(panel.y - S(24) - ImGui::GetFrameHeight());
const int other = kind == Legal::privacy ? Legal::terms : Legal::privacy;
if (ImGui::Button(kind == Legal::privacy ? "Terms of Service" : "Privacy Policy", ImVec2(S(180), 0))) legal.open = other;
ImGui::SameLine();
ImGui::SetCursorPosX(panel.x - S(28) - S(110));
const bool close = ImGui::Button("CLOSE", ImVec2(S(110), 0));
default_focus();
if (close || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) legal.open = -1;
ImGui::End();
}
void check_legal_versions(Ui& ui, const Settings& settings) {
auto& legal = ui.legal;
if (!legal.versions_started) {
legal.versions_started = true;
legal.versions_worker = std::thread([&legal] {
std::array<std::uint64_t, Legal::count> found{};
try {
std::string body;
launcher_update::http_stream(L"https://api.reskate.dev/api/v1/legal", answer_limit, timeout_ms,
[&](const char* data, DWORD size) { body.append(data, size); });
const auto root = Json::parse(body);
const auto& documents = root.at("documents");
for (std::size_t index = 0; index < documents.size(); ++index) {
const auto& document = documents.at(index);
const auto kind = document.at("kind").string();
for (int at = 0; at < Legal::count; ++at)
if (kind == kinds[at]) found[static_cast<std::size_t>(at)] = document.at("version").get<std::uint64_t>();
}
} catch (const std::exception& failure) {
// Nothing to show: the launcher does not hold anyone up over it.
logging::write(logging::Level::warning, logging::Channel::launcher,
std::format("Could not check the privacy policy and terms: {}", failure.what()));
}
std::lock_guard lock(legal.mutex);
legal.current = found;
legal.versions_arrived = true;
});
}
std::lock_guard lock(legal.mutex);
if (!legal.versions_arrived) return;
legal.versions_arrived = false;
const auto& current = legal.current;
legal.agreement = current[Legal::privacy] > settings.accepted_privacy || current[Legal::terms] > settings.accepted_terms;
if (legal.agreement)
logging::write(logging::Level::info, logging::Channel::launcher,
std::format("Privacy policy {} and terms {} to agree to (agreed: {} and {}).", current[Legal::privacy],
current[Legal::terms], settings.accepted_privacy, settings.accepted_terms));
}
void legal_agreement_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, HWND window) {
auto& legal = ui.legal;
auto& settings = launcher.settings();
const bool first = !settings.accepted_privacy && !settings.accepted_terms;
const auto panel = begin_panel("##legal_agreement", size, ImVec2(S(640), S(470)));
panel_title(fonts, first ? "BEFORE YOU PLAY" : "UPDATED TERMS");
ImGui::PushTextWrapPos(0);
if (first) {
ImGui::TextUnformatted("ReSkate has terms of service and a privacy policy. Read them, and agree to keep using ReSkate.");
} else {
std::string changed;
if (legal.current[Legal::terms] > settings.accepted_terms) changed = "terms of service";
if (legal.current[Legal::privacy] > settings.accepted_privacy)
changed += changed.empty() ? "privacy policy" : " and the privacy policy";
ImGui::TextUnformatted(std::format("The {} changed since you last agreed. Read what changed, and agree to keep using "
"ReSkate.", changed).c_str());
}
ImGui::Dummy(ImVec2(0, S(8)));
ImGui::TextDisabled("In short:");
for (const char* line : {"Play fair online and respect other players.",
"Don't unlock or copy cosmetics skate. sells.",
"Crash reports go to the ReSkate team unless you turn them off in Settings. Nothing in them is "
"meant to identify you.",
"ReSkate is a free fan project, not made by EA, and comes with no warranty."})
ImGui::BulletText("%s", line);
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, S(10)));
if (ImGui::Button("Read the Terms of Service", ImVec2(S(260), 0))) legal.open = Legal::terms;
ImGui::SameLine();
if (ImGui::Button("Read the Privacy Policy", ImVec2(S(260), 0))) legal.open = Legal::privacy;
ImGui::SetCursorPosY(panel.y - S(24) - ImGui::GetFrameHeight());
if (ImGui::Button("QUIT", ImVec2(S(110), 0))) PostMessageW(window, WM_CLOSE, 0, 0);
ImGui::SameLine();
ImGui::SetCursorPosX(panel.x - S(28) - S(160));
push_primary_button();
const bool agree = ImGui::Button("I AGREE", ImVec2(S(160), 0));
pop_primary_button();
default_focus();
if (agree) {
settings.accepted_privacy = (std::max)(settings.accepted_privacy, legal.current[Legal::privacy]);
settings.accepted_terms = (std::max)(settings.accepted_terms, legal.current[Legal::terms]);
launcher.save();
legal.agreement = false;
logging::write(logging::Level::info, logging::Channel::launcher,
std::format("Agreed to privacy policy {} and terms {}.", settings.accepted_privacy, settings.accepted_terms));
}
ImGui::End();
}
} // namespace dingosdk::launcher_gui::detail
+5
View File
@@ -473,6 +473,11 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui
"about your account is included. Needs crash reports on; the launcher picks a change up when it "
"next starts.",
settings.performance_timings);
ImGui::Spacing();
if (ImGui::Button("Privacy Policy")) ui.legal.open = Legal::privacy;
ImGui::SameLine();
if (ImGui::Button("Terms of Service")) ui.legal.open = Legal::terms;
setting_note("What ReSkate collects and sends, and the rules for using it and its servers.");
break;
}
ImGui::EndChild();
+1 -1
View File
@@ -2,7 +2,7 @@ if(WIN32)
add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp
Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp
Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp Launcher/gui_mods_profiles.cpp
Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/game_settings.cpp
Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/gui_legal.cpp Launcher/game_settings.cpp
Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/mod_profiles.cpp Launcher/thunderstore.cpp Launcher/official_mods.cpp
Launcher/problem.h)
target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs