From 4f1f5a882ea80ddb93551df8436769d00cc1bf54 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sat, 17 Oct 2026 18:25:22 -0500 Subject: [PATCH] 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. --- Launcher/gui_home.cpp | 7 +- Launcher/gui_internal.h | 49 ++++++++++ Launcher/gui_launcher.cpp | 4 + Launcher/gui_legal.cpp | 195 ++++++++++++++++++++++++++++++++++++++ Launcher/gui_settings.cpp | 5 + cmake/Apps.cmake | 2 +- 6 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 Launcher/gui_legal.cpp diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 63a11b87..9f279ba0 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -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; diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 012ec6fd..8fc5aa91 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -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 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/): +// 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 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 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 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. diff --git a/Launcher/gui_launcher.cpp b/Launcher/gui_launcher.cpp index 93978a55..85eb632c 100644 --- a/Launcher/gui_launcher.cpp +++ b/Launcher/gui_launcher.cpp @@ -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'; } diff --git a/Launcher/gui_legal.cpp b/Launcher/gui_legal.cpp new file mode 100644 index 00000000..3f224265 --- /dev/null +++ b/Launcher/gui_legal.cpp @@ -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 +#include + +// 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/), 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(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(); + 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(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(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 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(at)] = document.at("version").get(); + } + } 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 diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 467d7bd5..01bf3ac6 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -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(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 56757229..99671963 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -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