feat: added ability to decline TOS (#751)

* feat: added ability to decline TOS; added blockades for hosts while TOS is declined

* fix: allowed datastorage-v2, meta.polyfrost.org and status.polyfrost.org

* chore: changed typography

* chore: changed TOS decline warning to a modal
This commit is contained in:
Maciek 2026-09-01 11:31:47 +02:00 • committed by GitHub
parent 815b78e227
commit 606a15905d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 359 additions and 22 deletions

1
Cargo.lock generated
View file

@ -4558,6 +4558,7 @@ dependencies = [
"serde",
"strum",
"thiserror 2.0.18",
"url",
]
[[package]]

View file

@ -244,9 +244,38 @@ impl Actions {
}
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {
if let Some(updated) = self.mutate_settings(|settings| {
let mut was_declined = false;
let Some(updated) = self.mutate_settings(|settings| {
was_declined = settings.declined_tos;
settings.accepted_tos_version = terms_version;
settings.accepted_privacy_version = privacy_version;
settings.declined_tos = false;
}) else {
return;
};
self.persist(updated);
if was_declined {
self.notify("Restart to finish")
.body(
"Thanks. OneClient reconnects to Polyfrost services the next time you start \
it.",
)
.icon(IconType::RefreshCw01)
.send();
}
}
pub fn decline_tos(&self) {
oneclient_common::consent::decline();
if let Some(updated) = self.mutate_settings(|settings| {
settings.declined_tos = true;
settings.accepted_tos_version = 0;
settings.accepted_privacy_version = 0;
settings.seen_onboarding = true;
}) {
self.persist(updated);
}

View file

@ -78,6 +78,8 @@ fn main() {
let settings = rt.block_on(oneclient_core::settings::store::load_settings(None));
oneclient_common::consent::init(settings.declined_tos);
if settings.log_debug {
oneclient_core::logger::init_debug()
} else {

View file

@ -1,9 +1,11 @@
use freya::prelude::*;
use freya::router::RouterContext;
use super::settings_page;
use crate::components::{IconType, link_button, toggle};
use crate::components::{Button, IconType, link_button, toggle};
use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot};
use crate::platform;
use crate::routes::Route;
use crate::view::app::settings::{section_header, settings_row};
#[derive(PartialEq)]
@ -45,6 +47,20 @@ impl Component for SettingsLauncher {
let folder = data_dir.clone();
let open_folder = link_button().on_press(move |_| platform::open_url(&folder));
// The only way back for someone who declined during onboarding
let consent_summary = if settings.declined_tos {
"Declined. Poly+ and crash reporting stay off until you accept and restart OneClient."
} else {
"Accepted. Review them again at any time."
};
let review_terms = Button::new()
.secondary()
.small()
.on_press(|_| {
let _ = RouterContext::get().replace(Route::OnboardingTerms {});
})
.text("Review");
settings_page()
.child(section_header("GENERAL"))
.child(settings_row(
@ -59,6 +75,12 @@ impl Component for SettingsLauncher {
"Send anonymous crash and error reports to help fix bugs. Applies on restart.",
toggle(crash_reporting),
))
.child(settings_row(
IconType::File02,
"Terms & Privacy",
consent_summary,
review_terms,
))
.child(section_header("FOLDERS AND FILES"))
.child(settings_row(
IconType::Folder,

View file

@ -2,7 +2,9 @@ use freya::prelude::*;
use freya::query::UseQuery;
use freya::router::RouterContext;
use crate::components::{Button, Icon, IconType, ScrollArea, Segment, SegmentedControl, toggle};
use crate::components::{
Button, Icon, IconType, OverlayPopup, ScrollArea, Segment, SegmentedControl, toggle,
};
use crate::hooks::{
TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch,
use_migration, use_settings_snapshot, use_terms,
@ -13,6 +15,8 @@ use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::onboarding::{onboarding_illustration, onboarding_page, step_heading};
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
#[derive(Clone, Copy, PartialEq)]
enum LegalTab {
Terms,
@ -35,6 +39,7 @@ impl Component for OnboardingTerms {
let accepted = use_state(|| false);
let tab = use_state(|| LegalTab::Terms);
let confirming_decline = use_state(|| false);
let returning = settings.seen_onboarding;
let next = if returning {
@ -88,14 +93,33 @@ impl Component for OnboardingTerms {
.child(accept_row(accepted))
.into_element();
onboarding_page(
onboarding_illustration(IconType::File02),
content,
terms_nav(back, *accepted.read() && !loading, move || {
dispatch.accept_tos(terms_version, privacy_version);
let accept_dispatch = dispatch.clone();
let nav = terms_nav(back, !loading, move || {
if *accepted.read() {
accept_dispatch.accept_tos(terms_version, privacy_version);
let _ = RouterContext::get().replace(next.clone());
}),
)
} else {
let mut confirming = confirming_decline;
confirming.set(true);
}
});
let modal = confirming_decline.read().then(|| {
decline_modal(confirming_decline, move || {
dispatch.decline_tos();
let _ = RouterContext::get().replace(Route::Home {});
})
});
rect()
.width(Size::fill())
.height(Size::fill())
.child(onboarding_page(
onboarding_illustration(IconType::File02),
content,
nav,
))
.maybe_child(modal)
}
}
@ -234,7 +258,7 @@ fn external_link_button(text: &'static str, url: String) -> impl IntoElement {
.child(Icon::new(IconType::LinkExternal01).size(14.))
}
fn accept_row(accepted: State<bool>) -> impl IntoElement {
fn accept_row(accepted: State<bool>) -> Element {
rect()
.horizontal()
.width(Size::fill())
@ -259,7 +283,7 @@ fn accept_row(accepted: State<bool>) -> impl IntoElement {
)
.child(
label()
.text("Required to use OneClient.")
.text("Required for world hosting, socials, nametag indicator, and more.")
.font_size(11.)
.color(colors::fg_secondary()),
),
@ -272,7 +296,7 @@ fn terms_nav(
back: Option<Route>,
next_enabled: bool,
on_next: impl FnMut() + 'static,
) -> impl IntoElement {
) -> Element {
let mut on_next = on_next;
rect()
.horizontal()
@ -302,3 +326,89 @@ fn terms_nav(
)
.into_element()
}
fn decline_modal(confirming: State<bool>, on_confirm: impl FnMut() + 'static) -> Element {
let mut on_confirm = on_confirm;
let mut confirming = confirming;
OverlayPopup::new()
.on_close(move |_| confirming.set(false))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(460.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::danger()))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::code_warn()),
)
.child(
label()
.text("Continue without accepting?")
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(
label()
.text(
"This will disable access to world hosting, \
all social features, the OneClient nametag indicator, \
and more.",
)
.font_size(12.)
.width(Size::fill())
.max_lines(4)
.color(colors::fg_secondary()),
)
.child(
label()
.text(
"You can accept later under Settings > Launcher. That takes \
effect after a restart.",
)
.font_size(11.)
.width(Size::fill())
.max_lines(3)
.color(colors::fg_secondary().with_a(180)),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| confirming.set(false))
.text("Close"),
)
.child(
Button::new()
.danger()
.on_press(move |_| on_confirm())
.text("Decline"),
),
),
),
)
.into_element()
}

View file

@ -100,7 +100,9 @@ impl Component for Startup {
let stale = settings.settings.accepted_tos_version < required_terms
|| settings.settings.accepted_privacy_version < required_privacy;
let destination = if !settings.settings.seen_onboarding {
let destination = if settings.settings.declined_tos {
Route::Home {}
} else if !settings.settings.seen_onboarding {
Route::OnboardingWelcome {}
} else if stale {
Route::OnboardingTerms {}

View file

@ -21,3 +21,5 @@ directories.workspace = true
serde.workspace = true
strum.workspace = true
thiserror.workspace = true
url.workspace = true

View file

@ -0,0 +1,127 @@
use std::sync::atomic::{AtomicBool, Ordering};
/// Hosts that are out of bounds for someone who didn't accept TOS
const GATED_DOMAINS: &[&str] = &["polyfrost.org", "sentry.io"];
/// Hosts that are allowed even if someone didn't accept TOS
const ALLOWED_HOSTS: &[&str] = &[
"data-v2.polyfrost.org",
"meta.polyfrost.org",
"status.polyfrost.org",
];
static DECLINED: AtomicBool = AtomicBool::new(false);
pub fn init(declined: bool) {
DECLINED.store(declined, Ordering::SeqCst);
}
pub fn decline() {
DECLINED.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn declined() -> bool {
DECLINED.load(Ordering::Relaxed)
}
#[must_use]
pub fn blocks_url(url: &str) -> bool {
declined() && is_gated_url(url)
}
#[must_use]
pub fn is_gated_url(url: &str) -> bool {
url::Url::parse(url)
.ok()
.and_then(|parsed| parsed.host_str().map(is_gated_host))
.unwrap_or(true)
}
#[must_use]
pub fn is_gated_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
if ALLOWED_HOSTS.contains(&host.as_str()) {
return false;
}
GATED_DOMAINS
.iter()
.any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gates_polyfrost_services_and_the_crash_reporter() {
for host in [
"polyfrost.org",
"plus.polyfrost.org",
"PLUS.POLYFROST.ORG",
"plus.polyfrost.org.",
"o4511714343124992.ingest.us.sentry.io",
] {
assert!(is_gated_host(host), "expected {host} to be gated");
}
}
#[test]
fn leaves_everything_else_alone() {
for host in [
"api.modrinth.com",
"api.curseforge.com",
"sessionserver.mojang.com",
"api.minecraftservices.com",
"www.gstatic.com",
"github.com",
"notpolyfrost.org",
"polyfrost.org.attacker.com",
"evil-sentry.io.attacker.com",
] {
assert!(!is_gated_host(host), "unexpected gate on {host}");
}
}
#[test]
fn lets_the_exempt_hosts_through() {
for url in [
"https://data-v2.polyfrost.org/oneclient/bundles/metadata.json",
"https://data-v2.polyfrost.org/oneclient/versions/art/Tricky_Trials.jpg",
"https://data-v2.polyfrost.org/oneclient/tos.json",
"https://meta.polyfrost.org/fabric/v2/manifest.json",
"https://status.polyfrost.org/index.json",
"https://META.POLYFROST.ORG./minecraft/v1/manifest.json",
] {
assert!(!is_gated_url(url), "unexpected gate on {url}");
}
}
#[test]
fn the_exemption_is_the_exact_host_and_nothing_near_it() {
for host in [
"plus.polyfrost.org",
"evil.data-v2.polyfrost.org",
"meta.polyfrost.org.evil.polyfrost.org",
"status.polyfrost.org.evil.polyfrost.org",
] {
assert!(is_gated_host(host), "expected {host} to stay gated");
}
}
#[test]
fn gates_urls_by_their_host() {
assert!(is_gated_url("https://plus.polyfrost.org/account/login"));
assert!(is_gated_url("wss://plus.polyfrost.org/websocket"));
assert!(!is_gated_url("https://api.modrinth.com/v2/search"));
}
#[test]
fn unparseable_urls_fail_closed() {
assert!(is_gated_url("not a url"));
assert!(is_gated_url(""));
assert!(is_gated_url("file:///etc/hosts"));
}
}

View file

@ -1,6 +1,7 @@
//! Leaf crate must never depend on the database network stack or any
//! launcher subsystem or the dependency graph cycles back into a monolith
pub mod consent;
pub mod constants;
pub mod domain;
pub mod os_ext;

View file

@ -42,6 +42,11 @@ fn drop_opted_out_breadcrumbs(breadcrumb: Breadcrumb) -> Option<Breadcrumb> {
}
pub fn init(enabled: bool) -> Option<ClientInitGuard> {
if oneclient_common::consent::declined() {
tracing::debug!("crash reporting disabled: terms and privacy policy declined");
return None;
}
if !enabled {
tracing::debug!("crash reporting disabled by settings");
return None;

View file

@ -38,6 +38,7 @@ pub struct LauncherSettings {
pub seen_onboarding: bool,
pub accepted_tos_version: u32,
pub accepted_privacy_version: u32,
pub declined_tos: bool,
pub seen_versions: Vec<String>,
pub seen_changelog_version: Option<String>,
pub modrinth_api_key: Option<String>,
@ -74,6 +75,7 @@ impl Default for LauncherSettings {
seen_onboarding: false,
accepted_tos_version: 0,
accepted_privacy_version: 0,
declined_tos: false,
seen_versions: Vec::new(),
seen_changelog_version: None,
modrinth_api_key: None,

View file

@ -73,6 +73,8 @@ impl LauncherState {
};
let settings = store::load_settings(Some(&services.events)).await;
oneclient_common::consent::init(settings.declined_tos);
services
.requester
.set_config(crate::settings::net_config(&settings));

View file

@ -63,6 +63,12 @@ pub enum RequestError {
actual: u64,
},
#[error(
"OneClient did not contact {url} because the Terms of Service and Privacy Policy were \
declined. Accept them in Settings to turn Polyfrost services back on."
)]
ConsentRequired { url: String },
#[error("Invalid URL: {0}")]
UrlParseError(#[from] url::ParseError),

View file

@ -13,9 +13,6 @@ use crate::response::{ResponseExt, ResponseOptions};
const MAX_THROTTLE_RETRIES: u32 = 6;
/// Backstop against runaway fan-out not the download throttle
/// Must stay above the sum of per-phase caller concurrency or it becomes the
/// bottleneck
const MAX_INFLIGHT_REQUESTS: usize = 64;
fn retry_after(response: &Response) -> Option<std::time::Duration> {
@ -134,6 +131,12 @@ impl RequestClient {
let mut retries = 0;
let mut throttle_retries = 0u32;
if oneclient_common::consent::blocks_url(request.request.url().as_str()) {
let url = request.request.url().to_string();
tracing::debug!(%url, "request withheld: terms and privacy policy declined");
return Err(RequestError::ConsentRequired { url });
}
apply_curseforge_auth(&mut request.request, &self.config.load().curseforge_api_key)?;
let cloned_backup = request.request.try_clone();

View file

@ -35,6 +35,9 @@ pub enum PlusError {
body: String,
},
#[error("Poly+ is off because the Terms of Service and Privacy Policy were declined")]
ConsentRequired,
#[error(transparent)]
Request(#[from] reqwest::Error),
@ -67,6 +70,11 @@ pub fn start(auth: Arc<oneclient_auth::AuthService>) {
return;
}
if plus_blocked() {
tracing::info!("[plus] playtime reporting stays off: terms and privacy policy declined");
return;
}
tokio::spawn(async move {
let client = match build_client() {
Ok(client) => client,
@ -79,6 +87,15 @@ pub fn start(auth: Arc<oneclient_auth::AuthService>) {
let mut delay = RECONNECT_DELAY;
loop {
// Consent can only be withdrawn while the launcher is up, never
// restored, so ending the task is final rather than a pause
if plus_blocked() {
tracing::info!(
"[plus] ending playtime reporting: terms and privacy policy declined"
);
return;
}
delay = match session(&auth, &client).await {
Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY,
Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY),
@ -89,6 +106,10 @@ pub fn start(auth: Arc<oneclient_auth::AuthService>) {
});
}
fn plus_blocked() -> bool {
oneclient_common::consent::blocks_url(base_url())
}
fn build_client() -> Result<reqwest::Client, reqwest::Error> {
let builder = reqwest::Client::builder()
.tcp_keepalive(Some(Duration::from_secs(15)))
@ -165,8 +186,8 @@ async fn run_session(
tracing::info!("[plus] playtime websocket connected for {}", account.id);
let account_id = account.id;
pump(&mut websocket, PING_INTERVAL, account_id, || {
account_changed(auth, account_id)
pump(&mut websocket, PING_INTERVAL, account_id, || async move {
plus_blocked() || account_changed(auth, account_id).await
})
.await
}
@ -195,9 +216,7 @@ where
match event {
Event::Ping => {
if should_stop().await {
tracing::info!(
"[plus] default account changed, ending playtime session for {account_id}"
);
tracing::info!("[plus] ending playtime session for {account_id}");
break;
}
@ -219,6 +238,10 @@ async fn login(
client: &reqwest::Client,
account: &MinecraftAccount,
) -> Result<String, PlusError> {
if plus_blocked() {
return Err(PlusError::ConsentRequired);
}
let server_id = generate_server_id();
let join = client