From 8dca2550d1158fa748a4fd2b13fd6cf2d3ca6713 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:48:16 +0200 Subject: [PATCH] feat(core): Process recovery (session exit times and live log after restarting launcher) --- Cargo.lock | 1 + .../oneclient_app/src/layout/cluster_shell.rs | 16 +- packages/oneclient_app/src/utils.rs | 2 +- packages/oneclient_core/Cargo.toml | 1 + .../oneclient_core/src/bundles/overrides.rs | 14 +- .../oneclient_core/src/clusters/manager.rs | 4 +- packages/oneclient_core/src/game/download.rs | 2 +- packages/oneclient_core/src/game/launch.rs | 320 +- .../oneclient_core/src/game/log_replay.rs | 297 + packages/oneclient_core/src/game/mod.rs | 8 +- packages/oneclient_core/src/game/process.rs | 49 + packages/oneclient_core/src/game/reattach.rs | 414 ++ packages/oneclient_core/src/game/session.rs | 66 +- .../oneclient_core/src/game/shared_dir.rs | 2 +- packages/oneclient_core/src/game/tail.rs | 279 + packages/oneclient_core/src/java/install.rs | 2 +- .../src/packages/store/download.rs | 6 +- .../oneclient_core/src/packages/store/link.rs | 2 +- .../oneclient_core/src/packages/store/mod.rs | 4 +- packages/oneclient_core/src/recovery.rs | 8 +- packages/oneclient_core/src/state.rs | 2 + ...a35888bb3b6a5a4fda5df3c662ca16b1d801e.json | 12 + ...b55f9beddb045bc43eaef851da89b3e547933.json | 12 + ...79235e2470eeec5b10f1e768fcf54d37d57fe.json | 62 + ...0260716120000_session_process_tracking.sql | 6 + packages/oneclient_db/src/dao/game_session.rs | 89 +- .../oneclient_db/src/models/game_session.rs | 8 + packages/oneclient_db/src/models/mod.rs | 2 +- uncommitted.patch | 5975 ----------------- 29 files changed, 1536 insertions(+), 6129 deletions(-) create mode 100644 packages/oneclient_core/src/game/log_replay.rs create mode 100644 packages/oneclient_core/src/game/reattach.rs create mode 100644 packages/oneclient_core/src/game/tail.rs create mode 100644 packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json create mode 100644 packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json create mode 100644 packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json create mode 100644 packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql delete mode 100644 uncommitted.patch diff --git a/Cargo.lock b/Cargo.lock index 734f2f14..80081c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4577,6 +4577,7 @@ dependencies = [ "sha2 0.10.9", "sqlx", "strum", + "sysinfo", "thiserror 2.0.18", "tokio", "tokio-stream", diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index b870e958..3566684b 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -118,14 +118,18 @@ impl Component for ClusterShell { let show_game_log = game.is_active(cluster_id); let launch_state = launch_button_state(&game, cluster_id); + // Queried out here rather than where it is used: the shell can mount + // before the cluster list settles, and a hook that only runs once the + // cluster resolves would change this component's hook count mid-life. + let parsed = cluster.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); + let metadata = use_version_metadata( + parsed.as_ref().map(|p| p.major), + parsed.and_then(|p| p.key()), + cluster.as_ref().map(|c| c.mc_loader), + ); + let header = cluster.as_ref().map(|cluster| { let title = format!("{} {}", cluster.mc_loader, cluster.mc_version); - let parsed = parse_mc_version(&cluster.mc_version); - let metadata = use_version_metadata( - parsed.as_ref().map(|p| p.major), - parsed.and_then(|p| p.key()), - Some(cluster.mc_loader), - ); let subtitle = metadata .and_then(|m| m.long_description) .unwrap_or_else(|| cluster.name.clone()); diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 8895e0fd..40590cc5 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -35,7 +35,7 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { pub fn major_pretty_name(major: u32) -> String { if major >= 26 { - format!("{major}.x") + format!("{major}") } else { format!("1.{major}") } diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index e788dd26..0044b11a 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -124,6 +124,7 @@ url.workspace = true uuid.workspace = true chrono.workspace = true parking_lot.workspace = true +sysinfo.workspace = true sanitize-filename.workspace = true dunce.workspace = true bytes.workspace = true diff --git a/packages/oneclient_core/src/bundles/overrides.rs b/packages/oneclient_core/src/bundles/overrides.rs index d567adb1..d78393d9 100644 --- a/packages/oneclient_core/src/bundles/overrides.rs +++ b/packages/oneclient_core/src/bundles/overrides.rs @@ -331,7 +331,7 @@ mod tests { } async fn read(path: &Path) -> Option> { - tokio::fs::read(path).await.ok() + polyio::read(path).await.ok() } async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport { @@ -403,7 +403,7 @@ mod tests { write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; sync(&v1, &root).await; - tokio::fs::write(root.join("config/a.toml"), b"user-edit") + polyio::write(root.join("config/a.toml"), b"user-edit") .await .unwrap(); @@ -423,7 +423,7 @@ mod tests { write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; sync(&v1, &root).await; - tokio::fs::write(root.join("config/a.toml"), b"user-edit") + polyio::write(root.join("config/a.toml"), b"user-edit") .await .unwrap(); @@ -455,7 +455,7 @@ mod tests { write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await; sync(&v1, &root).await; - tokio::fs::write(root.join(path), b"runtime-mutated") + polyio::write(root.join(path), b"runtime-mutated") .await .unwrap(); @@ -482,7 +482,7 @@ mod tests { sync(&v1, &root).await; // User modified kept.toml; gone.toml untouched. - tokio::fs::write(root.join("config/kept.toml"), b"edited") + polyio::write(root.join("config/kept.toml"), b"edited") .await .unwrap(); @@ -503,10 +503,10 @@ mod tests { async fn bootstrap_pre_existing_edit_is_never_overwritten() { let root = tmp_root("bootstrap"); - tokio::fs::create_dir_all(root.join("config")) + polyio::create_dir_all(root.join("config")) .await .unwrap(); - tokio::fs::write(root.join("config/a.toml"), b"pre-existing") + polyio::write(root.join("config/a.toml"), b"pre-existing") .await .unwrap(); diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs index 62c0787c..72d11bac 100644 --- a/packages/oneclient_core/src/clusters/manager.rs +++ b/packages/oneclient_core/src/clusters/manager.rs @@ -153,9 +153,9 @@ impl ClusterManager { let marker = cluster.dedicated_marker()?; if dedicated { polyio::create_dir_all(cluster.dir()?).await?; - tokio::fs::write(&marker, b"").await.ok(); + polyio::write(&marker, b"").await.ok(); } else if marker.exists() { - tokio::fs::remove_file(&marker).await.ok(); + polyio::remove_file(&marker).await.ok(); } Ok(()) } diff --git a/packages/oneclient_core/src/game/download.rs b/packages/oneclient_core/src/game/download.rs index 5b42f10d..f5994f83 100644 --- a/packages/oneclient_core/src/game/download.rs +++ b/packages/oneclient_core/src/game/download.rs @@ -43,7 +43,7 @@ pub async fn download_to_path( child.set_phase(TaskPhase::Verifying); let actual = sha1_file(dest).await?; if normalize_hash(&actual) != normalize_hash(expected) { - let _ = tokio::fs::remove_file(dest).await; + let _ = polyio::remove_file(dest).await; return Err(GameError::HashMismatch { path: dest.display().to_string(), expected: expected.to_string(), diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index cc0b801b..b9245167 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -1,23 +1,22 @@ - use std::path::Path; use std::process::Stdio; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; +use chrono::{DateTime, Utc}; use interfrost::api::minecraft::ArgumentType; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; -use tokio::sync::Mutex as AsyncMutex; -use crate::auth::MinecraftAccount; use crate::ClusterStage; -use crate::clusters::ClusterManager; +use crate::auth::MinecraftAccount; +use crate::clusters::{Cluster, ClusterManager}; use crate::discord::Presence; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; use crate::game::{ GameError, arguments, download_minecraft, download_version_info, get_loader_version, libraries_missing, resolve_minecraft_version, }; -use crate::game::session::SessionRecorder; use crate::java::JavaManager; use crate::notification::{GroupedProgressSession, LaunchStage}; use crate::settings::GameSettingsProfile; @@ -125,9 +124,14 @@ pub async fn launch_cluster( ) .await?; - let version_info = - download_version_info(&state.services, Some(&progress), &version, loader_version.as_ref(), false) - .await; + let version_info = download_version_info( + &state.services, + Some(&progress), + &version, + loader_version.as_ref(), + false, + ) + .await; (version, updated, loader_version, version_info) }; @@ -141,9 +145,10 @@ pub async fn launch_cluster( } }; - let version_name = loader_version - .as_ref() - .map_or_else(|| version.id.clone(), |lv| format!("{}-{}", version.id, lv.id)); + let version_name = loader_version.as_ref().map_or_else( + || version.id.clone(), + |lv| format!("{}-{}", version.id, lv.id), + ); tracing::info!( cluster_id, @@ -172,8 +177,7 @@ pub async fn launch_cluster( match libraries_missing(&version_info, &java.os_arch, updated) { Ok(true) => { tracing::info!(cluster_id, "missing game files; repairing"); - let _ = - ClusterManager::set_stage(state, cluster_id, ClusterStage::Repairing).await; + let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::Repairing).await; stage(LaunchStage::Downloading); if let Err(err) = download_minecraft( &state.services, @@ -200,16 +204,14 @@ pub async fn launch_cluster( stage(LaunchStage::Launching); let cwd = game_dir; - tokio::fs::create_dir_all(&cwd).await.ok(); + polyio::create_dir_all(&cwd).await.ok(); if let Err(err) = crate::game::write_allowed_symlinks(&cwd).await { tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); } if !dedicated { - if let Err(err) = - crate::game::sync_shared_content(&state.services, &cluster, &cwd).await - { + if let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await { tracing::warn!(cluster_id, error = %err, "failed to sync shared content"); } // Redirect the shared dir's `logs`/`crash-reports` into this cluster's @@ -263,11 +265,7 @@ pub async fn launch_cluster( profile.resolution.unwrap_or_default(), &java.os_arch, )?; - arguments::append_profile_game_arguments( - &mut mc_args, - profile.force_fullscreen, - None, - ); + arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); run_hook(profile.hook_pre.as_deref(), &cwd).await; @@ -289,7 +287,39 @@ pub async fn launch_cluster( .args(mc_args) .current_dir(&cwd); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let log_path = crate::logs::cluster_output_log(&cluster)?; + if let Some(parent) = log_path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + // The game's output goes straight to the log file rather than through pipes + // held by the launcher. A pipe would tie the game's lifetime to ours: once + // the launcher exits its read end closes, and the game's next write to a + // broken stdout takes it down with us. Writing to a file keeps the two + // independent, and the launcher tails that file for the live console. + // A cloned handle shares the file offset, so stdout and stderr interleave + // into one stream instead of overwriting each other. + // `Stdio` needs owned std handles, so the tokio files are unwrapped only + // once the async work of opening and cloning them is done. + let handles = match tokio::fs::File::create(&log_path).await { + Ok(out) => match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + + match handles { + Ok((out, err)) => { + command.stdout(Stdio::from(out)).stderr(Stdio::from(err)); + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to open game log; discarding output"); + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + command.stdin(Stdio::null()); + detach(&mut command); let mut child = command .spawn() @@ -304,39 +334,30 @@ pub async fn launch_cluster( mc_version: cluster.mc_version.clone(), }); - let log_path = crate::logs::cluster_output_log(&cluster)?; - if let Some(parent) = log_path.parent() { - tokio::fs::create_dir_all(parent).await.ok(); - } - let log_file = tokio::fs::File::create(&log_path) - .await - .ok() - .map(|file| Arc::new(tokio::sync::Mutex::new(file))); + let recorder = + SessionRecorder::start(state, cluster_id, profile.mem_max.unwrap_or(2048), &java).await; - let recorder = SessionRecorder::start( - state, - cluster_id, - profile.mem_max.unwrap_or(2048), - &java, - ) - .await; + // Pin the process to the session row so that if the launcher exits before + // the game does, the next start can tell whether it is still playing. + if let (Some(recorder), Some(pid)) = (recorder.as_ref(), pid) { + recorder + .record_process(pid, crate::game::process_start_time(pid)) + .await; + } - if let Some(stdout) = child.stdout.take() { - spawn_log_reader(cluster_id, stdout, notifier.clone(), log_file.clone(), recorder.clone()); - } - if let Some(stderr) = child.stderr.take() { - spawn_log_reader(cluster_id, stderr, notifier.clone(), log_file.clone(), recorder.clone()); - } + let started_at = recorder + .as_ref() + .and_then(SessionRecorder::started_at) + .unwrap_or_else(Utc::now); + let tail = spawn_log_tail(cluster_id, log_path, notifier.clone(), recorder.clone()); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); state.games.register_kill(cluster_id, kill_tx); let state = Arc::clone(state); let post_hook = profile.hook_post.clone(); - let cluster_name = cluster.name.clone(); tokio::spawn(async move { let cluster = cluster; - let started = Instant::now(); let status = tokio::select! { status = child.wait() => status, _ = kill_rx => { @@ -344,79 +365,154 @@ pub async fn launch_cluster( child.wait().await } }; - let played = started.elapsed(); + tail.stop().await; + + let outcome = match status { + Ok(status) => Exit::Observed { + code: status.code().map(i64::from), + success: status.success(), + display: status.to_string(), + }, + Err(err) => Exit::Failed(err.to_string()), + }; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + recorder, + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome, + owns_slot: true, + }, + ) + .await; + }); + + Ok(LaunchedGame { cluster_id, pid }) +} + +/// Cut the game loose from the launcher's process group / console, so signals +/// aimed at the launcher (a terminal Ctrl-C, a console window closing) don't +/// reach the game as collateral. +fn detach(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command + .as_std_mut() + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } +} + +/// How a session ended, as far as anyone could tell. +pub(crate) enum Exit { + /// The launcher was there and saw the process exit. + Observed { + code: Option, + success: bool, + display: String, + }, + /// Waiting on the process itself failed. + Failed(String), + /// The game exited while the launcher was closed; the time was recovered + /// from its log, and the exit code is gone for good. + Inferred, +} + +pub(crate) struct SessionEnd { + pub started_at: DateTime, + pub ended_at: DateTime, + pub outcome: Exit, + /// Whether this session is the one currently holding the cluster's running + /// slot. A stale session recovered from the database may share its cluster + /// with a game that is playing right now — booking the old session's + /// playtime is right, but clearing that slot would report the live game as + /// exited. + pub owns_slot: bool, +} + +/// Everything that has to happen once a game is gone: clear its running state, +/// bank the playtime, close the session row, run the post hook and unwind the +/// shared-directory plumbing. Shared by the live exit path and by recovery of +/// sessions that outlived the launcher. +pub(crate) async fn finalize_session( + state: &Arc, + cluster: &Cluster, + cwd: &Path, + dedicated: bool, + post_hook: Option<&str>, + recorder: Option, + end: SessionEnd, +) { + let cluster_id = cluster.id; + let played = (end.ended_at - end.started_at).to_std().unwrap_or_default(); + + if end.owns_slot { state.games.remove(cluster_id); - state.services.notifier.game_stage(cluster_id, LaunchStage::Exited); + state + .services + .notifier + .game_stage(cluster_id, LaunchStage::Exited); if state.games.running_ids().is_empty() { state.discord.set_presence(Presence::Idle); } + } - if played > Duration::from_secs(1) { - let _ = ClusterManager::add_playtime(&state, cluster_id, played).await; + if played > Duration::from_secs(1) { + let _ = ClusterManager::add_playtime(state, cluster_id, played).await; + } + + if let Some(recorder) = recorder { + let code = match &end.outcome { + Exit::Observed { code, .. } => *code, + Exit::Failed(_) | Exit::Inferred => None, + }; + recorder.finish_at(&end.ended_at.to_rfc3339(), code).await; + } + + run_hook(post_hook, cwd).await; + + if !dedicated { + crate::game::import_manual_content(&state.services, cluster, cwd).await; + if let Err(err) = crate::game::clear_shared_content(cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); } + crate::game::unlink_cluster_logs(cwd).await; + } - if let Some(recorder) = recorder { - let exit_code = status.as_ref().ok().and_then(|s| s.code()).map(i64::from); - recorder.finish(exit_code).await; - } - - run_hook(post_hook.as_deref(), &cwd).await; - - if !dedicated { - crate::game::import_manual_content(&state.services, &cluster, &cwd).await; - if let Err(err) = crate::game::clear_shared_content(&cwd).await { - tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); - } - crate::game::unlink_cluster_logs(&cwd).await; - } - - match status { - Ok(status) if status.success() => state - .services - .notifier - .send_info("Game closed", &format!("{cluster_name} exited")), - Ok(status) => state.services.notifier.send_error( - "Game crashed", - &format!("{cluster_name} exited with {status}"), - ), - Err(err) => state - .services - .notifier - .send_error("Game error", &format!("{cluster_name}: {err}")), - } - }); - - Ok(LaunchedGame { - cluster_id, - pid, - }) -} - -fn spawn_log_reader( - cluster_id: i64, - reader: R, - notifier: crate::notification::NotificationService, - file: Option>>, - recorder: Option, -) where - R: tokio::io::AsyncRead + Unpin + Send + 'static, -{ - tokio::spawn(async move { - let mut lines = BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if let Some(file) = &file { - let mut handle = file.lock().await; - let _ = handle.write_all(line.as_bytes()).await; - let _ = handle.write_all(b"\n").await; - } - if let Some(recorder) = &recorder { - recorder.observe(&line).await; - } - notifier.game_log(cluster_id, line); - } - }); + let name = &cluster.name; + match end.outcome { + Exit::Observed { success: true, .. } => state + .services + .notifier + .send_info("Game closed", &format!("{name} exited")), + Exit::Observed { display, .. } => state + .services + .notifier + .send_error("Game crashed", &format!("{name} exited with {display}")), + Exit::Failed(err) => state + .services + .notifier + .send_error("Game error", &format!("{name}: {err}")), + // Nothing was watching, so there is no crash to report and no news the + // user wants a popup about — the session is simply booked and closed. + Exit::Inferred => {} + } } fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs new file mode 100644 index 00000000..9e0a536c --- /dev/null +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -0,0 +1,297 @@ +//! Reconstructing a game session from its log. +//! +//! When the launcher is closed while the game keeps playing, nothing observes +//! the exit. The log is the only witness left, so on the next start we replay +//! it to recover when the session ended and which servers it visited. +//! +//! Minecraft logs a bare wall-clock time (`[12:29:00]`) in the machine's local +//! zone — no date, no offset. Absolute timestamps therefore have to be rebuilt +//! by anchoring at the session start and walking forward. + +use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + +use super::session::{ServerJoin, parse_server_join}; + +/// A backwards jump larger than this means the clock wrapped past midnight +/// rather than lines merely arriving out of order between threads. +const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; + +/// Lines that mean "no longer connected to the server we were tracking". +/// Absent any of these the span is closed by the next join, or by the session +/// end — a missing marker costs accuracy, never correctness. +const LEAVE_MARKERS: &[&str] = &[ + "Stopping!", + "Stopping worker threads", + "Connection lost", + "Lost connection to server", + "Client disconnected with reason", + "Disconnected from server", +]; + +/// The clean-shutdown marker. Its timestamp is the truest exit time available. +const STOP_MARKER: &str = "Stopping!"; + +pub(crate) fn parse_log_time(line: &str) -> Option { + let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); + + // `%.f` also matches an empty fraction, so this covers `[12:29:00]` and + // `[12:29:00.123]` alike. + if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { + return Some(time); + } + + // Some pack-shipped log4j configs prepend the date. + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { + return Some(dt.time()); + } + } + + None +} + +pub(crate) fn is_leave_marker(line: &str) -> bool { + LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) +} + +fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + // DST end: the same wall-clock time happens twice. The first is the + // better guess for a forward-walking log. + LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), + // DST start: this wall-clock time never existed, so step over the gap + // rather than reading the local time as if it were UTC — that would be + // wrong by the zone's whole offset. + LocalResult::None => Local + .from_local_datetime(&(naive + chrono::Duration::hours(1))) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), + } +} + +/// Turns the log's bare times into absolute instants by walking forward from an +/// anchor and advancing the date whenever the clock wraps. +pub(crate) struct LogClock { + date: NaiveDate, + /// High-water mark, not the previous line: threads interleave, so a line + /// may legitimately sit a second behind the one before it. + peak: NaiveTime, +} + +impl LogClock { + pub(crate) fn new(anchor: DateTime) -> Self { + let local = anchor.with_timezone(&Local).naive_local(); + Self { + date: local.date(), + peak: local.time(), + } + } + + pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { + let behind = self.peak.signed_duration_since(time).num_seconds(); + if behind > ROLLOVER_SLACK_SECS { + self.date = self.date.succ_opt().unwrap_or(self.date); + self.peak = time; + } else if time > self.peak { + self.peak = time; + } + local_to_utc(self.date, time) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerSpan { + pub host: String, + pub port: Option, + pub joined_at: DateTime, + pub disconnected_at: Option>, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct LogReplay { + /// Timestamp of the last line carrying one — a lower bound on the exit. + pub last_activity: Option>, + /// Set when the game logged a clean shutdown. + pub stopped_at: Option>, + pub servers: Vec, +} + +/// Walk a whole session log, recovering server spans and the last sign of life. +pub(crate) fn replay(content: &str, started_at: DateTime) -> LogReplay { + let mut clock = LogClock::new(started_at); + let mut out = LogReplay::default(); + // Timestamps only appear on the first line of a multi-line entry (stack + // traces continue underneath), so carry the last one forward. + let mut now = started_at; + + for line in content.lines() { + if let Some(time) = parse_log_time(line) { + now = clock.absolute(time); + out.last_activity = Some(now); + } + + if let Some(ServerJoin { host, port }) = parse_server_join(line) { + close_open_span(&mut out, now); + out.servers.push(ServerSpan { + host, + port, + joined_at: now, + disconnected_at: None, + }); + continue; + } + + if is_leave_marker(line) { + close_open_span(&mut out, now); + } + + if line.contains(STOP_MARKER) { + out.stopped_at = Some(now); + } + } + + out +} + +fn close_open_span(replay: &mut LogReplay, at: DateTime) { + if let Some(open) = replay + .servers + .iter_mut() + .rev() + .find(|span| span.disconnected_at.is_none()) + { + open.disconnected_at = Some(at.max(open.joined_at)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn anchor() -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .unwrap() + .with_timezone(&Utc) + } + + fn local_at(h: u32, m: u32, s: u32) -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, h, m, s) + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn parses_plain_and_fractional_times() { + assert_eq!( + parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + assert_eq!( + parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), + NaiveTime::from_hms_milli_opt(12, 29, 0, 123) + ); + } + + #[test] + fn parses_dated_stamp() { + assert_eq!( + parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + } + + #[test] + fn ignores_untimestamped_lines() { + assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); + assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); + } + + #[test] + fn clock_keeps_same_day_while_moving_forward() { + let mut clock = LogClock::new(anchor()); + let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); + assert_eq!(at, local_at(12, 30, 0)); + } + + #[test] + fn clock_rolls_over_midnight() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); + let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); + assert_eq!(at, local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1)); + } + + #[test] + fn clock_tolerates_out_of_order_threads() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); + // A second thread's line lands a second late; this must not add a day. + let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); + assert_eq!(at, local_at(12, 29, 7)); + } + + #[test] + fn replays_server_span_closed_by_quit() { + let log = "\ +[12:29:00] [Client thread/INFO]: Setting user: Lynith +[12:30:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565 +[12:45:00] [Client thread/INFO]: Stopping! +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 1); + let span = &out.servers[0]; + assert_eq!(span.host, "mc.hypixel.net"); + assert_eq!(span.port, Some(25565)); + assert_eq!(span.joined_at, local_at(12, 30, 0)); + assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); + assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); + assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); + } + + #[test] + fn consecutive_joins_close_previous_span() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:40:00] [Render thread/INFO]: Connecting to b.example.com, 25566 +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 2); + assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); + assert_eq!(out.servers[1].disconnected_at, None); + } + + #[test] + fn span_left_open_when_log_just_stops() { + let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; + let out = replay(log, anchor()); + assert_eq!(out.servers[0].disconnected_at, None); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); + } + + #[test] + fn crash_without_stop_marker_reports_last_activity() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:31:00] [Render thread/ERROR]: Encountered an unexpected exception +\tat net.minecraft.Foo.bar(Foo.java:12) +"; + let out = replay(log, anchor()); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); + } + + #[test] + fn empty_log_yields_nothing() { + let out = replay("", anchor()); + assert!(out.servers.is_empty()); + assert!(out.last_activity.is_none()); + assert!(out.stopped_at.is_none()); + } +} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index bae80370..8fac330e 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -3,11 +3,14 @@ mod arguments; mod download; mod error; mod launch; +mod log_replay; mod metadata; mod process; +mod reattach; mod rules; mod session; mod shared_dir; +mod tail; pub use analytics::{ Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, @@ -20,7 +23,10 @@ pub use arguments::{ pub use download::{download_to_path, fetch_bytes_verified}; pub use error::GameError; pub use launch::{LaunchedGame, is_running, launch_cluster}; -pub use process::{GameProcess, GameProcessManager}; +pub use process::{ + GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, +}; +pub use reattach::recover_sessions; pub use metadata::{ download_minecraft, download_version_info, get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, libraries_missing, diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 3b13b631..e9e73d25 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -4,10 +4,55 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Instant; +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; use tokio::sync::oneshot; use crate::notification::LaunchStage; +fn probe(pid: u32) -> Option<(System, Pid)> { + let pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid)?; + Some((sys, pid)) +} + +/// Process start time in unix seconds — pids get recycled, this pins identity. +pub fn process_start_time(pid: u32) -> Option { + let (sys, pid) = probe(pid)?; + Some(sys.process(pid)?.start_time()) +} + +/// Whether `pid` is still the process we launched. A matching pid whose start +/// time differs is a recycled pid belonging to someone else — never ours. +pub fn is_process_alive(pid: u32, started_at: Option) -> bool { + let Some(actual) = process_start_time(pid) else { + return false; + }; + match started_at { + Some(expected) => actual == expected, + // Pre-migration sessions have no recorded start time. Liveness alone is + // a weaker signal, but treating the game as running is the safer error: + // worst case the exit time is recovered on a later start. + None => true, + } +} + +/// Ask a process to exit. Used for games re-adopted after a launcher restart, +/// where no `Child` handle survives to kill through. +pub fn kill_process(pid: u32) -> bool { + let Some((sys, pid)) = probe(pid) else { + return false; + }; + let Some(process) = sys.process(pid) else { + return false; + }; + // Prefer a graceful terminate so the game can save and shut down cleanly. + process + .kill_with(Signal::Term) + .unwrap_or_else(|| process.kill()) +} + #[derive(Debug, Clone)] pub struct GameProcess { pub pid: Option, @@ -82,6 +127,10 @@ impl GameProcessManager { .map(|(id, _)| *id) } + pub fn pid(&self, cluster_id: i64) -> Option { + self.inner.lock().unwrap().get(&cluster_id).and_then(|p| p.pid) + } + pub fn is_running(&self, cluster_id: i64) -> bool { self.inner .lock() diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs new file mode 100644 index 00000000..34791306 --- /dev/null +++ b/packages/oneclient_core/src/game/reattach.rs @@ -0,0 +1,414 @@ +//! Picking up game sessions that outlived the launcher. +//! +//! Games are spawned detached, so closing the launcher no longer takes the game +//! down with it. That leaves two loose ends to tidy on the next start, both of +//! them sessions whose row still has no `ended_at`: +//! +//! * the game is **still running** — re-adopt it, so the UI shows it as playing +//! and its exit is recorded properly when it finally happens; +//! * the game **already exited** unobserved — its log is the only witness, so +//! replay it to recover when it ended and which servers it visited. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::UnfinishedSession; + +use crate::clusters::{Cluster, ClusterManager}; +use crate::discord::Presence; +use crate::game::launch::{Exit, SessionEnd, finalize_session}; +use crate::game::log_replay::{self, ServerSpan}; +use crate::game::process::{is_process_alive, kill_process}; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; +use crate::notification::LaunchStage; +use crate::state::LauncherState; + +/// How often to check whether a re-adopted game is still alive. It is only a +/// fallback for a process we don't own, so a slow poll is plenty. +const LIVENESS_POLL: Duration = Duration::from_secs(3); + +/// Settle every session left open by a previous launcher run. Safe to call once +/// at startup, before anything can launch a game. +#[tracing::instrument(skip(state), level = "debug")] +pub async fn recover_sessions(state: &Arc) { + let sessions = match session_dao::unfinished_sessions(&state.services.db).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!(error = %err, "failed to list unfinished game sessions"); + return; + } + }; + + if sessions.is_empty() { + return; + } + + tracing::info!(count = sessions.len(), "recovering unfinished game sessions"); + + let mut live = Vec::new(); + let mut dead = Vec::new(); + for session in sessions { + match classify(state, session).await { + Some((cluster, session, started_at, Some(pid))) => { + live.push((cluster, session, started_at, pid)); + } + Some((cluster, session, started_at, None)) => { + dead.push((cluster, session, started_at)); + } + None => {} + } + } + + // Re-adopt the live games first. Reconciling an exited session tears down + // its shared game directory, which a still-running cluster may be sharing — + // registering the live ones up front is what makes that check possible. + for (cluster, session, started_at, pid) in live { + readopt(state, cluster, session, started_at, pid).await; + } + + for (cluster, session, started_at) in dead { + reconcile(state, cluster, session, started_at).await; + } +} + +/// Resolve a session to its cluster and decide whether its process is alive. +/// `None` means the session was closed out here and needs no further work. +async fn classify( + state: &Arc, + session: UnfinishedSession, +) -> Option<(Cluster, UnfinishedSession, DateTime, Option)> { + let cluster_id = session.cluster_id; + + // A session this launcher already owns needs no recovery. + if state.games.is_active(cluster_id) { + return None; + } + + let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { + Ok(at) => at.with_timezone(&Utc), + Err(err) => { + tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); + close_untraceable(state, &session).await; + return None; + } + }; + + // The cluster is gone, so there is no log to consult and no playtime worth + // attributing. Just close the row so it stops being reconsidered forever. + let Ok(cluster) = ClusterManager::get(state, cluster_id).await else { + tracing::warn!(cluster_id, "cluster missing for unfinished session; closing it"); + close_untraceable(state, &session).await; + return None; + }; + + let alive = session + .pid + .and_then(|pid| u32::try_from(pid).ok()) + .filter(|pid| is_process_alive(*pid, session.pid_started_at.map(|t| t as u64))); + + Some((cluster, session, started_at, alive)) +} + +/// Close a session we can say nothing about. Ending it at its start time books +/// no playtime, which beats inventing some. +async fn close_untraceable(state: &Arc, session: &UnfinishedSession) { + if let Err(err) = session_dao::finish_session_at( + &state.services.db, + &session.started_at, + &session.started_at, + None, + ) + .await + { + tracing::warn!(error = %err, "failed to close untraceable session"); + } +} + +/// The game is still playing. Wire it back up as though we had launched it. +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id, pid), level = "debug")] +async fn readopt( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, + pid: u32, +) { + let cluster_id = cluster.id; + let Ok(cwd) = cluster.game_dir() else { + return; + }; + + tracing::info!(cluster_id, pid, "re-adopting running game"); + + let open_server = open_server_of(state, &session.started_at).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + state.games.set_stage(cluster_id, LaunchStage::Running); + state.games.set_pid(cluster_id, Some(pid)); + state.games.set_dir(cluster_id, cwd.clone()); + state.services.notifier.game_stage(cluster_id, LaunchStage::Running); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); + + let Ok(log_path) = crate::logs::cluster_output_log(&cluster) else { + return; + }; + let tail = spawn_log_tail( + cluster_id, + log_path, + state.services.notifier.clone(), + Some(recorder.clone()), + ); + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); + + // The post hook belongs to whichever settings apply now; resolve it up front + // so the watcher task doesn't have to reach back into the state. + let post_hook = ClusterManager::resolve_settings(state, &cluster) + .await + .ok() + .and_then(|profile| profile.hook_post); + let dedicated = cluster.uses_dedicated_dir(); + let pid_started_at = session.pid_started_at.map(|t| t as u64); + let state = Arc::clone(state); + + tokio::spawn(async move { + tokio::select! { + () = wait_for_exit(pid, pid_started_at) => {} + _ = kill_rx => { + kill_process(pid); + wait_for_exit(pid, pid_started_at).await; + } + } + + tail.stop().await; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + Some(recorder), + SessionEnd { + started_at, + // We watched this one die, so the time is real even though the + // exit code went with the launcher that spawned it. + ended_at: Utc::now(), + outcome: Exit::Inferred, + // Only tear down the cluster's running state if it is still + // ours. With parallel clusters enabled the user can start a + // fresh game on this cluster while the re-adopted one plays on, + // and that newer game now owns the slot. + owns_slot: state.games.pid(cluster_id) == Some(pid), + }, + ) + .await; + }); +} + +async fn wait_for_exit(pid: u32, pid_started_at: Option) { + while is_process_alive(pid, pid_started_at) { + tokio::time::sleep(LIVENESS_POLL).await; + } +} + +/// The game exited while nothing was watching. Rebuild what we can from the log. +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id), level = "debug")] +async fn reconcile( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, +) { + let cluster_id = cluster.id; + let log = read_session_log(&cluster).await; + + let replay = log + .as_ref() + .map(|log| log_replay::replay(&log.content, started_at)) + .unwrap_or_default(); + + // Prefer the log's own testimony over the file's mtime: a clean shutdown + // says exactly when it happened, and the last timestamped line is the last + // proof of life. mtime only helps when nothing in the log carries a time. + let ended_at = replay + .stopped_at + .or(replay.last_activity) + .or_else(|| log.as_ref().and_then(|log| log.modified)) + .unwrap_or(started_at) + .clamp(started_at, Utc::now()); + + tracing::info!( + cluster_id, + session = %session.started_at, + %ended_at, + clean_exit = replay.stopped_at.is_some(), + servers = replay.servers.len(), + "recovered session that outlived the launcher" + ); + + let open_server = apply_spans(state, &session.started_at, &replay.servers).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + let Ok(cwd) = cluster.game_dir() else { + recorder.finish_at(&ended_at.to_rfc3339(), None).await; + return; + }; + + // Unwinding the shared directory is only safe if nobody is playing in it. + // A cluster re-adopted moments ago may share this exact directory, and + // clearing its content mid-session would pull the game apart underneath it. + let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); + if shared_dir_busy { + tracing::debug!(cluster_id, "shared game dir still in use; skipping exit cleanup"); + } + + finalize_session( + state, + &cluster, + &cwd, + cluster.uses_dedicated_dir() || shared_dir_busy, + // The post hook is deliberately skipped. It is an *on exit* hook, and + // the exit already happened — possibly days ago. Firing it now, during + // startup, would surprise anyone whose hook does something real. + None, + Some(recorder), + SessionEnd { + started_at, + ended_at, + outcome: Exit::Inferred, + // This session is over, but the cluster may have a newer one playing + // right now — only claim the slot if nothing else holds it. + owns_slot: !state.games.is_active(cluster_id), + }, + ) + .await; +} + +/// Rewrite a session's server spans from the log replay, which — unlike the +/// live rows — covers the whole session including the part we missed. Returns +/// the `joined_at` of a span still left open, for the caller to close. +async fn apply_spans( + state: &Arc, + session_id: &str, + spans: &[ServerSpan], +) -> Option { + let db = &state.services.db; + let existing = session_dao::list_session_servers(db, session_id) + .await + .unwrap_or_default(); + + // A log that yields fewer spans than we already recorded has been rotated or + // truncated. Live rows are then the better record; keep them and just report + // whichever is still open so it gets closed at the session's end. + if spans.len() < existing.len() { + tracing::debug!( + session = %session_id, + replayed = spans.len(), + existing = existing.len(), + "log replay is thinner than recorded spans; keeping recorded ones" + ); + return existing + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at); + } + + if let Err(err) = session_dao::delete_session_servers(db, session_id).await { + tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); + return None; + } + + let mut open = None; + for span in spans { + let joined_at = span.joined_at.to_rfc3339(); + if let Err(err) = session_dao::insert_server_join_at( + db, + session_id, + &span.host, + span.port.map(i64::from), + &joined_at, + ) + .await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); + continue; + } + + match span.disconnected_at { + Some(at) => { + if let Err(err) = + session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); + } + } + None => open = Some(joined_at), + } + } + + open +} + +async fn open_server_of(state: &Arc, session_id: &str) -> Option { + session_dao::list_session_servers(&state.services.db, session_id) + .await + .unwrap_or_default() + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at) +} + +struct SessionLog { + content: String, + modified: Option>, +} + +/// Read whichever log covers the session. `cluster-output.log` is preferred: the +/// launcher truncates it at every launch, so it belongs to exactly one session, +/// whereas `latest.log` is the game's own and may already have been rotated. +async fn read_session_log(cluster: &Cluster) -> Option { + let mut candidates: Vec = Vec::new(); + if let Ok(path) = crate::logs::cluster_output_log(cluster) { + candidates.push(path); + } + if let Ok(dir) = cluster.dir() { + candidates.push(dir.join("logs").join("latest.log")); + } + + for path in candidates { + match read_log(&path).await { + Some(log) if !log.content.trim().is_empty() => return Some(log), + _ => continue, + } + } + + None +} + +async fn read_log(path: &Path) -> Option { + let content = polyio::read_to_string(path).await.ok()?; + let modified = polyio::stat(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(DateTime::::from); + + Some(SessionLog { content, modified }) +} diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs index 6f94e3bb..c0760ccc 100644 --- a/packages/oneclient_core/src/game/session.rs +++ b/packages/oneclient_core/src/game/session.rs @@ -15,6 +15,9 @@ pub(crate) struct ServerJoin { pub port: Option, } +/// The session row's identity — its `started_at`, which is the primary key. +pub(crate) type SessionId = String; + pub(crate) fn parse_server_join(line: &str) -> Option { const MARKER: &str = "Connecting to "; let idx = line.find(MARKER)?; @@ -85,11 +88,49 @@ impl SessionRecorder { }) } - pub(crate) async fn observe(&self, line: &str) { - let Some(join) = parse_server_join(line) else { - return; - }; + /// Re-attach to a session row left open by a previous launcher run, so a + /// game that outlived the launcher keeps recording into the same session. + pub(crate) fn resume(db: DbPool, session_started_at: SessionId, open_server: Option) -> Self { + Self { + session_started_at, + db, + open_server: Arc::new(Mutex::new(open_server)), + } + } + /// When the session row says it began. Playtime is measured against this so + /// it agrees with the session span analytics reads back out of the row. + pub(crate) fn started_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.session_started_at) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) + } + + /// Remember which OS process backs this session. Without it, a launcher + /// restart cannot tell a live game from one that exited unobserved. + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { + if let Err(err) = session_dao::set_session_process( + &self.db, + &self.session_started_at, + Some(i64::from(pid)), + pid_started_at.map(|t| t as i64), + ) + .await + { + tracing::warn!(pid, error = %err, "failed to record session process"); + } + } + + pub(crate) async fn observe(&self, line: &str) { + if let Some(join) = parse_server_join(line) { + self.open(join).await; + } else if super::log_replay::is_leave_marker(line) { + self.close_open().await; + } + } + + async fn open(&self, join: ServerJoin) { let mut open = self.open_server.lock().await; if let Some(prev) = open.take() && let Err(err) = session_dao::finish_server(&self.db, &prev).await @@ -111,16 +152,27 @@ impl SessionRecorder { } } + async fn close_open(&self) { + if let Some(prev) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close server row"); + } + } + + /// Close the session. The time is explicit because an exit is not always + /// observed as it happens — one recovered from a log ended in the past. #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] - pub(crate) async fn finish(self, exit_code: Option) { + pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { if let Some(open) = self.open_server.lock().await.take() - && let Err(err) = session_dao::finish_server(&self.db, &open).await + && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await { tracing::warn!(error = %err, "failed to close open server on exit"); } if let Err(err) = - session_dao::finish_session(&self.db, &self.session_started_at, exit_code).await + session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) + .await { tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index b90f3ba0..97b06a07 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -308,7 +308,7 @@ async fn move_dir_contents(from: &Path, to: &Path) { let dest = to.join(name); - if tokio::fs::rename(&src, &dest).await.is_err() { + if polyio::rename(&src, &dest).await.is_err() { tracing::warn!(file = %src.display(), "failed to salvage leaked log file"); } } diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs new file mode 100644 index 00000000..44d4cd99 --- /dev/null +++ b/packages/oneclient_core/src/game/tail.rs @@ -0,0 +1,279 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; + +use crate::game::session::SessionRecorder; +use crate::notification::NotificationService; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +pub(crate) struct LogTail { + stop: Arc, + handle: tokio::task::JoinHandle<()>, +} + +impl LogTail { + /// Stop tailing, but only after one last pass: the lines explaining *why* + /// the game exited are written moments before it does, and would be lost if + /// the tail stopped the instant the process did. + pub(crate) async fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.handle.await; + } +} + +pub(crate) fn spawn_log_tail( + cluster_id: i64, + path: PathBuf, + notifier: NotificationService, + recorder: Option, +) -> LogTail { + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + + let handle = tokio::spawn(async move { + if let Err(err) = tail(cluster_id, &path, ¬ifier, recorder.as_ref(), &flag).await { + tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); + } + }); + + LogTail { stop, handle } +} + +async fn tail( + cluster_id: i64, + path: &PathBuf, + notifier: &NotificationService, + recorder: Option<&SessionRecorder>, + stop: &AtomicBool, +) -> std::io::Result<()> { + // The game may not have created the file yet on a re-adopt, and on launch it + // exists but is empty. Either way, wait for it rather than giving up. + let file = loop { + match tokio::fs::File::open(path).await { + Ok(file) => break file, + Err(_) if !stop.load(Ordering::Relaxed) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut offset = 0u64; + + loop { + line.clear(); + let read = reader.read_line(&mut line).await?; + + if read == 0 { + // A final pass has already run by the time the flag is seen here, + // since the read above returned EOF on a fully drained file. + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + // The log is truncated when a cluster relaunches; if the file + // shrank, our offset is past the end and we would never read again. + let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); + if len < offset { + reader.seek(std::io::SeekFrom::Start(0)).await?; + offset = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + offset += read as u64; + + // A partial line means the game is mid-write; wait for the newline + // rather than emitting half a line and re-emitting the rest. + if !line.ends_with('\n') { + // Unless the game is already gone — a process killed mid-write + // leaves a newline that will never arrive, and waiting for it would + // hang the caller of `stop()` forever. + if stop.load(Ordering::Relaxed) { + emit(cluster_id, &line, notifier, recorder).await; + return Ok(()); + } + reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?; + offset -= read as u64; + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + emit(cluster_id, &line, notifier, recorder).await; + } +} + +async fn emit( + cluster_id: i64, + line: &str, + notifier: &NotificationService, + recorder: Option<&SessionRecorder>, +) { + // Blank lines are kept: the game prints them, and the console should read + // the way the log does. + let text = line.trim_end_matches(['\n', '\r']).to_string(); + if let Some(recorder) = recorder { + recorder.observe(&text).await; + } + notifier.game_log(cluster_id, text); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::notification::Notification; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; + + struct Harness { + dir: async_tempfile::TempDir, + rx: mpsc::UnboundedReceiver, + notifier: NotificationService, + } + + impl Harness { + /// The temp dir removes itself on drop, so nothing here needs cleaning up. + async fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let dir = polyio::tempdir().await.expect("temp dir"); + Self { + dir, + rx, + notifier: NotificationService::new(tx), + } + } + + fn path(&self) -> PathBuf { + self.dir.dir_path().join("cluster-output.log") + } + + /// Drain whatever the tail has emitted so far. + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(Notification::GameLog { line, .. }) = self.rx.try_recv() { + out.push(line); + } + out + } + } + + /// Give the tail a few poll cycles to notice what we wrote. + async fn settle() { + tokio::time::sleep(POLL_INTERVAL * 4).await; + } + + #[tokio::test] + async fn follows_lines_appended_after_start() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] first\n").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + settle().await; + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b"[12:00:01] second\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); + } + + #[tokio::test] + async fn waits_for_a_file_that_does_not_exist_yet() { + let mut h = Harness::new().await; + let path = h.path(); + + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + settle().await; + polyio::write(&path, "[12:00:00] late\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] late"]); + } + + #[tokio::test] + async fn holds_back_a_partial_line_until_its_newline_lands() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] half").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + settle().await; + // A line mid-write must not be emitted, or it would arrive twice. + assert!(h.drain().is_empty()); + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b" and half\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); + } + + #[tokio::test] + async fn recovers_when_the_log_is_truncated() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] old session line\n").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + settle().await; + assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); + + // A relaunch truncates the log; the tail would otherwise sit forever at + // an offset past the new end and go silent. + polyio::write(&path, "[13:00:00] new\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[13:00:00] new"]); + } + + #[tokio::test] + async fn stops_even_when_the_last_line_has_no_newline() { + let mut h = Harness::new().await; + let path = h.path(); + // A process killed mid-write leaves a newline that never arrives. + polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + settle().await; + + // Waiting for that newline would hang the exit path forever. + let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; + assert!(stopped.is_ok(), "tail.stop() hung on an unterminated final line"); + assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); + } + + #[tokio::test] + async fn emits_the_final_lines_written_just_before_exit() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] Stopping!\n").await.unwrap(); + + // Stopping immediately, as the exit path does, must still flush the + // reason the game exited rather than race past it. + let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); + } +} diff --git a/packages/oneclient_core/src/java/install.rs b/packages/oneclient_core/src/java/install.rs index 1a8c2716..7cb4ccc1 100644 --- a/packages/oneclient_core/src/java/install.rs +++ b/packages/oneclient_core/src/java/install.rs @@ -57,7 +57,7 @@ pub async fn install_package( .await; } - let _ = tokio::fs::remove_file(&archive_path).await; + let _ = polyio::remove_file(&archive_path).await; tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); diff --git a/packages/oneclient_core/src/packages/store/download.rs b/packages/oneclient_core/src/packages/store/download.rs index 1bfd817e..9c8fcc2b 100644 --- a/packages/oneclient_core/src/packages/store/download.rs +++ b/packages/oneclient_core/src/packages/store/download.rs @@ -24,9 +24,9 @@ pub async fn ensure_artifact_file( if dest.exists() { let existing = sha1_file(dest).await?; if normalize_hash(&existing) == normalize_hash(hash) { - return Ok(tokio::fs::metadata(dest).await?.len()); + return Ok(polyio::stat(dest).await?.len()); } - tokio::fs::remove_file(dest).await?; + polyio::remove_file(dest).await?; } tracing::debug!("downloading artifact file"); @@ -45,7 +45,7 @@ pub async fn ensure_artifact_file( .into()); } - Ok(tokio::fs::metadata(dest).await?.len()) + Ok(polyio::stat(dest).await?.len()) } #[allow(clippy::too_many_arguments)] diff --git a/packages/oneclient_core/src/packages/store/link.rs b/packages/oneclient_core/src/packages/store/link.rs index 7205bc1a..d4beee0d 100644 --- a/packages/oneclient_core/src/packages/store/link.rs +++ b/packages/oneclient_core/src/packages/store/link.rs @@ -74,7 +74,7 @@ pub async fn unlink_cluster_file( .join(file_name); if path.exists() { - tokio::fs::remove_file(&path).await?; + polyio::remove_file(&path).await?; } Ok(()) } diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs index 8e1ae8f1..6843cfea 100644 --- a/packages/oneclient_core/src/packages/store/mod.rs +++ b/packages/oneclient_core/src/packages/store/mod.rs @@ -270,9 +270,9 @@ impl PackageStore { if let Some(parent) = dest.parent() { polyio::create_dir_all(parent).await?; } - tokio::fs::copy(path, &dest).await?; + polyio::copy(path, &dest).await?; - let size = tokio::fs::metadata(&dest).await?.len(); + let size = polyio::stat(&dest).await?.len(); let stored_path = relative_cache_path(&dest)?; let row = artifact_dao::insert_artifact( diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 70b98c6c..abdedb0b 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -160,7 +160,7 @@ async fn index_cache_file( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - let size = tokio::fs::metadata(file).await.ok().map(|m| m.len() as i64); + let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); artifact_dao::insert_artifact( pool, @@ -265,7 +265,7 @@ async fn relink_cluster_files( if artifact_dao::get_artifact_by_hash(pool, &hash).await?.is_none() { let stored_path = relative_launcher_path(launcher_dir, &file); - let size = tokio::fs::metadata(&file).await.ok().map(|m| m.len() as i64); + let size = polyio::stat(&file).await.ok().map(|m| m.len() as i64); artifact_dao::insert_artifact( pool, &hash, @@ -388,7 +388,7 @@ fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String { async fn list_dir_names(dir: &Path) -> LauncherResult> { let mut names = Vec::new(); - let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + let Ok(mut entries) = polyio::read_dir(dir).await else { return Ok(names); }; while let Some(entry) = entries.next_entry().await? { @@ -401,7 +401,7 @@ async fn list_dir_names(dir: &Path) -> LauncherResult> { async fn list_files(dir: &Path) -> LauncherResult> { let mut files = Vec::new(); - let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + let Ok(mut entries) = polyio::read_dir(dir).await else { return Ok(files); }; while let Some(entry) = entries.next_entry().await? { diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index a8b47501..2f829022 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -91,6 +91,8 @@ impl LauncherState { } }; + crate::game::recover_sessions(&background).await; + let (versions_res, bundles_res) = tokio::join!( background.versions.sync(&background.services), background.bundles.sync(&background.services), diff --git a/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json new file mode 100644 index 00000000..cb59124e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_sessions\n\t\tSET pid = ?, pid_started_at = ?\n\t\tWHERE started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e" +} diff --git a/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json new file mode 100644 index 00000000..ebc1b8fd --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM game_session_servers\n\t\tWHERE session_started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933" +} diff --git a/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json new file mode 100644 index 00000000..9d2eae4f --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, started_at, pid, pid_started_at\n\t\tFROM game_sessions\n\t\tWHERE ended_at IS NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "pid", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid" + } + } + }, + { + "name": "pid_started_at", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid_started_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe" +} diff --git a/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql new file mode 100644 index 00000000..499251b1 --- /dev/null +++ b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql @@ -0,0 +1,6 @@ +-- Track the OS process behind a session so a restarted launcher can tell a +-- still-running game from one that exited while the launcher was closed. +-- `pid_started_at` is the process start time (unix seconds), used to reject +-- a recycled pid that now belongs to an unrelated process. +ALTER TABLE game_sessions ADD COLUMN pid INTEGER; +ALTER TABLE game_sessions ADD COLUMN pid_started_at INTEGER; diff --git a/packages/oneclient_db/src/dao/game_session.rs b/packages/oneclient_db/src/dao/game_session.rs index 9dbc428f..7496e40a 100644 --- a/packages/oneclient_db/src/dao/game_session.rs +++ b/packages/oneclient_db/src/dao/game_session.rs @@ -3,6 +3,7 @@ use sqlx::SqlitePool; use crate::models::{ GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, + UnfinishedSession, }; pub async fn insert_session( @@ -36,7 +37,17 @@ pub async fn finish_session( started_at: &str, exit_code: Option, ) -> Result<(), sqlx::Error> { - let ended_at = Utc::now().to_rfc3339(); + finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await +} + +/// Close a session at an explicit time. Used when the exit time is inferred +/// from the game's log rather than observed live. +pub async fn finish_session_at( + pool: &SqlitePool, + started_at: &str, + ended_at: &str, + exit_code: Option, +) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE game_sessions @@ -52,13 +63,58 @@ pub async fn finish_session( Ok(()) } +pub async fn set_session_process( + pool: &SqlitePool, + started_at: &str, + pid: Option, + pid_started_at: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_sessions + SET pid = ?, pid_started_at = ? + WHERE started_at = ? + "#, + pid, + pid_started_at, + started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn unfinished_sessions(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + UnfinishedSession, + r#" + SELECT cluster_id, started_at, pid, pid_started_at + FROM game_sessions + WHERE ended_at IS NULL + ORDER BY started_at ASC + "# + ) + .fetch_all(pool) + .await +} + pub async fn insert_server_join( pool: &SqlitePool, session_started_at: &str, address: &str, port: Option, ) -> Result { - let joined_at = Utc::now().to_rfc3339(); + insert_server_join_at(pool, session_started_at, address, port, &Utc::now().to_rfc3339()).await +} + +/// Record a join at an explicit time, for spans replayed out of a game log. +pub async fn insert_server_join_at( + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, + joined_at: &str, +) -> Result { sqlx::query!( r#" INSERT INTO game_session_servers (session_started_at, address, port, joined_at) @@ -71,11 +127,19 @@ pub async fn insert_server_join( ) .execute(pool) .await?; - Ok(joined_at) + Ok(joined_at.to_string()) } pub async fn finish_server(pool: &SqlitePool, joined_at: &str) -> Result<(), sqlx::Error> { - let disconnected_at = Utc::now().to_rfc3339(); + finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await +} + +/// Close a server span at an explicit time, for spans replayed out of a log. +pub async fn finish_server_at( + pool: &SqlitePool, + joined_at: &str, + disconnected_at: &str, +) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE game_session_servers @@ -128,6 +192,23 @@ pub async fn list_session_servers( .await } +/// Drop every server span of a session, so a log replay can rewrite them from scratch. +pub async fn delete_session_servers( + pool: &SqlitePool, + session_started_at: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM game_session_servers + WHERE session_started_at = ? + "#, + session_started_at + ) + .execute(pool) + .await?; + Ok(()) +} + pub async fn server_join_counts( pool: &SqlitePool, cluster_id: i64, diff --git a/packages/oneclient_db/src/models/game_session.rs b/packages/oneclient_db/src/models/game_session.rs index 83679372..04a19ab3 100644 --- a/packages/oneclient_db/src/models/game_session.rs +++ b/packages/oneclient_db/src/models/game_session.rs @@ -23,6 +23,14 @@ pub struct NewGameSession<'a> { pub java_version: Option<&'a str>, } +#[derive(Debug, Clone, FromRow)] +pub struct UnfinishedSession { + pub cluster_id: i64, + pub started_at: String, + pub pid: Option, + pub pid_started_at: Option, +} + #[derive(Debug, Clone, FromRow)] pub struct GameSessionServerRow { pub session_started_at: String, diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 503e40ba..576f0aa0 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -16,7 +16,7 @@ pub use cluster_bundle::{ }; pub use game_session::{ GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, - SessionSpan, + SessionSpan, UnfinishedSession, }; pub use java::JavaVersionRow; pub use setting_profile::SettingProfileRow; diff --git a/uncommitted.patch b/uncommitted.patch deleted file mode 100644 index bcc84f80..00000000 --- a/uncommitted.patch +++ /dev/null @@ -1,5975 +0,0 @@ -diff --git a/Cargo.lock b/Cargo.lock -index 4bbfb520aaf2633fa51513fc3bd28d10a3c85a6b..1b2edf4f6723b081b4a862fdb9b66c6f3f3403fd 100644 -GIT binary patch -delta 6903 -zcmai3dyHL2ebyX%{aD+v7u&mw9XlIiJ0W2=^Eh*6CJJ5$wN+?K8z*T?5N4UDeUp7S -zyLW>fK}eJr6cS@j#Ha$bq_il1^g*&Jw#2dkCAP+`$*;|mvF@wR -zy2XJ(-)@~8_j4iod?pY(DU)*4a%Qyl%q6XqPok@ozl)4 -z6^yc6*X-uu{K?~FXZ_-n+wxUkBOCKoXNzrX7Fx6Q?|t=PK6GQTD}UipGB#?vD<5yS -zPwX4KJB}yivF}}WS|L`@v6}o5J_ol(IeZAN7?Y5a(OB$ElgQ`QEqUEEd3rQ}YY;)BikN?|*g2bvV-)o!}FU -zcfZn{(ataQ?r&ZM+wQ$+wdZX2mT5bCY|$Qz<86Bkt7Ee>t(lnFEr{0CbH%3okx!Gs -z`mR?;@-q*Sb@}N>$rS_L4ky>iiMWDU91 -zUOO!;0u@S=N^b?1lBKBX$9`q_n$<`3>DYKHojXGRk>=yEcyfHYwR{eQG32`mxopEq -zjO%;vbB7I=+9+0PENi8)s^V3&%qUSN8mY3C)k@-ZjMzGJP6m;TiB)Ng_LZoa*jc~p -zt3&mvKfh#i@5xQV;(R@QnVqziq*WPhX_5VC6IF>PX9abouZ(o56i#ub42?z#8hGVh -zl?-JjB_(N7B3G{Qpx6|YAv9}`HuN)YyU;E0)Nbh9tiEdTLmi5rt -zn%L`h&uipQkF3qiO5z=I3U8obqvc8qwXg@mxRU!w-=I`Sc59+rAk)J$-xh7t~rcQFh;H<3aWF -zPi(JG{mbr+HiUUT-I|U0j~^y(k~NJ)mEhC~rdgRMb_e=6+A0ySYMy``3?2IeR*_XY#-v6U%9S0uzq>rnmxLw{`OS^OCRFLHxKw*+w-=z -zzqXyIeCZR#^^<@SoLmZJ7`RO0)D;IZ1fWC+Q45)bW)@+JSlToj@&KK{y^DsskpK2F -zw7|Kj?t4@H*oz}?oN3Lr?wFc8*$T@kbhkv`W0U#Cg|;6LaRxX(9rI~Mu3UQMP_?tZ -z>xQ*!8}g{XpxvadWQBv93P4qYFagS?vLzsy@|1$B&@DixNIiEvDrV4_*pjm}qLqAo -z=E>oYZ6#}K`lC(tGtZ1H9lYtc*R2D?mlJI1xp(|<``|3Fu|4JOLad*7w?8BVP8Z79 -zFavsQq=O)kctnx_m$F2fb*da_IcQ@&kjG}OSiWu_*}m=6 -zY`3i!XIqV689!Oy#x7oZ`hknqz0S|ghw=HAKR$k9J}y9S#Qg5d3wy|b+hst_SjUuS -zrh-(9!pfwR^$qw@?O6->9Qvf4teCRUnMj$%5}HwHqsjzojX@Y8F;O*!xk@`gq|uC96mqcC -z17E2}lk6C-ulnq!`so)p=7$+Mxb*k`{uC+bL_YFBapPn~!MhIfL@EVAE73GA>ZqW> -z5_Bv0w@h4u?#oaq5oKu&`V%6q3XHN;-@PzWkN;?B)9mft>Sze2wi|aXo%zo{A#0n? -z?@IgQ|Mk5cJs9ZT)iCC`n+vB_h`T=hqdy(!>dt)TDY8Ak^Y6&@hXbm$1WR*kg0umm -zNmn>lDl3s7>zTpcqKFZv$5tHECF+|(gG=v9bU`<=*Z=qjWF@k1dG+dZ*YADpgDcm0 -zfDpS5DhbThNrYUwZNmy_<_Gt|aR_NO5D8J>GTA6Slz|mUDQcE`OPNYQZ{T1+CK{~b -z*qV?GA`w-f^4)(;MmzTpkxMqNtn$0N$XmB=>QmI&E@C0nNhwWLItSlUqAiSatYUy- -zbPW|2ABU)*R}Cu7tCB0!l&>Na;1L09Q;c^0>5b&JZR>ir}R-$1U -zv>-JO%m<;SDNsqoG19sDCh~#d`VZ#zcV4@NylXJiHW|q$ -z7Re27hHogLt;@(uoKA`4DUuhOLq&vFL1GIk@glIK@ey`V1;U><&e+}BvtgZWeUk)7SuCK23F2^|G^1cUUt@5A(fPI87aJ*6ajVC>jKYa%d@bUY}ru_18az}pYezGBld&y9} -z^-BZ!mKNEXKXErwI&&}CP)~koIN$RC8O>X#$-&MO4-i>2p~wiuo_zKLi_3atWYOid>RenSZ|1XAiGMCz`UpZQ`j);aL& -zWU4q{U->sbOzvH+>P8IAod6r>Be2eAf$zZhVM5w|dpV&XLpP?E}LLQ?1!Fx9Z$tg4N`T8zv#oco%mZ -zO3Eh|tm4o|uuqan>5X%ca}1~%9r8fS2B|?3x`fDr5{8-`$+HF7154A>;Iplh`H?%w -z?xys5$sS&8jo;d81Ej=6&$BmEe-;i&tU}&fHHL_*RGSFtc@SU%JlVq~>!X!NZufRD47Y8mjP$gC6wDe`f -zHHF&NO;gwg)xE>QzEtR98eM`@kKi7`ZR{H3`XdwTHua1A#xFJFoDGcyzGyl7h!+zR -zFr9+s7oj2s`&_}cZ;;D7C%#4gY~!vKS-dK{`050&AK}pIs&ukS$!doL!naUkrO?2B -z=PmjLV;vqG0M3>imeMsw#bFc$)d{H)>qm}^=9j-i)^@}{kdd8ho4U(qmyiZv2K2R3 -zfOw3D@E0Iz2DvP{#7%Z)o1S( -zd!zoFp%KP5%XQy%%KdLR+w~~Rqo(D%pak3TWP8;&(QrL?v3w7Vzgol`W(L!tRX3in -zkc$0FG`+SmrE1UJ+M27s^~4)C_iv~hmctA%4Br!lYDObyn232n2|43|TNunh(m~*A -z1Xs%n!X2|BBQ0{`6a0fzn92-~oFCqux8}ftuCG21`#4Nt|K -z=sJm?J3iMFlid}2Iu>Oxw~S{^aJt@bYI(%jw=qmzms7ns`_XImtOVnsMWPxw$#w0M#>7q*knzGgrQ3sZcG)&_y_u&m5qPfId5kJ+SDvh -z4VDu!{&liH-*aQJXZvzoP-W+ete2RCxj{p;9{vXIF$VZ3fkBdq(7?c8txAf%f!_xn -zBO+1(%piW?HDO_a+~dPL(>eRlckFIHR?4zl#Jyxt2S+RdOq+wP;aoB=AuAwO0P;|0 -zu#Ld4hVg-y6=MKa$~}?<$DU|yUmMwdVToP1>P5>pEO$!ppm4~%g1`fz04M>!%_vbb -zU=MJ*nb0LnFDfUj)fiR4d_WRM9Uyx+1iR}_%NljDW9+9a-_3Qt>z&2kyx}z2QYSL9 -z5`y-r6LF#0ia&(e88s$=GgPJFAt~l1C^*d6L*p+oK@9;k!6<|)4iD{CVQ^vLvXCeA -z!p*}lv0abeJ^AtFJ~&?k`Pzrb-h6h1Y`=JHUo%1K-nTeE-TS}aiFdC1F=_20vi>Bw -zVG^QSD2xnZ5RwM)T3|i^N8oHyf(Z<$%rZ(Xp}YcgbmZ{HFba`3iVgEhOcd)A4{ogI -zK6Oz(`;TOA=i|f0U#;(x0 -zAm}})NNJRT1J+g57*l|NLCeU>K#Rg(1iupd_4NxQBb|F~@k7|8?#QTvjcqH6ah+d! -zH?fDQK`?Me4x)w4g6P6uguCZ}Lu4MP3&AR#g8k#D#Dx6Bn++}vcEPwB>EokedH>qBRW!Rm2JDhVkoO51%bu6z{XPh>3-`;?X -z*e==3frM`6RaL%Lu2f2?ooJ9aqCwthOE{#_+2WIjE<91>x88NHTuF}9$^#E>LVlih -zDza|o%Pv`5KDS@`iW!yP11%0Ny6^%#>%lxk!G?es*?}rC>_tJuYp}^7KYpPEz1*as -z5qxSV?vx3!uA`GX0;qFyq#1^tyIaw|o#V&R=Vmj8RgbyxTji?>48j_qv#S>q3eWXp -z)Gy*=ILv*w(WY|F#6wSsFwAkKN?e~s(!7667okxo9DRybDv1YK%;PMHiL})+ya>Ap -zEnrw=7SJt%87zs#4~U3p7N52(Y+2%!{4m8#cFxoRGPYkyN7(d@efm01M7n4Y5D Option { -- AppAssets::get(path).map(|file| { -- match file.data { -- std::borrow::Cow::Borrowed(slice) => { -- bytes::Bytes::copy_from_slice(slice) -- }, -- std::borrow::Cow::Owned(vec) => { -- tracing::warn!("Cloning asset data for '{}'", path); -- bytes::Bytes::from(vec) -- }, -+ AppAssets::get(path).map(|file| match file.data { -+ std::borrow::Cow::Borrowed(slice) => bytes::Bytes::copy_from_slice(slice), -+ std::borrow::Cow::Owned(vec) => { -+ tracing::warn!("Cloning asset data for '{}'", path); -+ bytes::Bytes::from(vec) - } - }) - } - } -- -diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs -index 5e64e9f..63637f4 100644 ---- a/packages/oneclient_app/src/bridge/commands.rs -+++ b/packages/oneclient_app/src/bridge/commands.rs -@@ -1,4 +1,3 @@ -- - use std::path::PathBuf; - - use oneclient_core::notification::UserChoice; -@@ -12,25 +11,43 @@ use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; - pub enum BridgeCommand { - ReloadSettings, - SaveSettings, -- SetSettings { settings: LauncherSettings }, -- RecordSeenVersion { version: String }, -+ SetSettings { -+ settings: LauncherSettings, -+ }, -+ RecordSeenVersion { -+ version: String, -+ }, - MarkOnboardingSeen, -+ AcceptTos { -+ terms_version: u32, -+ privacy_version: u32, -+ }, - -- SaveGlobalProfile { profile: GameSettingsProfile }, -- UpdateGlobalProfile { update: ProfileUpdate }, -+ SaveGlobalProfile { -+ profile: GameSettingsProfile, -+ }, -+ UpdateGlobalProfile { -+ update: ProfileUpdate, -+ }, - -- CreateSettingsProfile { name: String }, -+ CreateSettingsProfile { -+ name: String, -+ }, - CreateProfileFromGlobal { - name: String, - mem_max: Option, - force_fullscreen: Option, - }, -- UpsertNamedProfile { profile: GameSettingsProfile }, -+ UpsertNamedProfile { -+ profile: GameSettingsProfile, -+ }, - UpdateNamedProfile { - name: String, - update: ProfileUpdate, - }, -- DeleteNamedProfile { name: String }, -+ DeleteNamedProfile { -+ name: String, -+ }, - - UpdateClusterProfile { - cluster_id: ClusterId, -@@ -49,8 +66,12 @@ pub enum BridgeCommand { - vendor: oneclient_core::java::JavaVendor, - major: u32, - }, -- AddCustomJavaRuntime { path: PathBuf }, -- RemoveJavaRuntime { path: String }, -+ AddCustomJavaRuntime { -+ path: PathBuf, -+ }, -+ RemoveJavaRuntime { -+ path: String, -+ }, - - ToggleNotificationCenter, - CloseNotificationCenter, -@@ -64,14 +85,20 @@ pub enum BridgeCommand { - AnswerPrompt(UserChoice), - OpenClusterUpdate(ClusterUpdateSummary), - CloseClusterUpdate, -- SendNotification { spec: NotificationSpec }, -+ SendNotification { -+ spec: NotificationSpec, -+ }, - SendTestProgress { - current: u64, - total: u64, - }, - -- LaunchCluster { cluster_id: ClusterId }, -- KillCluster { cluster_id: ClusterId }, -+ LaunchCluster { -+ cluster_id: ClusterId, -+ }, -+ KillCluster { -+ cluster_id: ClusterId, -+ }, - DismissGameError, - - ImportLocalFile { -@@ -91,7 +118,9 @@ pub enum BridgeCommand { - bundle_name: String, - skip_compatibility: bool, - }, -- ApplyBundleUpdates { cluster_id: ClusterId }, -+ ApplyBundleUpdates { -+ cluster_id: ClusterId, -+ }, - SyncBundles, - - ImportLauncher { -diff --git a/packages/oneclient_app/src/bridge/mod.rs b/packages/oneclient_app/src/bridge/mod.rs -index 54cfff5..a6b5cce 100644 ---- a/packages/oneclient_app/src/bridge/mod.rs -+++ b/packages/oneclient_app/src/bridge/mod.rs -@@ -1,4 +1,3 @@ -- - mod commands; - mod runtime; - mod snapshot; -diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs -index 7f3f70b..919ee01 100644 ---- a/packages/oneclient_app/src/bridge/runtime.rs -+++ b/packages/oneclient_app/src/bridge/runtime.rs -@@ -9,8 +9,8 @@ use tokio::sync::{mpsc, watch}; - - use crate::components::IconType; - use crate::notifications::{ -- ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, -- NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, -+ ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, -+ NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, - }; - - use super::commands::BridgeCommand; -@@ -243,6 +243,7 @@ impl CoreBridgeRuntime { - LauncherState::initialize(notifier).await?; - - oneclient_core::status::start(); -+ oneclient_core::plus::start(); - - let data_dir = oneclient_core::paths::launcher_dir() - .map(|p| p.display().to_string()) -@@ -277,10 +278,12 @@ impl CoreBridgeRuntime { - snapshots.settings.error = None; - let state = LauncherState::get()?; - let loaded = store::load_settings(Some(&state.services.notifier)).await; -+ let discord_enabled = loaded.discord_enabled; - { - let mut lock = state.settings.write(); - *lock = loaded; - } -+ state.discord.set_enabled(discord_enabled); - refresh_settings(snapshots)?; - } - BridgeCommand::SaveSettings => { -@@ -298,6 +301,7 @@ impl CoreBridgeRuntime { - mutate_settings(snapshots, |s| *s = settings)?; - let state = LauncherState::get()?; - let snapshot = state.settings.read().clone(); -+ state.discord.set_enabled(snapshot.discord_enabled); - if let Err(err) = save_settings(&snapshot).await { - snapshots.settings.error = Some(err.to_string()); - } -@@ -319,6 +323,20 @@ impl CoreBridgeRuntime { - snapshots.settings.error = Some(err.to_string()); - } - } -+ BridgeCommand::AcceptTos { -+ terms_version, -+ privacy_version, -+ } => { -+ mutate_settings(snapshots, |settings| { -+ settings.accepted_tos_version = terms_version; -+ settings.accepted_privacy_version = privacy_version; -+ })?; -+ let state = LauncherState::get()?; -+ let snapshot = state.settings.read().clone(); -+ if let Err(err) = save_settings(&snapshot).await { -+ snapshots.settings.error = Some(err.to_string()); -+ } -+ } - - BridgeCommand::SaveGlobalProfile { profile } => { - let state = LauncherState::get()?; -@@ -609,20 +627,18 @@ impl CoreBridgeRuntime { - version_id, - } => { - let state = LauncherState::get()?; -- match install_package(&state, provider, &project_id, &version_id, cluster_id).await -- { -- Ok(()) => { -- state -- .services -- .notifier -- .send_info("Installed", "Package added to cluster"); -- bump_clusters_generation(snapshots); -+ tokio::spawn(async move { -+ let notifier = state.services.notifier.clone(); -+ match install_package(&state, provider, &project_id, &version_id, cluster_id) -+ .await -+ { -+ Ok(name) => { -+ notifier.send_info("Installed", &format!("Added {name}")); -+ notifier.invalidate_clusters(); -+ } -+ Err(err) => notifier.send_error("Install failed", &err.to_string()), - } -- Err(err) => state -- .services -- .notifier -- .send_error("Install failed", &err.to_string()), -- } -+ }); - } - - BridgeCommand::InstallBundle { -@@ -631,15 +647,24 @@ impl CoreBridgeRuntime { - skip_compatibility, - } => { - let state = LauncherState::get()?; -- oneclient_core::install_bundle( -- cluster_id, -- &bundle_name, -- skip_compatibility, -- state.bundles.as_ref(), -- &state.services, -- ) -- .await?; -- bump_clusters_generation(snapshots); -+ tokio::spawn(async move { -+ let notifier = state.services.notifier.clone(); -+ match oneclient_core::install_bundle( -+ cluster_id, -+ &bundle_name, -+ skip_compatibility, -+ state.bundles.as_ref(), -+ &state.services, -+ ) -+ .await -+ { -+ Ok(_) => { -+ notifier.send_info("Installed", &format!("Added {bundle_name}")); -+ notifier.invalidate_clusters(); -+ } -+ Err(err) => notifier.send_error("Install failed", &err.to_string()), -+ } -+ }); - } - BridgeCommand::ApplyBundleUpdates { cluster_id } => { - let state = LauncherState::get()?; -@@ -792,7 +817,7 @@ async fn install_package( - project_id: &str, - version_id: &str, - cluster_id: i64, --) -> anyhow::Result<()> { -+) -> anyhow::Result { - let provider_impl = state.services.packages.get(provider)?; - let project = provider_impl - .get_project(project_id, &state.services) -@@ -823,7 +848,7 @@ async fn install_package( - child.finish(); - session.finish(); - result?; -- Ok(()) -+ Ok(project.name) - } - - fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: LaunchStage) { -diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs -index baa7908..b878408 100644 ---- a/packages/oneclient_app/src/bridge/snapshot.rs -+++ b/packages/oneclient_app/src/bridge/snapshot.rs -@@ -32,10 +32,7 @@ impl GameSnapshot { - } - - pub fn logs_for(&self, cluster_id: i64) -> Arc>> { -- self.logs -- .get(&cluster_id) -- .cloned() -- .unwrap_or_default() -+ self.logs.get(&cluster_id).cloned().unwrap_or_default() - } - } - -diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs -index 773865f..9e703b5 100644 ---- a/packages/oneclient_app/src/components/active_cluster_panel.rs -+++ b/packages/oneclient_app/src/components/active_cluster_panel.rs -@@ -60,7 +60,9 @@ impl Component for ActiveClusterPanel { - let p = intro.get().value(); - let slide_x = (p - 1.0) * 48.0; - -- let parsed = active.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); -+ let parsed = active -+ .as_ref() -+ .and_then(|c| parse_mc_version(&c.mc_version)); - let metadata = use_version_metadata( - parsed.as_ref().map(|p| p.major), - parsed.and_then(|p| p.minor), -@@ -114,7 +116,11 @@ impl Component for ActiveClusterPanel { - .spacing(8.) - .cross_align(Alignment::Center) - .margin(Gaps::new(8., 0., 0., 0.)) -- .child(launch_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) -+ .child(launch_button( -+ cluster_id, -+ dispatch, -+ launch_button_state(&game, cluster_id), -+ )) - .child(cluster_settings_button(cluster_id)), - ) - } -diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs -index cf9ac79..61a3d4e 100644 ---- a/packages/oneclient_app/src/components/button.rs -+++ b/packages/oneclient_app/src/components/button.rs -@@ -3,7 +3,10 @@ use std::borrow::Cow; - - use freya::prelude::*; - --use crate::{components::{IconType, Icon}, theme::colors}; -+use crate::{ -+ components::{Icon, IconType}, -+ theme::colors, -+}; - - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] - pub enum ButtonVariant { -@@ -285,8 +288,7 @@ impl Component for Button { - - rect.get_style().clone_from(&self.style); - -- rect = rect.background(background) -- .color(foreground); -+ rect = rect.background(background).color(foreground); - - if let Some(border) = border_color { - rect = rect.border( -@@ -420,7 +422,11 @@ fn size_layout(size: ButtonSize) -> (Gaps, CornerRadius, f32, Size, Size) { - pub fn link_button() -> Button { - Button::new() - .primary() -- .child(Icon::new(IconType::LinkExternal01).size(14.).color(colors::fg_primary())) -+ .child( -+ Icon::new(IconType::LinkExternal01) -+ .size(14.) -+ .color(colors::fg_primary()), -+ ) - .child(label().text("Open")) - } - -@@ -433,4 +439,4 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { - crate::platform::open_url(&folder.to_string_lossy()); - }) - .child(Icon::new(IconType::Folder).size(16.)) --} -\ No newline at end of file -+} -diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs -index 7880627..3c5f35a 100644 ---- a/packages/oneclient_app/src/components/cluster_update_popup.rs -+++ b/packages/oneclient_app/src/components/cluster_update_popup.rs -@@ -49,7 +49,13 @@ fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> im - .corner_radius(CornerRadius::new_all(16.)) - .background(CARD_BG) - .border(border_all_color(1., colors::component_border())) -- .shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0)))) -+ .shadow(Shadow::from(( -+ 0., -+ 18., -+ 52., -+ 0., -+ Color::from_argb(150, 0, 0, 0), -+ ))) - .child(banner(summary)) - .child(content(summary, dispatch)) - } -@@ -93,7 +99,11 @@ fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { - .horizontal() - .cross_align(Alignment::Center) - .spacing(5.) -- .child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE)) -+ .child( -+ Icon::new(IconType::RefreshCw01) -+ .size(12.) -+ .color(Color::WHITE), -+ ) - .child( - label() - .text("Updated") -diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs -index f40a0eb..5ebecd2 100644 ---- a/packages/oneclient_app/src/components/dropdown.rs -+++ b/packages/oneclient_app/src/components/dropdown.rs -@@ -81,8 +81,8 @@ impl Component for Dropdown { - }; - - let visible = options.len().min(MAX_VISIBLE_OPTIONS); -- let list_h = visible as f32 * OPTION_HEIGHT -- + visible.saturating_sub(1) as f32 * OPTION_SPACING; -+ let list_h = -+ visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING; - - let offset_y = match (button_area(), list_size()) { - (Some(button), Some(list)) => { -@@ -141,10 +141,10 @@ impl Component for Dropdown { - .font_size(12.) - .color(colors::fg_primary()), - ) -- .child(Icon::new( -- IconType::ChevronDown) -- .size(14.) -- .color(colors::fg_secondary()), -+ .child( -+ Icon::new(IconType::ChevronDown) -+ .size(14.) -+ .color(colors::fg_secondary()), - ), - ) - .maybe_child(is_open.then(|| { -diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs -index 4c25af2..636734c 100644 ---- a/packages/oneclient_app/src/components/dynamic_art.rs -+++ b/packages/oneclient_app/src/components/dynamic_art.rs -@@ -83,9 +83,12 @@ pub fn use_art_bytes( - let state = reader.state(); - match (&art_url, &*state) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) -- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { -- (format!("{max_edge}|{url}"), bytes.clone()) -- } -+ | ( -+ Some(url), -+ QueryStateData::Loading { -+ res: Some(Ok(bytes)), -+ }, -+ ) => (format!("{max_edge}|{url}"), bytes.clone()), - _ => ( - format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), - fallback.read().clone(), -diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs -index 80f531f..306991f 100644 ---- a/packages/oneclient_app/src/components/log_viewer.rs -+++ b/packages/oneclient_app/src/components/log_viewer.rs -@@ -1,4 +1,3 @@ -- - use std::sync::{Arc, LazyLock}; - - use freya::prelude::*; -@@ -289,12 +288,11 @@ fn auto_scroll_toggle(mut auto_scroll: State) -> impl IntoElement { - } else { - colors::fg_secondary() - })) -- .child( -- label() -- .text("Auto-scroll") -- .font_size(11.) -- .color(if on { colors::brand() } else { colors::fg_secondary() }), -- ) -+ .child(label().text("Auto-scroll").font_size(11.).color(if on { -+ colors::brand() -+ } else { -+ colors::fg_secondary() -+ })) - .into_element() - } - -@@ -308,11 +306,15 @@ struct PosGeom { - corrected_y: f32, - } - --fn pos_at_screen(lines: &[Arc], geom: PosGeom, screen_x: f32, screen_y: f32) -> (usize, usize) { -+fn pos_at_screen( -+ lines: &[Arc], -+ geom: PosGeom, -+ screen_x: f32, -+ screen_y: f32, -+) -> (usize, usize) { - let region_y = screen_y - geom.viewport_top_px; - let band_y = region_y - geom.corrected_y - BODY_PAD_TOP; -- let line = -- ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; -+ let line = ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; - - let text_origin_x = ROW_PAD_X + geom.num_col_w + NUM_GAP; - let local_x = screen_x - geom.viewport_left_px - geom.corrected_x - text_origin_x; -diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/major_version_card.rs -index b8c1600..641b6ff 100644 ---- a/packages/oneclient_app/src/components/major_version_card.rs -+++ b/packages/oneclient_app/src/components/major_version_card.rs -@@ -55,11 +55,7 @@ impl Component for MajorVersionCard { - let on_press = self.on_press.clone(); - - let opacity = if selected || hovered || focused { -- if selected { -- 1.0 -- } else { -- 0.85 -- } -+ if selected { 1.0 } else { 0.85 } - } else { - 0.6 - }; -@@ -108,7 +104,10 @@ impl Component for MajorVersionCard { - .main_align(Alignment::SpaceBetween) - .corner_radius(CornerRadius::new_all(12.)) - .cross_align(Alignment::Start) -- .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) -+ .border( -+ border_all_color(1., border_color) -+ .alignment(BorderAlignment::Inner), -+ ) - .layer(Layer::Relative(3)) - .background( - LinearGradient::new() -diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs -index b84e4df..27309f4 100644 ---- a/packages/oneclient_app/src/components/microsoft_login.rs -+++ b/packages/oneclient_app/src/components/microsoft_login.rs -@@ -84,7 +84,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { - return; - } - -- // open the browser immediately -+ // open the browser immediately - platform::open_url(session.auth_url()); - finish.mutate(session.clone()); - pending_login.set(Some(session)); -@@ -100,7 +100,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { - } - }); - -- // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress -+ // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress - let locked = status.as_ref().is_some_and(|s| s.current > 0); - let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); - let pending = in_flight && !*cancelled.read(); -diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs -index 898dfb7..d902086 100644 ---- a/packages/oneclient_app/src/components/navbar/app_navbar.rs -+++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs -@@ -37,7 +37,7 @@ impl Component for Navbar { - .window_drag() - .width(Size::window_percent(100.)) - .height(Size::px(theme::NAVBAR_HEIGHT_PX)) -- .position(Position::new_absolute().top(0.).left(0.).right(0.)) -+ .position(Position::new_absolute().top(0.).left(0.).right(0.)), - ) - } - } -@@ -95,8 +95,8 @@ struct NavLink { - impl Component for NavLink { - fn render(&self) -> impl IntoElement { - let mut hovering = use_state(|| false); -- let a11y_id = use_a11y(); -- let focused = use_focus(a11y_id); -+ let a11y_id = use_a11y(); -+ let focused = use_focus(a11y_id); - - let active = self.active; - let target = self.target.clone(); -@@ -125,12 +125,12 @@ impl Component for NavLink { - .cross_align(Alignment::Center) - .spacing(2.) - .width(Size::px(nav_label.len() as f32 * 10. + 10.)) -- // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. -- // This basically fools freya into thinking it indeed has a box and properly handles pointer events -- .background(Color::RED.with_a(0)) -- .a11y_id(a11y_id) -- .a11y_focusable(true) -- .a11y_role(AccessibilityRole::Button) -+ // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. -+ // This basically fools freya into thinking it indeed has a box and properly handles pointer events -+ .background(Color::RED.with_a(0)) -+ .a11y_id(a11y_id) -+ .a11y_focusable(true) -+ .a11y_role(AccessibilityRole::Button) - .on_all_press(move |e: Event| { - e.prevent_default(); - let _ = RouterContext::get().push(target.clone()); -diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs -index 4742b31..7e6d132 100644 ---- a/packages/oneclient_app/src/components/package_row.rs -+++ b/packages/oneclient_app/src/components/package_row.rs -@@ -391,9 +391,12 @@ fn remote_icon( - let reader = icon_query.read(); - let loaded = match (icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) -- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { -- Some((url.clone(), bytes.clone())) -- } -+ | ( -+ Some(url), -+ QueryStateData::Loading { -+ res: Some(Ok(bytes)), -+ }, -+ ) => Some((url.clone(), bytes.clone())), - _ => None, - }; - -@@ -420,7 +423,11 @@ fn icon_box(icon: IconType, size: f32) -> Element { - .height(Size::px(size)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) -- .child(Icon::new(icon).size(size * 0.4).color(colors::fg_secondary())) -+ .child( -+ Icon::new(icon) -+ .size(size * 0.4) -+ .color(colors::fg_secondary()), -+ ) - .into_element() - } - -@@ -436,7 +443,10 @@ fn meta_size(size: u64) -> impl IntoElement { - } - - fn provider_badge(provider: ProviderId) -> Element { -- badge(Icon::new(provider).size(12.).into_element(), provider.to_string()) -+ badge( -+ Icon::new(provider).size(12.).into_element(), -+ provider.to_string(), -+ ) - } - - fn local_badge() -> Element { -diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs -index 1f5054a..b7151b9 100644 ---- a/packages/oneclient_app/src/components/player_model.rs -+++ b/packages/oneclient_app/src/components/player_model.rs -@@ -128,7 +128,7 @@ impl Component for PlayerModel { - - let (w, h) = (ctx.size.width, ctx.size.height); - -- if w <= 0.0 || h <= 0.0 { -+ if w <= 0.0 || h <= 0.0 { - return; - } - -diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs -index 3a3c80b..93a9d85 100644 ---- a/packages/oneclient_app/src/components/recents_row.rs -+++ b/packages/oneclient_app/src/components/recents_row.rs -@@ -49,7 +49,9 @@ impl Component for RecentsRow { - conf.on_creation(OnCreation::Run); - conf.on_change(OnChange::Rerun); - let total = CARD_MS + (count.saturating_sub(1) as u64) * STAGGER_MS; -- AnimNum::new(0., 1.).time(total.max(1)).function(Function::Linear) -+ AnimNum::new(0., 1.) -+ .time(total.max(1)) -+ .function(Function::Linear) - }); - let progress = intro.get().value(); - -@@ -112,11 +114,7 @@ impl Component for ClusterCard { - let eased = 1.0 - (1.0 - local).powi(3); - let rise = (1.0 - eased) * CARD_RISE_PX; - -- let title = format!( -- "{} {}", -- self.cluster.mc_version, -- self.cluster.mc_loader -- ); -+ let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); - - let cluster_id = self.cluster.id; - let on_press = move |_| { -diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs -index 1eb5a31..cdb6539 100644 ---- a/packages/oneclient_app/src/components/screenshot_viewer.rs -+++ b/packages/oneclient_app/src/components/screenshot_viewer.rs -@@ -102,11 +102,15 @@ impl Component for ScreenshotViewer { - .height(Size::fill()) - .child(preview), - ) -- .child(chevron_btn(IconType::ArrowRight, has_next, move |_| { -- if idx + 1 < len { -- index.set(idx + 1); -- } -- })), -+ .child(chevron_btn( -+ IconType::ArrowRight, -+ has_next, -+ move |_| { -+ if idx + 1 < len { -+ index.set(idx + 1); -+ } -+ }, -+ )), - ) - .child( - rect() -@@ -119,7 +123,9 @@ impl Component for ScreenshotViewer { - .secondary() - .on_press(move |_| { - if let Some(dir) = open_path.parent() { -- crate::platform::open_url(&dir.to_string_lossy()); -+ crate::platform::open_url( -+ &dir.to_string_lossy(), -+ ); - } - }) - .child(Icon::new(IconType::Folder).size(14.)) -@@ -198,8 +204,12 @@ fn header_row( - len: usize, - on_close: impl Into>>, - ) -> impl IntoElement { -- let mut meta = format!("{} • {}", format_size(info.size_bytes), fmt_date(info.created)); -- -+ let mut meta = format!( -+ "{} • {}", -+ format_size(info.size_bytes), -+ fmt_date(info.created) -+ ); -+ - if let Some(res) = info.resolution { - meta = format!("{} • {}", format_res(res), meta); - } -diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs -index ba49b6c..471e973 100644 ---- a/packages/oneclient_app/src/components/scrollview.rs -+++ b/packages/oneclient_app/src/components/scrollview.rs -@@ -314,7 +314,11 @@ impl ScrollArea { - let shift = *shift_held.read(); - - let h_delta = if horizontal { -- if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x } -+ if shift && e.delta_x == 0.0 { -+ e.delta_y -+ } else { -+ e.delta_x -+ } - } else { - 0.0 - }; -diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs -index f79d340..1aa5d32 100644 ---- a/packages/oneclient_app/src/components/tab_bar.rs -+++ b/packages/oneclient_app/src/components/tab_bar.rs -@@ -19,7 +19,7 @@ impl TabItem { - } - } - -- #[allow(dead_code)] -+ #[allow(dead_code)] - pub fn count_text(mut self, text: impl Into) -> Self { - self.count = Some(text.into()); - self -diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs -index 2ea7c95..0976248 100644 ---- a/packages/oneclient_app/src/components/toasts.rs -+++ b/packages/oneclient_app/src/components/toasts.rs -@@ -32,7 +32,6 @@ impl Variant { - Self::Message - } - } -- - } - - fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { -diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs -index 2c3071f..3bb1a5b 100644 ---- a/packages/oneclient_app/src/components/update_prompt.rs -+++ b/packages/oneclient_app/src/components/update_prompt.rs -@@ -19,7 +19,7 @@ impl Component for UpdatePromptOverlay { - let Some(prompt) = snapshot.pending_prompt.clone() else { - return rect().into_element(); - }; -- -+ - if prompt.kind != PromptKind::Update { - return rect().into_element(); - } -diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs -index 4b2b07e..23f6ae5 100644 ---- a/packages/oneclient_app/src/constants.rs -+++ b/packages/oneclient_app/src/constants.rs -@@ -1,2 +1,2 @@ - pub const APP_ID: &str = "org.polyfrost.OneClient"; --pub const WINDOW_TITLE: &str = "OneClient"; -\ No newline at end of file -+pub const WINDOW_TITLE: &str = "OneClient"; -diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs -index 488dd8b..0b042bf 100644 ---- a/packages/oneclient_app/src/hooks/dispatch.rs -+++ b/packages/oneclient_app/src/hooks/dispatch.rs -@@ -42,6 +42,13 @@ impl BridgeDispatch { - self.send(BridgeCommand::MarkOnboardingSeen); - } - -+ pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { -+ self.send(BridgeCommand::AcceptTos { -+ terms_version, -+ privacy_version, -+ }); -+ } -+ - pub fn import_launcher( - &self, - source: oneclient_core::MigrationSource, -@@ -64,9 +71,7 @@ impl BridgeDispatch { - } - - pub fn create_settings_profile(&self, name: impl Into) { -- self.send(BridgeCommand::CreateSettingsProfile { -- name: name.into(), -- }); -+ self.send(BridgeCommand::CreateSettingsProfile { name: name.into() }); - } - - pub fn create_profile_from_global( -@@ -94,20 +99,11 @@ impl BridgeDispatch { - } - - pub fn delete_named_profile(&self, name: impl Into) { -- self.send(BridgeCommand::DeleteNamedProfile { -- name: name.into(), -- }); -+ self.send(BridgeCommand::DeleteNamedProfile { name: name.into() }); - } - -- pub fn update_cluster_profile( -- &self, -- cluster_id: ClusterId, -- update: ProfileUpdate, -- ) { -- self.send(BridgeCommand::UpdateClusterProfile { -- cluster_id, -- update, -- }); -+ pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { -+ self.send(BridgeCommand::UpdateClusterProfile { cluster_id, update }); - } - - pub fn create_and_assign_cluster_profile( -@@ -121,11 +117,7 @@ impl BridgeDispatch { - }); - } - -- pub fn set_cluster_loader_version( -- &self, -- cluster_id: ClusterId, -- version: impl Into, -- ) { -+ pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { - self.send(BridgeCommand::SetClusterLoaderVersion { - cluster_id, - version: version.into(), -@@ -318,6 +310,7 @@ impl NotificationBuilder { - } - - pub fn send(self) { -- self.dispatch.send(BridgeCommand::SendNotification { spec: self.spec }); -+ self.dispatch -+ .send(BridgeCommand::SendNotification { spec: self.spec }); - } - } -diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs -index 9062362..87b90ee 100644 ---- a/packages/oneclient_app/src/hooks/mod.rs -+++ b/packages/oneclient_app/src/hooks/mod.rs -@@ -19,30 +19,30 @@ pub use queries::{ - CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, - ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, - OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, -- SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, -- UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, -- accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, -- changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, -+ SetDefaultAccountKeys, TermsQuery, UploadLogKeys, UploadLogMutation, UseLogAction, -+ UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, -+ VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, -+ category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, - content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, - invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, - java_runtimes, loader_versions, login_code_already_handled, migration_detection, - mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, - pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items, -- search_pending, search_total, try_account, try_accounts, try_cluster_analytics, -- try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile, -- try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account, -- use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, -- use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, -- use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, -- use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, -- use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, -- use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action, -- use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, -- use_package_categories, use_package_meta_batch, use_package_project, use_package_search, -- use_package_versions, use_player_profile, use_player_skin, use_provider_versions, -- use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, -- use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list, -- versions_total, -+ search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account, -+ try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, -+ try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account, -+ use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, -+ use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, -+ use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content, -+ use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, -+ use_cluster_settings, use_clusters, use_current_account, use_default_account, -+ use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, -+ use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration, -+ use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, -+ use_package_project, use_package_search, use_package_versions, use_player_profile, -+ use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts, -+ use_remove_account, use_screenshot_action, use_set_default_account, use_terms, use_upload_log, -+ use_version_metadata, use_versions, version_list, versions_total, - }; - - use crate::{ -diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs -index 1d433fa..9181aa5 100644 ---- a/packages/oneclient_app/src/hooks/queries/analytics.rs -+++ b/packages/oneclient_app/src/hooks/queries/analytics.rs -@@ -2,7 +2,6 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; - use oneclient_core::LauncherError; - use oneclient_core::game::{Analytics, cluster_analytics, global_analytics}; - -- - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub struct GlobalAnalyticsKeys; - -@@ -33,7 +32,6 @@ pub fn try_global_analytics(query: &UseQuery) -> Option Some(list.clone()), -- QueryStateData::Loading { res: Some(Ok(list)) } => Some(list.clone()), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => Some(list.clone()), - _ => None, - } - } -@@ -75,12 +77,8 @@ impl QueryCapability for BundlesWithStatusQuery { - async fn run(&self, keys: &Self::Keys) -> Result { - let _ = keys; - let state = LauncherState::get()?; -- get_bundles_with_update_status( -- self.cluster_id, -- state.bundles.as_ref(), -- &state.services, -- ) -- .await -+ get_bundles_with_update_status(self.cluster_id, state.bundles.as_ref(), &state.services) -+ .await - } - } - -@@ -90,7 +88,9 @@ pub fn bundles_with_status_items( - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), -- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list.clone(), - _ => Vec::new(), - } - } -diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs -index 04b3f89..c9ad5da 100644 ---- a/packages/oneclient_app/src/hooks/queries/changelog.rs -+++ b/packages/oneclient_app/src/hooks/queries/changelog.rs -@@ -1,5 +1,7 @@ - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; --use oneclient_core::{fetch_changelog, parse_changelog, ChangelogGroup, LauncherError, LauncherState}; -+use oneclient_core::{ -+ ChangelogGroup, LauncherError, LauncherState, fetch_changelog, parse_changelog, -+}; - - use crate::hooks::use_settings_snapshot; - -@@ -32,15 +34,14 @@ pub fn use_changelog() -> UseQuery { - .trim() - .to_string(); - -- use_query(Query::new( -- ChangelogKeys { meta_url_base }, -- ChangelogQuery, -- )) -+ use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) - } - - pub fn changelog_groups(query: &UseQuery) -> Option> { - match &*query.read().state() { -- QueryStateData::Settled { res: Ok(groups), .. } => Some(groups.clone()), -+ QueryStateData::Settled { -+ res: Ok(groups), .. -+ } => Some(groups.clone()), - QueryStateData::Loading { - res: Some(Ok(groups)), - } => Some(groups.clone()), -diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs -index 4e72732..2159fcb 100644 ---- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs -+++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs -@@ -1,4 +1,3 @@ -- - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; - use oneclient_core::packages::{ContentType, PackageStore}; - use oneclient_core::{LauncherError, LauncherState, LinkedArtifactInfo}; -@@ -51,7 +50,9 @@ pub fn cluster_content_items(query: &UseQuery) -> Vec list.clone(), -- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list.clone(), - _ => Vec::new(), - } - } -diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs -index 0f09711..61f487f 100644 ---- a/packages/oneclient_app/src/hooks/queries/clusters.rs -+++ b/packages/oneclient_app/src/hooks/queries/clusters.rs -@@ -1,7 +1,7 @@ - use freya::query::{Query, QueryCapability, UseQuery, use_query}; --use oneclient_core::clusters::{Cluster, ClusterManager}; - use oneclient_core::LauncherError; - use oneclient_core::LauncherState; -+use oneclient_core::clusters::{Cluster, ClusterManager}; - - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub struct ListClustersQuery; -@@ -23,5 +23,3 @@ impl QueryCapability for ListClustersQuery { - pub fn use_clusters() -> UseQuery { - use_query(Query::new(ListClustersKeys, ListClustersQuery)) - } -- -- -diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs -index e2c7b7e..df34a5c 100644 ---- a/packages/oneclient_app/src/hooks/queries/java.rs -+++ b/packages/oneclient_app/src/hooks/queries/java.rs -@@ -1,6 +1,4 @@ --use freya::query::{ -- QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query, --}; -+use freya::query::{QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query}; - use oneclient_core::LauncherState; - use oneclient_core::java::{AvailableJava, JavaManager, JavaRuntime, JavaVendor}; - -@@ -31,7 +29,9 @@ pub fn java_runtimes(query: &UseQuery) -> Vec list.clone(), -- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list.clone(), - _ => Vec::new(), - } - } -@@ -68,14 +68,14 @@ pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery, --) -> (Vec, bool) { -+pub fn provider_versions(query: &UseQuery) -> (Vec, bool) { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => (list.clone(), false), - QueryStateData::Settled { res: Err(_), .. } => (Vec::new(), false), -- QueryStateData::Loading { res: Some(Ok(list)) } => (list.clone(), true), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => (list.clone(), true), - _ => (Vec::new(), true), - } - } -diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs -index eb1ba9f..4d51fdb 100644 ---- a/packages/oneclient_app/src/hooks/queries/logs.rs -+++ b/packages/oneclient_app/src/hooks/queries/logs.rs -@@ -89,7 +89,9 @@ where - { - match &*query.read().state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), -- QueryStateData::Loading { res: Some(Ok(value)) } => Some(value.clone()), -+ QueryStateData::Loading { -+ res: Some(Ok(value)), -+ } => Some(value.clone()), - _ => None, - } - } -diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs -index ac8cd27..84da2cc 100644 ---- a/packages/oneclient_app/src/hooks/queries/mod.rs -+++ b/packages/oneclient_app/src/hooks/queries/mod.rs -@@ -14,6 +14,7 @@ mod player_profile; - mod screenshots; - mod settings_profiles; - mod skin; -+mod tos; - mod version_metadata; - mod versions; - -@@ -69,5 +70,6 @@ pub use settings_profiles::{ - use_named_profiles, - }; - pub use skin::use_player_skin; -+pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; - pub use version_metadata::{pick_version_metadata, use_version_metadata}; - pub use versions::{loader_versions, use_loader_versions, use_versions}; -diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs -index b2f05da..54d59e0 100644 ---- a/packages/oneclient_app/src/hooks/queries/mutations.rs -+++ b/packages/oneclient_app/src/hooks/queries/mutations.rs -@@ -1,4 +1,3 @@ -- - use std::path::PathBuf; - - use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; -@@ -37,9 +36,18 @@ pub struct ClusterMutation; - - #[derive(Clone, PartialEq, Eq, Hash)] - pub enum ClusterAction { -- ToggleArtifact { cluster_id: ClusterId, hash: String }, -- RemoveArtifact { cluster_id: ClusterId, hash: String }, -- RemoveBundlePackageFromDisk { cluster_id: ClusterId, hash: String }, -+ ToggleArtifact { -+ cluster_id: ClusterId, -+ hash: String, -+ }, -+ RemoveArtifact { -+ cluster_id: ClusterId, -+ hash: String, -+ }, -+ RemoveBundlePackageFromDisk { -+ cluster_id: ClusterId, -+ hash: String, -+ }, - SetBundlePackageEnabled { - cluster_id: ClusterId, - bundle_name: String, -@@ -106,10 +114,14 @@ impl MutationCapability for ClusterMutation { - ClusterAction::SetDedicatedDir { - cluster_id, - dedicated, -- } => oneclient_core::clusters::ClusterManager::set_dedicated_dir( -- &state, *cluster_id, *dedicated, -- ) -- .await, -+ } => { -+ oneclient_core::clusters::ClusterManager::set_dedicated_dir( -+ &state, -+ *cluster_id, -+ *dedicated, -+ ) -+ .await -+ } - }; - result.map_err(|e| e.to_string()) - } -diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs -index 85c427a..1ca3c6b 100644 ---- a/packages/oneclient_app/src/hooks/queries/packages.rs -+++ b/packages/oneclient_app/src/hooks/queries/packages.rs -@@ -1,4 +1,3 @@ -- - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; - use oneclient_core::packages::domain::GameLoader; - use oneclient_core::packages::types::{ -@@ -48,7 +47,8 @@ impl QueryCapability for PackageSearchQuery { - &SearchFilters { - query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()), - content_type: Some(keys.content_type), -- game_versions: (!keys.game_versions.is_empty()).then(|| keys.game_versions.clone()), -+ game_versions: (!keys.game_versions.is_empty()) -+ .then(|| keys.game_versions.clone()), - loaders: (!keys.loaders.is_empty()).then(|| keys.loaders.clone()), - categories: (!keys.categories.is_empty()).then(|| keys.categories.clone()), - sort: Some(keys.sort), -@@ -91,7 +91,9 @@ pub fn search_items(query: &UseQuery) -> Vec - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(), -- QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(page)), -+ } => page.items.clone(), - _ => Vec::new(), - } - } -@@ -100,13 +102,18 @@ pub fn search_total(query: &UseQuery) -> usize { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.total, -- QueryStateData::Loading { res: Some(Ok(page)) } => page.total, -+ QueryStateData::Loading { -+ res: Some(Ok(page)), -+ } => page.total, - _ => 0, - } - } - - pub fn search_pending(query: &UseQuery) -> bool { -- matches!(&*query.read().state(), QueryStateData::Pending | QueryStateData::Loading { .. }) -+ matches!( -+ &*query.read().state(), -+ QueryStateData::Pending | QueryStateData::Loading { .. } -+ ) - } - - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -@@ -126,7 +133,9 @@ impl QueryCapability for PackageProjectQuery { - async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let provider = state.services.packages.get(keys.provider)?; -- provider.get_project(&keys.project_id, &state.services).await -+ provider -+ .get_project(&keys.project_id, &state.services) -+ .await - } - } - -@@ -146,8 +155,12 @@ pub fn use_package_project( - pub fn project_detail(query: &UseQuery) -> Option { - let reader = query.read(); - match &*reader.state() { -- QueryStateData::Settled { res: Ok(detail), .. } => Some(detail.clone()), -- QueryStateData::Loading { res: Some(Ok(detail)) } => Some(detail.clone()), -+ QueryStateData::Settled { -+ res: Ok(detail), .. -+ } => Some(detail.clone()), -+ QueryStateData::Loading { -+ res: Some(Ok(detail)), -+ } => Some(detail.clone()), - _ => None, - } - } -@@ -260,7 +273,9 @@ pub fn version_list(query: &UseQuery) -> Vec page.items.clone(), -- QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(page)), -+ } => page.items.clone(), - _ => Vec::new(), - } - } -@@ -269,7 +284,9 @@ pub fn versions_total(query: &UseQuery) -> usize { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.total, -- QueryStateData::Loading { res: Some(Ok(page)) } => page.total, -+ QueryStateData::Loading { -+ res: Some(Ok(page)), -+ } => page.total, - _ => 0, - } - } -@@ -315,7 +332,9 @@ pub fn category_list(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), -- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list.clone(), - _ => Vec::new(), - } - } -diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs -index 5bb3d3c..df42b8b 100644 ---- a/packages/oneclient_app/src/hooks/queries/player_profile.rs -+++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs -@@ -39,7 +39,10 @@ impl QueryCapability for FetchPlayerProfileQuery { - // } - } - --pub fn use_player_profile(uuid: impl Into, access_token: Option>) -> UseQuery { -+pub fn use_player_profile( -+ uuid: impl Into, -+ access_token: Option>, -+) -> UseQuery { - use_query( - Query::new( - PlayerProfileQueryKeys { -diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs -index 3b01dd9..214aa56 100644 ---- a/packages/oneclient_app/src/hooks/queries/screenshots.rs -+++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs -@@ -12,7 +12,6 @@ use tokio::sync::Semaphore; - - static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); - -- - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub struct ClusterScreenshotsKeys { - pub cluster_id: i64, -@@ -96,7 +95,6 @@ pub fn try_cluster_screenshots( - } - } - -- - pub async fn invalidate_screenshots_queries() { - QueriesStorage::::try_invalidate_all().await; - QueriesStorage::::try_invalidate_all().await; -diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs -index 6d3b1a8..f0d5bdf 100644 ---- a/packages/oneclient_app/src/hooks/queries/skin.rs -+++ b/packages/oneclient_app/src/hooks/queries/skin.rs -@@ -42,4 +42,4 @@ fn java_string_hash(s: &str) -> i32 { - h = h.wrapping_mul(31).wrapping_add(c as i32); - } - h --} -\ No newline at end of file -+} -diff --git a/packages/oneclient_app/src/hooks/queries/tos.rs b/packages/oneclient_app/src/hooks/queries/tos.rs -new file mode 100644 -index 0000000..bec73ab ---- /dev/null -+++ b/packages/oneclient_app/src/hooks/queries/tos.rs -@@ -0,0 +1,61 @@ -+use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -+use oneclient_core::{LauncherError, LauncherState, TermsDocument, fetch_terms}; -+ -+use crate::hooks::use_settings_snapshot; -+ -+#[derive(Clone, Debug, PartialEq, Eq, Hash)] -+pub struct TermsKeys { -+ pub meta_url_base: String, -+} -+ -+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -+pub struct TermsQuery; -+ -+impl QueryCapability for TermsQuery { -+ type Ok = TermsDocument; -+ type Err = LauncherError; -+ type Keys = TermsKeys; -+ -+ async fn run(&self, _keys: &Self::Keys) -> Result { -+ let state = LauncherState::get()?; -+ fetch_terms(&state.services).await -+ } -+} -+ -+pub fn use_terms() -> UseQuery { -+ let settings = use_settings_snapshot().settings; -+ let meta_url_base = settings -+ .custom_meta_url_base -+ .clone() -+ .unwrap_or_default() -+ .trim() -+ .to_string(); -+ -+ use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) -+} -+ -+pub fn terms_document(query: &UseQuery) -> Option { -+ match &*query.read().state() { -+ QueryStateData::Settled { -+ res: Ok(document), .. -+ } => Some(document.clone()), -+ QueryStateData::Loading { -+ res: Some(Ok(document)), -+ } => Some(document.clone()), -+ _ => None, -+ } -+} -+ -+pub fn terms_error(query: &UseQuery) -> Option { -+ match &*query.read().state() { -+ QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), -+ _ => None, -+ } -+} -+ -+pub fn terms_is_loading(query: &UseQuery) -> bool { -+ matches!( -+ &*query.read().state(), -+ QueryStateData::Pending | QueryStateData::Loading { res: None } -+ ) -+} -diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs -index c589718..f30f513 100644 ---- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs -+++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs -@@ -1,6 +1,6 @@ - use freya::query::QueryStateData; --use oneclient_core::packages::domain::GameLoader; - use oneclient_core::VersionMetadata; -+use oneclient_core::packages::domain::GameLoader; - - use super::use_versions; - -@@ -46,7 +46,9 @@ pub fn use_version_metadata( - let state = reader.state(); - let list = match &*state { - QueryStateData::Settled { res: Ok(list), .. } => list, -- QueryStateData::Loading { res: Some(Ok(list)) } => list, -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list, - _ => return None, - }; - -diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs -index 8f4d053..b48c48f 100644 ---- a/packages/oneclient_app/src/hooks/queries/versions.rs -+++ b/packages/oneclient_app/src/hooks/queries/versions.rs -@@ -69,7 +69,9 @@ pub fn loader_versions(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), -- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), -+ QueryStateData::Loading { -+ res: Some(Ok(list)), -+ } => list.clone(), - _ => Vec::new(), - } - } -diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs -index 0d8ec34..64696a3 100644 ---- a/packages/oneclient_app/src/hooks/view_state.rs -+++ b/packages/oneclient_app/src/hooks/view_state.rs -@@ -5,38 +5,38 @@ use super::{use_dispatch, use_settings_snapshot}; - - #[derive(Clone, Copy)] - pub struct PersistedView { -- pub layout: State, -- pub sort: State>, -+ pub layout: State, -+ pub sort: State>, - } - - pub fn use_view_state(key: &str) -> PersistedView { -- let key = key.to_string(); -- let settings = use_settings_snapshot().settings; -- let dispatch = use_dispatch(); -+ let key = key.to_string(); -+ let settings = use_settings_snapshot().settings; -+ let dispatch = use_dispatch(); - -- let initial = settings.view_state(&key); -- let init_layout = initial.layout; -- let init_sort = initial.sort; -- let layout = use_state(move || init_layout); -- let sort = use_state(move || init_sort.clone()); -+ let initial = settings.view_state(&key); -+ let init_layout = initial.layout; -+ let init_sort = initial.sort; -+ let layout = use_state(move || init_layout); -+ let sort = use_state(move || init_sort.clone()); - -- let mut first = use_state(|| true); -- use_side_effect(move || { -- let next = ViewState { -- layout: *layout.read(), -- sort: sort.read().clone(), -- }; -- if *first.peek() { -- first.set(false); -- return; -- } -- if settings.view_state(&key) == next { -- return; -- } -- let mut updated = settings.clone(); -- updated.set_view_state(key.clone(), next); -- dispatch.set_settings(updated); -- }); -+ let mut first = use_state(|| true); -+ use_side_effect(move || { -+ let next = ViewState { -+ layout: *layout.read(), -+ sort: sort.read().clone(), -+ }; -+ if *first.peek() { -+ first.set(false); -+ return; -+ } -+ if settings.view_state(&key) == next { -+ return; -+ } -+ let mut updated = settings.clone(); -+ updated.set_view_state(key.clone(), next); -+ dispatch.set_settings(updated); -+ }); - -- PersistedView { layout, sort } -+ PersistedView { layout, sort } - } -diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs -index e727b02..4b898b0 100644 ---- a/packages/oneclient_app/src/layout/app_shell.rs -+++ b/packages/oneclient_app/src/layout/app_shell.rs -@@ -9,8 +9,8 @@ use skia_safe::sampling_options::CubicResampler; - - use crate::Route; - use crate::components::Button; --use crate::layout::AnimatedAppOutlet; - use crate::components::{AppNavbar, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; -+use crate::layout::AnimatedAppOutlet; - use crate::theme; - use crate::use_settings_snapshot; - use oneclient_db::models::ClusterId; -diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs -index 49b13df..2572ac0 100644 ---- a/packages/oneclient_app/src/layout/cluster_shell.rs -+++ b/packages/oneclient_app/src/layout/cluster_shell.rs -@@ -33,7 +33,7 @@ impl ClusterViewShellTab { - match self { - Self::Overview => "Overview", - Self::Logs => "Logs", -- Self::GameLog => "Game Log", -+ Self::GameLog => "Live Log", - Self::Screenshots => "Screenshots", - Self::Mods => "Mods", - Self::Shaders => "Shaders", -diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs -index bab99f5..9342e22 100644 ---- a/packages/oneclient_app/src/layout/mod.rs -+++ b/packages/oneclient_app/src/layout/mod.rs -@@ -6,8 +6,8 @@ mod root_layout; - mod settings_shell; - - pub use animated_outlet::AnimatedAppOutlet; --pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; - pub(crate) use app_shell::gradient_overlay_radial; -+pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; - pub use cluster_shell::{ClusterShell, cluster_content}; - pub use onboarding_shell::OnboardingShell; - pub use root_layout::RootLayout; -diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs -index 773be6c..8fe9d55 100644 ---- a/packages/oneclient_app/src/layout/settings_shell.rs -+++ b/packages/oneclient_app/src/layout/settings_shell.rs -@@ -198,7 +198,10 @@ const SEARCH_INDEX: &[SearchItem] = &[ - icon: IconType::Globe01, - title: "Language", - description: "Choose the launcher language.", -- keywords: &["language", "locale", "english", "spanish", "french", "german", "russian", "japanese", "chinese"], -+ keywords: &[ -+ "language", "locale", "english", "spanish", "french", "german", "russian", "japanese", -+ "chinese", -+ ], - route: Route::SettingsLanguage {}, - }, - // Developer -@@ -332,10 +335,7 @@ const GROUPS: &[SettingsGroup] = &[ - }, - SettingsGroup { - label: "ABOUT", -- tabs: &[ -- SettingsTab::DeveloperOptions, -- SettingsTab::Changelog, -- ], -+ tabs: &[SettingsTab::DeveloperOptions, SettingsTab::Changelog], - }, - ]; - -@@ -490,18 +490,20 @@ fn search_results(query: String, mut search: State) -> Vec { - matches.truncate(SEARCH_RESULTS_MAX); - - if matches.is_empty() { -- return vec![rect() -- .width(Size::fill()) -- .padding(Gaps::new_symmetric(10., 14.)) -- .corner_radius(CornerRadius::new_all(12.)) -- .background(colors::page_elevated()) -- .child( -- label() -- .text("No settings matched your search.") -- .font_size(12.) -- .color(colors::fg_secondary()), -- ) -- .into_element()]; -+ return vec![ -+ rect() -+ .width(Size::fill()) -+ .padding(Gaps::new_symmetric(10., 14.)) -+ .corner_radius(CornerRadius::new_all(12.)) -+ .background(colors::page_elevated()) -+ .child( -+ label() -+ .text("No settings matched your search.") -+ .font_size(12.) -+ .color(colors::fg_secondary()), -+ ) -+ .into_element(), -+ ]; - } - - let mut out: Vec = Vec::with_capacity(matches.len() + 1); -@@ -696,11 +698,11 @@ impl Component for SidebarInfo { - .spacing(4.) - .font_size(12.) - .color(colors::fg_secondary()) -- .children(items.into_iter().map(|item| { -- label() -- .text(item) -- .into_element() -- })) -+ .children( -+ items -+ .into_iter() -+ .map(|item| label().text(item).into_element()), -+ ) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(copy_to_clipboard) -diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs -index 673e9f7..4f2b6b3 100644 ---- a/packages/oneclient_app/src/lib.rs -+++ b/packages/oneclient_app/src/lib.rs -@@ -12,8 +12,8 @@ mod notifications; - pub mod platform; - mod routes; - pub mod theme; --pub mod updater; - mod ui; -+pub mod updater; - pub(crate) mod utils; - mod view; - -diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs -index 76bad9d..d5e7a08 100644 ---- a/packages/oneclient_app/src/main.rs -+++ b/packages/oneclient_app/src/main.rs -@@ -39,12 +39,15 @@ fn main() { - let _tokio_guard = rt.enter(); - - let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); -- -+ - if settings.log_debug { - oneclient_core::logger::init_debug() - } else { - oneclient_core::logger::init() -- }.expect("Failed to initialize logger"); -+ } -+ .expect("Failed to initialize logger"); -+ -+ let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); - - let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new(); - handle.spawn_runtime(); -@@ -67,7 +70,7 @@ fn main() { - std::env::var("ONECLIENT_GPU_CACHE_MB") - .ok() - .and_then(|v| v.parse::().ok()) -- .unwrap_or(32) -+ .unwrap_or(32), - ) - .with_default_font(theme::DEFAULT_FONT); - -diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs -index ba20fc9..09a4db7 100644 ---- a/packages/oneclient_app/src/routes.rs -+++ b/packages/oneclient_app/src/routes.rs -@@ -17,13 +17,13 @@ use crate::view::{ - ClusterShaders, ClusterTextures, ProcessLogs, - }, - settings::{ -- SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, -- SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft, -+ SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, SettingsJava, -+ SettingsLanguage, SettingsLauncher, SettingsMinecraft, - }, - }, - onboarding::{ - OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, -- OnboardingMigration, OnboardingPreferences, OnboardingWelcome, -+ OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, - }, - }; - -@@ -37,6 +37,8 @@ pub enum Route { - #[layout(OnboardingShell)] - #[route("/onboarding")] - OnboardingWelcome {}, -+ #[route("/onboarding/terms")] -+ OnboardingTerms {}, - #[route("/onboarding/migration")] - OnboardingMigration {}, - #[route("/onboarding/language")] -@@ -120,6 +122,7 @@ impl Route { - match self { - Route::Startup { .. } => "Startup".to_string(), - Route::OnboardingWelcome { .. } => "Welcome".to_string(), -+ Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), - Route::OnboardingMigration { .. } => "Migration".to_string(), - Route::OnboardingLanguage { .. } => "Language".to_string(), - Route::OnboardingAccount { .. } => "Account".to_string(), -diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs -index 9dddbfa..0db8798 100644 ---- a/packages/oneclient_app/src/theme/colors.rs -+++ b/packages/oneclient_app/src/theme/colors.rs -@@ -149,4 +149,4 @@ pub fn selection_bg() -> Color { - - pub fn toast_action() -> Color { - Color::from_rgb(155, 161, 166) --} -\ No newline at end of file -+} -diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs -index 5900812..33feece 100644 ---- a/packages/oneclient_app/src/ui.rs -+++ b/packages/oneclient_app/src/ui.rs -@@ -17,14 +17,12 @@ pub fn border_all(width: f32) -> Border { - } - - pub fn border_all_color(width: f32, color: Color) -> Border { -- Border::new() -- .fill(color) -- .width(BorderWidth { -- top: width, -- right: width, -- bottom: width, -- left: width, -- }) -+ Border::new().fill(color).width(BorderWidth { -+ top: width, -+ right: width, -+ bottom: width, -+ left: width, -+ }) - } - - pub fn border_left(width: f32) -> Border { -@@ -79,4 +77,4 @@ pub fn entrance_motion_layer( - .child(child), - ) - .into_element() --} -\ No newline at end of file -+} -diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs -index ffc60e6..69c07c5 100644 ---- a/packages/oneclient_app/src/updater.rs -+++ b/packages/oneclient_app/src/updater.rs -@@ -55,10 +55,7 @@ fn check_for_update() -> anyhow::Result> { - Ok(check_update(current, config)?) - } - --async fn download_and_install( -- update: Update, -- notifier: NotificationService, --) -> anyhow::Result<()> { -+async fn download_and_install(update: Update, notifier: NotificationService) -> anyhow::Result<()> { - let progress_id = Uuid::new_v4(); - let version = update.version.clone(); - let label = format!("Downloading OneClient {version}"); -diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs -index daab86b..ab046e6 100644 ---- a/packages/oneclient_app/src/utils.rs -+++ b/packages/oneclient_app/src/utils.rs -@@ -16,7 +16,10 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { - let Some(parsed) = parse_mc_version(&cluster.mc_version) else { - continue; - }; -- groups.entry(parsed.major).or_default().push(cluster.clone()); -+ groups -+ .entry(parsed.major) -+ .or_default() -+ .push(cluster.clone()); - } - - for list in groups.values_mut() { -@@ -82,9 +85,7 @@ pub fn resolve_cluster( - .iter() - .find(|cluster| { - let version_ok = minor.is_none_or(|minor| { -- parse_mc_version(&cluster.mc_version) -- .and_then(|parsed| parsed.minor) -- == Some(minor) -+ parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.minor) == Some(minor) - }); - let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); - version_ok && loader_ok -@@ -241,21 +242,22 @@ fn version_sort_key(cluster: &Cluster) -> (u32, u32) { - .unwrap_or((0, 0)) - } - -- - #[cfg(not(target_os = "linux"))] - pub fn is_wayland() -> bool { -- false -+ false - } - - #[cfg(target_os = "linux")] - pub fn is_wayland() -> bool { -- static IS_WAYLAND: OnceLock = OnceLock::new(); -- -- *IS_WAYLAND.get_or_init(|| { -- if cfg!(target_os = "linux") { -- std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) -- } else { -- false -- } -- }) --} -\ No newline at end of file -+ static IS_WAYLAND: OnceLock = OnceLock::new(); -+ -+ *IS_WAYLAND.get_or_init(|| { -+ if cfg!(target_os = "linux") { -+ std::env::var("XDG_SESSION_TYPE") -+ .map(|v| v == "wayland") -+ .unwrap_or(false) -+ } else { -+ false -+ } -+ }) -+} -diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs -index c94bc14..1148231 100644 ---- a/packages/oneclient_app/src/view/app/accounts.rs -+++ b/packages/oneclient_app/src/view/app/accounts.rs -@@ -1,7 +1,9 @@ --use chrono::Utc; -+use chrono::{DateTime, Utc}; -+use freya::animation::{AnimNum, Function, OnFinish, use_animation}; - use freya::prelude::*; - use freya::query::{MutationCapability, MutationStateData, UseMutation}; - use oneclient_core::auth::{AccountKind, MinecraftAccount}; -+use uuid::Uuid; - - use crate::components::{ - Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput, -@@ -74,13 +76,17 @@ impl Component for Accounts { - let mut rows: Vec = accounts - .iter() - .map(|account| { -- account_row( -- account, -- Some(account.id) == default_id, -+ AccountRow { -+ id: account.id, -+ username: account.username.clone(), -+ kind: account.kind, -+ expires: account.expires, -+ is_default: Some(account.id) == default_id, - set_default, - remove, - refresh, -- ) -+ } -+ .into_element() - }) - .collect(); - if rows.is_empty() { -@@ -339,113 +345,182 @@ fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement { - .into_element() - } - --fn account_row( -- account: &MinecraftAccount, -+const REFRESH_SPIN_TIME: u64 = 800; -+ -+const REFRESHING_OPACITY: f32 = 0.7; -+ -+struct AccountRow { -+ id: Uuid, -+ username: String, -+ kind: AccountKind, -+ expires: DateTime, - is_default: bool, - set_default: crate::hooks::UseSetDefaultAccount, - remove: crate::hooks::UseRemoveAccount, - refresh: crate::hooks::UseRefreshAccount, --) -> Element { -- let id = account.id; -- let is_microsoft = account.is_microsoft(); -- let expired = is_microsoft && account.expires <= Utc::now(); -- -- let border_color = if expired { -- colors::danger() -- } else if is_default { -- colors::brand() -- } else { -- colors::component_border() -- }; -+} - -- rect() -- .horizontal() -- .width(Size::fill()) -- .cross_align(Alignment::Center) -- .content(Content::Flex) -- .spacing(16.) -- .padding(Gaps::new_all(12.)) -- .corner_radius(CornerRadius::new_all(12.)) -- .background(colors::page_elevated()) -- .border(border_all_color(1., border_color)) -- .a11y_role(AccessibilityRole::Button) -- .maybe(!is_default, |el| { -- el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) -- }) -- .child( -- Avatar::new(id.to_string()) -- .width(Size::px(40.)) -- .height(Size::px(40.)), -- ) -- .child( -- rect() -- .vertical() -- .width(Size::flex(1.0)) -- .spacing(4.) -- .child( -- rect() -- .horizontal() -- .cross_align(Alignment::Center) -- .spacing(8.) -- .child( -- label() -- .text(account.username.clone()) -- .font_size(16.) -- .font_weight(FontWeight::MEDIUM) -- .color(colors::fg_primary()), -- ) -- .child(kind_badge(account.kind)) -- .maybe_child(is_default.then(default_badge)) -- .maybe_child(expired.then(expired_badge)), -- ) -- .child( -- label() -- .text(id.to_string()) -- .font_size(11.) -- .color(colors::fg_secondary()), -- ), -- ) -- .maybe_child(is_microsoft.then(|| { -- Button::new() -- .ghost() -- .icon() -- .on_press(move |e: Event| { -- e.stop_propagation(); -- refresh.mutate(RefreshAccountKeys { id }); -- }) -- .child( -- Icon::new(IconType::RefreshCw01) -- .size(18.) -- .color(if expired { -- colors::danger() -- } else { -- colors::fg_secondary() -- }), -- ) -- .into_element() -- })) -- .child( -- Button::new().ghost().icon().enabled(false).child( -- Icon::new(IconType::Pencil01) -- .size(18.) -- .color(colors::fg_secondary()), -- ), -- ) -- .child( -- Button::new() -- .ghost() -- .icon() -- .on_press(move |e: Event| { -- e.stop_propagation(); -- remove.mutate(RemoveAccountKeys { id }); -- }) -- .child( -- Icon::new(IconType::Trash01) -+impl PartialEq for AccountRow { -+ fn eq(&self, other: &Self) -> bool { -+ self.id == other.id -+ && self.username == other.username -+ && self.kind == other.kind -+ && self.expires == other.expires -+ && self.is_default == other.is_default -+ } -+} -+ -+impl Component for AccountRow { -+ fn render_key(&self) -> DiffKey { -+ DiffKey::from(&self.id) -+ } -+ -+ fn render(&self) -> impl IntoElement { -+ let id = self.id; -+ let is_default = self.is_default; -+ let set_default = self.set_default; -+ let remove = self.remove; -+ let refresh = self.refresh; -+ -+ let is_microsoft = self.kind == AccountKind::Microsoft; -+ let expired = is_microsoft && self.expires <= Utc::now(); -+ -+ let mut refreshing = use_state(|| false); -+ let is_refreshing = *refreshing.read(); -+ -+ let spin = use_animation(|conf| { -+ conf.on_finish(OnFinish::restart()); -+ AnimNum::new(0., 360.) -+ .time(REFRESH_SPIN_TIME) -+ .function(Function::Linear) -+ }); -+ -+ use_side_effect_with_deps(&is_refreshing, move |&is_refreshing| { -+ let mut spin = spin; -+ if is_refreshing { -+ spin.start(); -+ } else { -+ spin.reset(); -+ } -+ }); -+ -+ let rotation = if is_refreshing { -+ spin.get().value() -+ } else { -+ 0. -+ }; -+ -+ let border_color = if expired { -+ colors::danger() -+ } else if is_default { -+ colors::brand() -+ } else { -+ colors::component_border() -+ }; -+ -+ rect() -+ .horizontal() -+ .width(Size::fill()) -+ .cross_align(Alignment::Center) -+ .content(Content::Flex) -+ .spacing(16.) -+ .padding(Gaps::new_all(12.)) -+ .corner_radius(CornerRadius::new_all(12.)) -+ .background(colors::page_elevated()) -+ .border(border_all_color(1., border_color)) -+ .opacity(if is_refreshing { -+ REFRESHING_OPACITY -+ } else { -+ 1. -+ }) -+ .a11y_role(AccessibilityRole::Button) -+ .maybe(!is_default, |el| { -+ el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) -+ }) -+ .child( -+ Avatar::new(id.to_string()) -+ .width(Size::px(40.)) -+ .height(Size::px(40.)), -+ ) -+ .child( -+ rect() -+ .vertical() -+ .width(Size::flex(1.0)) -+ .spacing(4.) -+ .child( -+ rect() -+ .horizontal() -+ .cross_align(Alignment::Center) -+ .spacing(8.) -+ .child( -+ label() -+ .text(self.username.clone()) -+ .font_size(16.) -+ .font_weight(FontWeight::MEDIUM) -+ .color(colors::fg_primary()), -+ ) -+ .child(kind_badge(self.kind)) -+ .maybe_child(is_default.then(default_badge)) -+ .maybe_child(expired.then(expired_badge)), -+ ) -+ .child( -+ label() -+ .text(id.to_string()) -+ .font_size(11.) -+ .color(colors::fg_secondary()), -+ ), -+ ) -+ .maybe_child(is_microsoft.then(|| { -+ Button::new() -+ .ghost() -+ .icon() -+ .on_press(move |e: Event| { -+ e.stop_propagation(); -+ if *refreshing.peek() { -+ return; -+ } -+ refreshing.set(true); -+ spawn(async move { -+ refresh.mutate_async(RefreshAccountKeys { id }).await; -+ refreshing.set(false); -+ }); -+ }) -+ .child( -+ rect().rotate(rotation).child( -+ Icon::new(IconType::RefreshCw01) -+ .size(18.) -+ .color(if expired { -+ colors::danger() -+ } else { -+ colors::fg_secondary() -+ }), -+ ), -+ ) -+ .into_element() -+ })) -+ .child( -+ Button::new().ghost().icon().enabled(false).child( -+ Icon::new(IconType::Pencil01) - .size(18.) - .color(colors::fg_secondary()), - ), -- ) -- .into_element() -+ ) -+ .child( -+ Button::new() -+ .ghost() -+ .icon() -+ .on_press(move |e: Event| { -+ e.stop_propagation(); -+ remove.mutate(RemoveAccountKeys { id }); -+ }) -+ .child( -+ Icon::new(IconType::Trash01) -+ .size(18.) -+ .color(colors::fg_secondary()), -+ ), -+ ) -+ .into_element() -+ } - } - - fn kind_badge(kind: AccountKind) -> impl IntoElement { -diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs -index b43f531..7aeb775 100644 ---- a/packages/oneclient_app/src/view/app/analytics/charts.rs -+++ b/packages/oneclient_app/src/view/app/analytics/charts.rs -@@ -62,7 +62,10 @@ impl Component for WhenChart { - ) - } - WhenMode::Hour => { -- let hour = self.peak_hour.map(format_hour).unwrap_or_else(|| "—".to_string()); -+ let hour = self -+ .peak_hour -+ .map(format_hour) -+ .unwrap_or_else(|| "—".to_string()); - ( - format!("Peak around {hour}"), - BarChart::new(self.per_hour.clone(), hour_labels()) -@@ -148,7 +151,12 @@ impl Component for DailyChart { - let win_total: i64 = values.iter().sum(); - let subtitle = match (slice.first(), slice.last()) { - (Some((a, _)), Some((b, _))) => { -- format!("{} · {} – {}", format_duration_hm(win_total), format_day(*a), format_day(*b)) -+ format!( -+ "{} · {} – {}", -+ format_duration_hm(win_total), -+ format_day(*a), -+ format_day(*b) -+ ) - } - _ => format_duration_hm(win_total), - }; -@@ -159,24 +167,16 @@ impl Component for DailyChart { - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) -- .child(nav_button( -- IconType::ChevronsLeft, -- can_older, -- move |_| { -- if can_older { -- *offset.write() = off + 1; -- } -- }, -- )) -- .child(nav_button( -- IconType::ChevronsRight, -- can_newer, -- move |_| { -- if can_newer { -- *offset.write() = off - 1; -- } -- }, -- )) -+ .child(nav_button(IconType::ChevronsLeft, can_older, move |_| { -+ if can_older { -+ *offset.write() = off + 1; -+ } -+ })) -+ .child(nav_button(IconType::ChevronsRight, can_newer, move |_| { -+ if can_newer { -+ *offset.write() = off - 1; -+ } -+ })) - .child( - SegmentedControl::new(range) - .height(30.) -@@ -272,6 +272,12 @@ fn weekday_labels() -> Vec { - - fn hour_labels() -> Vec { - (0..24) -- .map(|h| if h % 6 == 0 { format_hour(h) } else { String::new() }) -+ .map(|h| { -+ if h % 6 == 0 { -+ format_hour(h) -+ } else { -+ String::new() -+ } -+ }) - .collect() - } -diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs -index f09a358..2235de7 100644 ---- a/packages/oneclient_app/src/view/app/analytics/mod.rs -+++ b/packages/oneclient_app/src/view/app/analytics/mod.rs -@@ -45,11 +45,7 @@ fn charts_grid(cards: Vec) -> Element { - let mut grid = rect().vertical().width(Size::fill()).spacing(16.); - for pair in cards.chunks(2) { - if pair.len() == 1 { -- grid = grid.child( -- rect() -- .width(Size::fill()) -- .child(pair[0].clone()), -- ); -+ grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); - continue; - } - -diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs -index fe5743d..a8e3e7c 100644 ---- a/packages/oneclient_app/src/view/app/analytics/servers.rs -+++ b/packages/oneclient_app/src/view/app/analytics/servers.rs -@@ -12,7 +12,12 @@ use super::{card, card_header}; - - pub(super) fn servers_section(servers: &[ServerStat]) -> Element { - let total_joins: i64 = servers.iter().map(|s| s.joins).sum(); -- let max_secs = servers.iter().map(|s| s.total_secs).max().unwrap_or(0).max(1); -+ let max_secs = servers -+ .iter() -+ .map(|s| s.total_secs) -+ .max() -+ .unwrap_or(0) -+ .max(1); - - let mut list = rect().vertical().width(Size::fill()).spacing(4.); - for server in servers.iter().take(10) { -@@ -155,9 +160,12 @@ impl Component for ServerIcon { - let reader = query.read(); - let loaded = match (&self.url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) -- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { -- Some((url.clone(), bytes.clone())) -- } -+ | ( -+ Some(url), -+ QueryStateData::Loading { -+ res: Some(Ok(bytes)), -+ }, -+ ) => Some((url.clone(), bytes.clone())), - _ => None, - }; - -@@ -185,7 +193,11 @@ fn server_icon_placeholder(is_ip: bool, size: f32) -> Element { - .height(Size::px(size)) - .corner_radius(CornerRadius::new_all(6.)) - .background(colors::component_bg()) -- .child(Icon::new(icon).size(size * 0.5).color(colors::fg_secondary())) -+ .child( -+ Icon::new(icon) -+ .size(size * 0.5) -+ .color(colors::fg_secondary()), -+ ) - .into_element() - } - -diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs -index aaf072c..10bb6bd 100644 ---- a/packages/oneclient_app/src/view/app/analytics/tiles.rs -+++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs -@@ -3,9 +3,9 @@ use freya::prelude::*; - use oneclient_core::game::{Analytics, Persona}; - - use crate::components::{Icon, IconType}; --use crate::utils::format_duration_hm; - use crate::theme::colors; - use crate::ui::border_all_color; -+use crate::utils::format_duration_hm; - - use super::card; - -@@ -19,10 +19,22 @@ pub(super) fn tiles_row(analytics: &Analytics) -> Element { - let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); - - let mut tiles = vec![ -- stat_tile(IconType::ClockRewind, "Total playtime", format_duration_hm(stats.total_secs)), -+ stat_tile( -+ IconType::ClockRewind, -+ "Total playtime", -+ format_duration_hm(stats.total_secs), -+ ), - stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), -- stat_tile(IconType::Calendar, "Avg / session", format_duration_hm(avg_session)), -- stat_tile(IconType::Rocket02, "Active days", stats.active_days.to_string()), -+ stat_tile( -+ IconType::Calendar, -+ "Avg / session", -+ format_duration_hm(avg_session), -+ ), -+ stat_tile( -+ IconType::Rocket02, -+ "Active days", -+ stats.active_days.to_string(), -+ ), - ]; - if !analytics.servers.is_empty() { - tiles.push(stat_tile( -@@ -97,7 +109,11 @@ fn persona_card(persona: Persona) -> Element { - .corner_radius(CornerRadius::new_all(22.)) - .background(colors::brand().with_a(40)) - .center() -- .child(Icon::new(persona_icon(persona)).size(22.).color(colors::brand())), -+ .child( -+ Icon::new(persona_icon(persona)) -+ .size(22.) -+ .color(colors::brand()), -+ ), - ) - .child( - rect() -diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs -index bc9c10c..c919849 100644 ---- a/packages/oneclient_app/src/view/app/browser/index/cards.rs -+++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs -@@ -1,17 +1,19 @@ - use super::*; - - use freya::router::RouterContext; --use oneclient_core::packages::types::ProjectSummary; - use oneclient_core::packages::ProviderId; -+use oneclient_core::packages::types::ProjectSummary; - --use crate::components::{ -- Icon, IconType, --}; -+use crate::components::{Icon, IconType}; - use crate::routes::Route; - use crate::theme::colors; - use crate::ui::border_all_color; - --pub(super) fn grid_row(row: Vec, cluster_id: i64, package_type: &str) -> impl IntoElement { -+pub(super) fn grid_row( -+ row: Vec, -+ cluster_id: i64, -+ package_type: &str, -+) -> impl IntoElement { - let package_type = package_type.to_string(); - let fill = GRID_COLUMNS - row.len(); - -@@ -154,7 +156,11 @@ impl Component for PackageCard { - } - } - --pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement { -+pub(super) fn list_row( -+ item: ProjectSummary, -+ cluster_id: i64, -+ package_type: &str, -+) -> impl IntoElement { - ListRow { - item, - cluster_id, -@@ -185,69 +191,69 @@ impl Component for ListRow { - Cursor::set(CursorIcon::default()); - }); - -- rect() -- .horizontal() -- .width(Size::fill()) -- .height(Size::px(LIST_ROW_H)) -- .cross_align(Alignment::Center) -- .spacing(12.) -- .padding(Gaps::new_all(16.)) -- .corner_radius(CornerRadius::new_all(10.)) -- .background(CARD_BG) -- .border(border_all_color( -- 1., -- if focused { -- colors::brand() -- } else { -- colors::component_border() -- }, -- )) -- .overflow(Overflow::Clip) -- .content(Content::Flex) -- .a11y_id(a11y_id) -- .a11y_focusable(true) -- .a11y_role(AccessibilityRole::Button) -- .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) -- .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) -- .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) -- .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) -- .child( -- rect() -- .vertical() -- .width(Size::flex(1.0)) -- .spacing(3.) -- .child( -- rect() -- .horizontal() -- .cross_align(Alignment::Center) -- .spacing(6.) -- .child( -- label() -- .text(item.name.clone()) -- .font_size(15.) -- .font_weight(FontWeight::MEDIUM) -- .max_lines(1) -- .color(CARD_NAME), -- ) -- .child( -- label() -- .text(format!("by {}", item.author)) -- .font_size(10.) -- .max_lines(1) -- .color(colors::fg_secondary()), -- ) -- .child(Icon::new(item.provider).size(12.)), -- ) -- .child( -- label() -- .text(item.summary.clone()) -- .font_size(11.) -- .max_lines(2) -- .width(Size::fill()) -- .color(colors::fg_secondary()), -- ), -- ) -- .child(downloads_row(item.downloads)) -+ rect() -+ .horizontal() -+ .width(Size::fill()) -+ .height(Size::px(LIST_ROW_H)) -+ .cross_align(Alignment::Center) -+ .spacing(12.) -+ .padding(Gaps::new_all(16.)) -+ .corner_radius(CornerRadius::new_all(10.)) -+ .background(CARD_BG) -+ .border(border_all_color( -+ 1., -+ if focused { -+ colors::brand() -+ } else { -+ colors::component_border() -+ }, -+ )) -+ .overflow(Overflow::Clip) -+ .content(Content::Flex) -+ .a11y_id(a11y_id) -+ .a11y_focusable(true) -+ .a11y_role(AccessibilityRole::Button) -+ .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) -+ .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) -+ .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) -+ .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) -+ .child( -+ rect() -+ .vertical() -+ .width(Size::flex(1.0)) -+ .spacing(3.) -+ .child( -+ rect() -+ .horizontal() -+ .cross_align(Alignment::Center) -+ .spacing(6.) -+ .child( -+ label() -+ .text(item.name.clone()) -+ .font_size(15.) -+ .font_weight(FontWeight::MEDIUM) -+ .max_lines(1) -+ .color(CARD_NAME), -+ ) -+ .child( -+ label() -+ .text(format!("by {}", item.author)) -+ .font_size(10.) -+ .max_lines(1) -+ .color(colors::fg_secondary()), -+ ) -+ .child(Icon::new(item.provider).size(12.)), -+ ) -+ .child( -+ label() -+ .text(item.summary.clone()) -+ .font_size(11.) -+ .max_lines(2) -+ .width(Size::fill()) -+ .color(colors::fg_secondary()), -+ ), -+ ) -+ .child(downloads_row(item.downloads)) - } - } - -diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs -index cb4c799..77df9ea 100644 ---- a/packages/oneclient_app/src/view/app/browser/index/mod.rs -+++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs -@@ -1,6 +1,5 @@ - use freya::animation::{ -- AnimNum, Ease, Function, OnChange, OnCreation, -- use_animation_with_dependencies, -+ AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, - }; - use freya::prelude::*; - use oneclient_core::packages::types::ProjectSummary; -@@ -174,7 +173,9 @@ impl Component for Browser { - match mode { - ViewLayout::Grid => { - let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS); -- sa.lazy(rows, CARD_H, GRID_SPACING, |_| skeleton_grid_row().into_element()) -+ sa.lazy(rows, CARD_H, GRID_SPACING, |_| { -+ skeleton_grid_row().into_element() -+ }) - } - ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { - SkeletonListRow.into_element() -@@ -289,4 +290,3 @@ fn controls( - ), - ) - } -- -diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs -index 1e03d38..3808083 100644 ---- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs -+++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs -@@ -120,4 +120,3 @@ impl Component for CategoryRow { - ) - } - } -- -diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs -index d7e9aa4..30f6d2a 100644 ---- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs -+++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs -@@ -1,8 +1,6 @@ - use super::*; - --use freya::animation::{ -- AnimNum, Ease, Function, OnCreation, OnFinish, use_animation, --}; -+use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; - - use crate::theme::colors; - use crate::ui::border_all_color; -diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs -index db7aadd..b3cdbce 100644 ---- a/packages/oneclient_app/src/view/app/browser/package/mod.rs -+++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs -@@ -3,8 +3,8 @@ use oneclient_core::packages::{ContentType, ProviderId}; - - use crate::components::ScrollArea; - use crate::hooks::{ -- content_type_for_slug, project_detail, use_browser_compat, use_dispatch, -- use_link_confirm, use_package_project, use_package_versions, version_list, versions_total, -+ content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm, -+ use_package_project, use_package_versions, version_list, versions_total, - }; - use crate::theme::colors; - use crate::ui::border_all_color; -@@ -205,4 +205,3 @@ impl Component for BrowserPackage { - .into_element() - } - } -- -diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs -index c0e854d..42f88fc 100644 ---- a/packages/oneclient_app/src/view/app/browser/package/panels.rs -+++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs -@@ -1,14 +1,14 @@ - use super::*; - - use freya::query::QueryStateData; -+use oneclient_core::packages::ProviderId; - use oneclient_core::packages::types::{ - GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary, - }; --use oneclient_core::packages::ProviderId; - - use crate::BridgeDispatch; - use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; --use crate::hooks::{use_cached_image, VERSIONS_PAGE_SIZE}; -+use crate::hooks::{VERSIONS_PAGE_SIZE, use_cached_image}; - use crate::theme::colors; - use crate::ui::border_all_color; - use crate::utils::format_size; -@@ -16,7 +16,6 @@ use crate::utils::format_size; - const GALLERY_IMAGE_H: f32 = 360.; - const GALLERY_EDGE: u32 = 1024; - -- - pub(super) fn loading_body() -> impl IntoElement { - rect() - .width(Size::fill()) -@@ -356,4 +355,3 @@ fn release_badge(release_type: ReleaseType) -> impl IntoElement { - .color(color), - ) - } -- -diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs -index 3c4e40f..2b3b017 100644 ---- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs -+++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs -@@ -1,16 +1,13 @@ - use super::*; - --use oneclient_core::packages::types::{ -- ProjectDetail, ProjectMember, --}; - use oneclient_core::packages::ProviderId; -+use oneclient_core::packages::types::{ProjectDetail, ProjectMember}; - - use crate::BridgeDispatch; - use crate::components::{Button, Icon, IconType}; - use crate::theme::colors; - use crate::ui::border_all_color; - -- - pub(super) fn sidebar( - project: Option, - latest_version: Option, -diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs -index 827836e..4d760ff 100644 ---- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs -+++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs -@@ -1,4 +1,3 @@ -- - use freya::prelude::*; - use oneclient_core::Patch; - use oneclient_core::java::JavaRuntime; -@@ -6,7 +5,8 @@ use oneclient_core::packages::domain::GameLoader; - use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; - - use crate::components::{ -- Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number -+ Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, -+ validate_number, - }; - use crate::hooks::{ - ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, -@@ -37,7 +37,10 @@ impl Component for ClusterSettings { - .as_ref() - .map(|c| c.mc_version.clone()) - .unwrap_or_default(); -- let loader = cluster.as_ref().map(|c| c.mc_loader).unwrap_or(GameLoader::Fabric); -+ let loader = cluster -+ .as_ref() -+ .map(|c| c.mc_loader) -+ .unwrap_or(GameLoader::Fabric); - let versions_query = use_loader_versions(mc_version, loader); - let runtimes_query = use_java_runtimes(); - -@@ -149,13 +152,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen - - Button::new() - .small() -- .ghost() -- .icon() -+ .ghost() -+ .icon() - .corner_radius(CornerRadius::new_all(7.)) -- .maybe(overridden, |el| { -- el.on_press(move |_| on_reset.call(())) -- }) -- .enabled(overridden) -+ .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) -+ .enabled(overridden) - .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) - .into_element() - } -@@ -394,7 +395,11 @@ impl Component for ResolutionRow { - .text_align(TextAlign::Center) - .width(Size::px(70.)), - ) -- .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) -+ .child( -+ Icon::new(IconType::X) -+ .size(14.) -+ .color(colors::fg_secondary()), -+ ) - .child( - TextInput::new(height) - .placeholder("480") -@@ -527,7 +532,12 @@ impl Component for TextRow { - .placeholder(placeholder) - .width(Size::px(220.)); - -- settings_row(icon, title, description, override_cell(control, overridden, on_reset)) -+ settings_row( -+ icon, -+ title, -+ description, -+ override_cell(control, overridden, on_reset), -+ ) - } - } - -@@ -564,8 +574,7 @@ impl Component for JavaRow { - .map(runtime_label) - .unwrap_or_else(|| "Automatic".into()); - -- let paths: Vec = -- runtimes.iter().map(|r| r.absolute_path.clone()).collect(); -+ let paths: Vec = runtimes.iter().map(|r| r.absolute_path.clone()).collect(); - - let dispatch = dispatch.clone(); - Dropdown::new(selected, options) -@@ -641,7 +650,10 @@ impl Component for LoaderRow { - settings_row( - IconType::Rocket02, - "Loader Version", -- format!("The {} loader version used to launch this cluster.", self.loader), -+ format!( -+ "The {} loader version used to launch this cluster.", -+ self.loader -+ ), - control, - ) - } -diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs -index fa61a34..e136905 100644 ---- a/packages/oneclient_app/src/view/app/cluster/logs.rs -+++ b/packages/oneclient_app/src/view/app/cluster/logs.rs -@@ -65,7 +65,8 @@ enum Confirm { - - fn kind_badge(kind: LogKind) -> &'static str { - match kind { -- LogKind::Game { .. } => "Launcher", -+ LogKind::Game { .. } => "Game output", -+ LogKind::Launcher => "Launcher logs", - LogKind::Minecraft => "Minecraft", - LogKind::CrashReport => "Crash report", - LogKind::Other => "Other", -@@ -151,7 +152,7 @@ impl Component for ClusterLogs { - let value = search.read().clone(); - (!value.trim().is_empty()).then_some(value) - }; -- -+ - let content_query = use_log_content( - selected_path.clone(), - level.read().to_level(), -@@ -162,7 +163,7 @@ impl Component for ClusterLogs { - let Some(cluster) = load_cluster(cluster_id) else { - return cluster_not_found(); - }; -- let folder = cluster.game_dir().ok().map(|d| d.join("logs")); -+ let folder = cluster.dir().ok().map(|d| d.join("logs")); - - let files = try_cluster_logs(&logs_query).unwrap_or_default(); - let has_log = !files.is_empty(); -diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs -index bc95a97..1cf1f0e 100644 ---- a/packages/oneclient_app/src/view/app/cluster/mod.rs -+++ b/packages/oneclient_app/src/view/app/cluster/mod.rs -@@ -1,23 +1,23 @@ --mod overview; - mod logs; -+mod overview; - mod package_manager; - - mod cluster_settings; - mod mods; -+mod process_logs; - mod screenshots; - mod shaders; - mod textures; --mod process_logs; - --pub use overview::ClusterOverview; - pub use logs::ClusterLogs; -+pub use overview::ClusterOverview; - - pub use cluster_settings::ClusterSettings; - pub use mods::ClusterMods; -+pub use process_logs::ProcessLogs; - pub use screenshots::ClusterScreenshots; - pub use shaders::ClusterShaders; - pub use textures::ClusterTextures; --pub use process_logs::ProcessLogs; - - use freya::prelude::*; - use freya::query::QueryStateData; -diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs -index 6387b54..9c8622e 100644 ---- a/packages/oneclient_app/src/view/app/cluster/mods.rs -+++ b/packages/oneclient_app/src/view/app/cluster/mods.rs -@@ -1,14 +1,14 @@ - use freya::prelude::*; --use oneclient_core::packages::{ContentType, ProviderId}; -+use oneclient_core::packages::ContentType; - - use crate::hooks::{ -- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, -- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, -+ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, -+ use_bundles_with_status, use_cluster_content, - }; - use crate::layout::cluster_content; - - use super::package_manager::{ -- PackageManager, bundle_categories, bundle_packages, managed_project_ids, -+ PackageManager, bundle_categories, bundle_packages, use_content_meta, - }; - use super::{cluster_not_found, load_cluster}; - -@@ -23,8 +23,8 @@ impl Component for ClusterMods { - let bundles = use_bundles_with_status(self.cluster_id); - let overrides = use_bundle_overrides(self.cluster_id); - let bundle_items = bundles_with_status_items(&bundles); -- let project_ids = managed_project_ids(&bundle_items, ContentType::Mod); -- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); -+ let content_items = cluster_content_items(&content); -+ let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); - - let Some(_cluster) = load_cluster(self.cluster_id) else { - return cluster_not_found(); -@@ -32,10 +32,10 @@ impl Component for ClusterMods { - - let all_categories = bundle_categories(&bundle_items); - let items = bundle_packages( -- cluster_content_items(&content), -+ content_items, - &bundle_items, - &bundle_overrides_map(&overrides), -- &package_meta_batch(&meta), -+ &meta, - ContentType::Mod, - ); - -diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs -index 20fdcbc..3665b0a 100644 ---- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs -+++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs -@@ -1,4 +1,3 @@ -- - use std::collections::{HashMap, HashSet}; - - use freya::prelude::*; -@@ -6,7 +5,7 @@ use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; - use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; - - use crate::components::{CardLayout, PackageEntry}; --use crate::hooks::use_view_state; -+use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state}; - - mod views; - use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar}; -@@ -18,9 +17,13 @@ const GRID_GAP: f32 = 10.; - const GRID_MIN_W: f32 = 260.; - const LAZY_OVERSCAN: i64 = 2; - --pub fn managed_project_ids( -+pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; -+ -+fn provider_project_ids( -+ content: &[LinkedArtifactInfo], - bundles: &[BundleWithUpdateStatus], - content_type: ContentType, -+ provider: ProviderId, - ) -> Vec { - let mut ids = Vec::new(); - for bundle in bundles { -@@ -28,19 +31,49 @@ pub fn managed_project_ids( - if file.content_type() != content_type { - continue; - } -- if let BundleFileKind::Managed { project_id, .. } = &file.kind { -+ if let BundleFileKind::Managed { -+ project_id, -+ provider: file_provider, -+ .. -+ } = &file.kind -+ && *file_provider == provider -+ { - ids.push(project_id.clone()); - } - } - } -+ for info in content { -+ if info.content_type != content_type || info.provider != Some(provider) { -+ continue; -+ } -+ if let Some(project_id) = &info.project_id { -+ ids.push(project_id.clone()); -+ } -+ } - ids - } - -+pub fn use_content_meta( -+ content: &[LinkedArtifactInfo], -+ bundles: &[BundleWithUpdateStatus], -+ content_type: ContentType, -+) -> PackageMetaMap { -+ let mut out = PackageMetaMap::new(); -+ for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { -+ let ids = provider_project_ids(content, bundles, content_type, provider); -+ let query = use_package_meta_batch(provider, ids); -+ for (project_id, meta) in package_meta_batch(&query) { -+ out.insert((provider, project_id), meta); -+ } -+ } -+ out -+} -+ - pub fn bundle_packages( - content: Vec, - bundles: &[BundleWithUpdateStatus], - overrides: &HashMap<(String, String), String>, -- meta: &HashMap, -+ meta: &PackageMetaMap, - content_type: ContentType, - ) -> Vec { - let mut by_project: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); -@@ -104,10 +137,7 @@ pub fn bundle_packages( - } - - for info in &content { -- let in_bundle = info -- .project_id -- .as_deref() -- .is_some_and(|p| seen.contains(p)) -+ let in_bundle = info.project_id.as_deref().is_some_and(|p| seen.contains(p)) - || seen.contains(&info.hash); - if in_bundle { - continue; -@@ -141,10 +171,10 @@ fn make_row( - categories: Vec, - enabled: bool, - installed_info: Option<&LinkedArtifactInfo>, -- meta: &HashMap, -+ meta: &PackageMetaMap, - fallback_name: String, - ) -> PackageEntry { -- let m = meta.get(&package_id); -+ let m = meta.get(&(provider, package_id.clone())); - let name = m - .map(|p| p.name.clone()) - .filter(|s| !s.is_empty()) -diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs -index 529adc1..4b30c03 100644 ---- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs -+++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs -@@ -264,7 +264,13 @@ impl Component for FilterPopover { - .background(colors::page_elevated().with_a(230)) - .blur(12.) - .border(crate::ui::border_all_color(1., colors::component_border())) -- .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0)))) -+ .shadow(Shadow::from(( -+ 0., -+ 8., -+ 32., -+ 0., -+ Color::from_argb(120, 0, 0, 0), -+ ))) - .a11y_id(a11y_id) - .a11y_role(AccessibilityRole::Dialog) - .on_global_key_down(move |e: Event| { -@@ -527,12 +533,16 @@ impl Component for ContentBox { - - let header = (count > 0) - .then(|| match kind { -- ContentKind::External => Some( -- action_header(browse_button(cluster_id, package_type)).into_element(), -- ), -+ ContentKind::External => { -+ Some(action_header(browse_button(cluster_id, package_type)).into_element()) -+ } - ContentKind::Local => Some( -- action_header(add_from_file_button(cluster_id, content_type, dispatch.clone())) -- .into_element(), -+ action_header(add_from_file_button( -+ cluster_id, -+ content_type, -+ dispatch.clone(), -+ )) -+ .into_element(), - ), - ContentKind::Other => None, - }) -diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs -index 8568ae6..df8b1f7 100644 ---- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs -+++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs -@@ -1,4 +1,3 @@ -- - use freya::prelude::*; - - use crate::components::LogViewer; -@@ -30,9 +29,7 @@ impl Component for ProcessLogs { - not_running() - }; - -- cluster_content() -- .child(body) -- .into_element() -+ cluster_content().child(body).into_element() - } - } - -diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs -index ed13be2..c9fd68e 100644 ---- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs -+++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs -@@ -8,8 +8,7 @@ use oneclient_core::settings::ViewLayout; - - use crate::components::{ - Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, -- open_folder_button, -- Segment, SegmentedControl, -+ Segment, SegmentedControl, open_folder_button, - }; - use crate::hooks::{ - ScreenshotAction, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, -@@ -57,7 +56,14 @@ impl Component for ClusterScreenshots { - let grid_width = use_state(|| 0f32); - let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); - -- let toolbar = toolbar_row(&shots, folder, view_mode, edit_mode, selected, confirm_delete); -+ let toolbar = toolbar_row( -+ &shots, -+ folder, -+ view_mode, -+ edit_mode, -+ selected, -+ confirm_delete, -+ ); - - let content: Element = if shots.is_empty() { - empty_state(matches!( -@@ -210,7 +216,12 @@ fn toolbar_row( - .horizontal() - .cross_align(Alignment::Center) - .spacing(10.) -- .maybe_child((!editing).then_some(folder).flatten().map(open_folder_button)); -+ .maybe_child( -+ (!editing) -+ .then_some(folder) -+ .flatten() -+ .map(open_folder_button), -+ ); - - if editing { - let select_all_paths = all_paths.clone(); -@@ -422,8 +433,6 @@ fn confirm_panel( - .into_element() - } - -- -- - fn res_badge(res: Option<(u32, u32)>) -> Option { - res.map(format_res).map(|text| { - rect() -diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs -index 61fde57..a69000b 100644 ---- a/packages/oneclient_app/src/view/app/cluster/shaders.rs -+++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs -@@ -1,14 +1,14 @@ - use freya::prelude::*; --use oneclient_core::packages::{ContentType, ProviderId}; -+use oneclient_core::packages::ContentType; - - use crate::hooks::{ -- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, -- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, -+ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, -+ use_bundles_with_status, use_cluster_content, - }; - use crate::layout::cluster_content; - - use super::package_manager::{ -- PackageManager, bundle_categories, bundle_packages, managed_project_ids, -+ PackageManager, bundle_categories, bundle_packages, use_content_meta, - }; - use super::{cluster_not_found, load_cluster}; - -@@ -23,8 +23,8 @@ impl Component for ClusterShaders { - let bundles = use_bundles_with_status(self.cluster_id); - let overrides = use_bundle_overrides(self.cluster_id); - let bundle_items = bundles_with_status_items(&bundles); -- let project_ids = managed_project_ids(&bundle_items, ContentType::Shader); -- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); -+ let content_items = cluster_content_items(&content); -+ let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); - - let Some(_cluster) = load_cluster(self.cluster_id) else { - return cluster_not_found(); -@@ -32,10 +32,10 @@ impl Component for ClusterShaders { - - let all_categories = bundle_categories(&bundle_items); - let items = bundle_packages( -- cluster_content_items(&content), -+ content_items, - &bundle_items, - &bundle_overrides_map(&overrides), -- &package_meta_batch(&meta), -+ &meta, - ContentType::Shader, - ); - -diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs -index fc42cbd..9f08ec8 100644 ---- a/packages/oneclient_app/src/view/app/cluster/textures.rs -+++ b/packages/oneclient_app/src/view/app/cluster/textures.rs -@@ -1,14 +1,14 @@ - use freya::prelude::*; --use oneclient_core::packages::{ContentType, ProviderId}; -+use oneclient_core::packages::ContentType; - - use crate::hooks::{ -- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, -- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, -+ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, -+ use_bundles_with_status, use_cluster_content, - }; - use crate::layout::cluster_content; - - use super::package_manager::{ -- PackageManager, bundle_categories, bundle_packages, managed_project_ids, -+ PackageManager, bundle_categories, bundle_packages, use_content_meta, - }; - use super::{cluster_not_found, load_cluster}; - -@@ -23,8 +23,8 @@ impl Component for ClusterTextures { - let bundles = use_bundles_with_status(self.cluster_id); - let overrides = use_bundle_overrides(self.cluster_id); - let bundle_items = bundles_with_status_items(&bundles); -- let project_ids = managed_project_ids(&bundle_items, ContentType::ResourcePack); -- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); -+ let content_items = cluster_content_items(&content); -+ let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); - - let Some(_cluster) = load_cluster(self.cluster_id) else { - return cluster_not_found(); -@@ -32,10 +32,10 @@ impl Component for ClusterTextures { - - let all_categories = bundle_categories(&bundle_items); - let items = bundle_packages( -- cluster_content_items(&content), -+ content_items, - &bundle_items, - &bundle_overrides_map(&overrides), -- &package_meta_batch(&meta), -+ &meta, - ContentType::ResourcePack, - ); - -diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs -index 14b4e41..3d416fe 100644 ---- a/packages/oneclient_app/src/view/app/clusters/page.rs -+++ b/packages/oneclient_app/src/view/app/clusters/page.rs -@@ -8,7 +8,6 @@ use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, M - use crate::hooks::{ - use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_version_metadata, - }; --use crate::view::app::launch_button_state; - use crate::routes::Route; - use crate::theme::colors; - use crate::ui::border_all_color; -@@ -16,6 +15,7 @@ use crate::utils::{ - default_loader, default_major, default_minor, group_clusters_by_major, loaders_for_major, - major_pretty_name, minor_label, minor_versions, resolve_cluster, - }; -+use crate::view::app::launch_button_state; - - const GRID_GAP_PX: f32 = 12.; - const MIN_CARD_WIDTH_PX: f32 = 280.; -@@ -212,7 +212,10 @@ fn detail_sidebar( - .as_ref() - .and_then(|m| m.long_description.clone()) - .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); -- let tags = metadata.as_ref().map(|m| m.tags.clone()).unwrap_or_default(); -+ let tags = metadata -+ .as_ref() -+ .map(|m| m.tags.clone()) -+ .unwrap_or_default(); - - rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) -@@ -225,17 +228,9 @@ fn detail_sidebar( - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .overflow(Overflow::Clip) -- .child( -- rect() -- .width(Size::fill()) -- .max_height(Size::px(140.)) -- .child(ClusterLandscapeArt::for_version( -- major, -- minor_value, -- loader_value, -- false, -- )), -- ) -+ .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( -+ ClusterLandscapeArt::for_version(major, minor_value, loader_value, false), -+ )) - .child( - rect() - .vertical() -@@ -274,7 +269,11 @@ fn detail_sidebar( - .content(Content::Flex) - .main_align(Alignment::Center) - .spacing(8.) -- .child(play_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) -+ .child(play_button( -+ cluster_id, -+ dispatch, -+ launch_button_state(&game, cluster_id), -+ )) - .child(view_button(cluster_id, active_id)), - ), - ) -diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs -index 9df3240..1cf9396 100644 ---- a/packages/oneclient_app/src/view/app/mod.rs -+++ b/packages/oneclient_app/src/view/app/mod.rs -@@ -1,14 +1,14 @@ --mod analytics; --mod home; - mod accounts; -+mod analytics; - mod clusters; - mod debug; -+mod home; - mod skins; - mod stats; - - pub mod browser; --pub mod settings; - pub mod cluster; -+pub mod settings; - - use crate::bridge::GameSnapshot; - use oneclient_core::notification::LaunchStage; -@@ -23,10 +23,10 @@ pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static st - } - } - --pub(crate) use analytics::analytics_body; - pub use accounts::Accounts; --pub use home::Home; -+pub(crate) use analytics::analytics_body; - pub use clusters::Clusters; - pub use debug::Debug; -+pub use home::Home; - pub use skins::AccountSkins; - pub use stats::Stats; -diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs -index ac42047..13dd672 100644 ---- a/packages/oneclient_app/src/view/app/settings/apis.rs -+++ b/packages/oneclient_app/src/view/app/settings/apis.rs -@@ -1,8 +1,8 @@ - use freya::prelude::*; - -+use super::settings_page; - use crate::components::{IconType, TextInput}; - use crate::hooks::{use_dispatch, use_settings_snapshot}; --use super::settings_page; - use crate::view::app::settings::{section_header, settings_row}; - - fn normalize(value: &str) -> Option { -diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs -index 7a43f38..3edc388 100644 ---- a/packages/oneclient_app/src/view/app/settings/appearance.rs -+++ b/packages/oneclient_app/src/view/app/settings/appearance.rs -@@ -1,8 +1,8 @@ - use freya::prelude::*; - -+use super::settings_page; - use crate::components::{Icon, IconType, toggle}; - use crate::hooks::{use_dispatch, use_settings_snapshot}; --use super::settings_page; - use crate::theme::colors; - use crate::ui::border_all_color; - use crate::view::app::settings::settings_row; -diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs -index 8238e3f..791bf81 100644 ---- a/packages/oneclient_app/src/view/app/settings/changelog.rs -+++ b/packages/oneclient_app/src/view/app/settings/changelog.rs -@@ -1,8 +1,8 @@ - use freya::prelude::*; - -+use super::settings_page; - use crate::components::{Icon, IconType}; - use crate::hooks::{changelog_error, changelog_groups, changelog_is_loading, use_changelog}; --use super::settings_page; - use crate::theme::colors; - - #[derive(PartialEq)] -@@ -119,14 +119,16 @@ impl Component for ReleaseCard { - .spacing(4.) - .padding(Gaps::new(0., 0., 0., 6.)) - .children(if changes.is_empty() { -- vec![rect() -- .child( -- label() -- .text("No changes recorded for this version.") -- .font_size(12.) -- .color(colors::fg_secondary()), -- ) -- .into_element()] -+ vec![ -+ rect() -+ .child( -+ label() -+ .text("No changes recorded for this version.") -+ .font_size(12.) -+ .color(colors::fg_secondary()), -+ ) -+ .into_element(), -+ ] - } else { - changes - .into_iter() -diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs -index 4e5983c..3a35490 100644 ---- a/packages/oneclient_app/src/view/app/settings/developer.rs -+++ b/packages/oneclient_app/src/view/app/settings/developer.rs -@@ -1,10 +1,10 @@ - use freya::prelude::*; - use freya::router::RouterContext; - -+use super::settings_page; - use crate::Route; - use crate::components::{Button, IconType, link_button, toggle}; - use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; --use super::settings_page; - use crate::view::app::settings::{section_header, settings_row}; - - #[derive(PartialEq)] -@@ -67,7 +67,7 @@ impl Component for SettingsDeveloper { - "Debug Page", - "View the debug page.", - link_button().on_press(move |_| { -- let _ = RouterContext::get().push(Route::Debug { }); -+ let _ = RouterContext::get().push(Route::Debug {}); - }), - )) - .into_element() -diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs -index 5a9c3e6..bc8cda8 100644 ---- a/packages/oneclient_app/src/view/app/settings/java.rs -+++ b/packages/oneclient_app/src/view/app/settings/java.rs -@@ -1,12 +1,12 @@ - use freya::prelude::*; - use oneclient_core::java::{AvailableJava, JavaRuntime, JavaVendor}; - -+use super::settings_page; - use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; - use crate::hooks::{ -- java_runtimes, provider_versions, use_dispatch, use_java_runtimes, use_provider_versions, -- BridgeDispatch, -+ BridgeDispatch, java_runtimes, provider_versions, use_dispatch, use_java_runtimes, -+ use_provider_versions, - }; --use super::settings_page; - use crate::theme::colors; - use crate::ui::border_all_color; - use crate::view::app::settings::section_header; -@@ -151,15 +151,13 @@ fn table_header() -> impl IntoElement { - .child(head("VENDOR", Size::px(130.))) - .child(head("VERSION", Size::px(90.))) - .child( -- rect() -- .width(Size::flex(1.0)) -- .child( -- label() -- .text("PATH") -- .font_size(11.) -- .font_weight(FontWeight::MEDIUM) -- .color(colors::fg_secondary()), -- ), -+ rect().width(Size::flex(1.0)).child( -+ label() -+ .text("PATH") -+ .font_size(11.) -+ .font_weight(FontWeight::MEDIUM) -+ .color(colors::fg_secondary()), -+ ), - ) - .child(rect().width(Size::px(34.))) - .into_element() -@@ -206,7 +204,10 @@ impl Component for RuntimeRow { - .fill(colors::component_border()), - ) - }) -- .child(cell(format!("{} {}", runtime.vendor, runtime.major), Size::px(130.))) -+ .child(cell( -+ format!("{} {}", runtime.vendor, runtime.major), -+ Size::px(130.), -+ )) - .child(cell(runtime.version.clone(), Size::px(90.))) - .child( - rect() -@@ -241,7 +242,11 @@ fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { - .ghost() - .small() - .on_press(move |_| dispatch.remove_java_runtime(path.clone())) -- .child(Icon::new(IconType::Trash01).size(14.).color(colors::danger())) -+ .child( -+ Icon::new(IconType::Trash01) -+ .size(14.) -+ .color(colors::danger()), -+ ) - .into_element() - } - -diff --git a/packages/oneclient_app/src/view/app/settings/language.rs b/packages/oneclient_app/src/view/app/settings/language.rs -index baba05a..0248396 100644 ---- a/packages/oneclient_app/src/view/app/settings/language.rs -+++ b/packages/oneclient_app/src/view/app/settings/language.rs -@@ -1,13 +1,11 @@ - use freya::prelude::*; - --use crate::components::{IconType, Icon}; - use super::settings_page; -+use crate::components::{Icon, IconType}; - use crate::theme::colors; - use crate::view::app::settings::section_header; - --const LANGUAGES: &[(&str, &str)] = &[ -- ("English", "English (US)"), --]; -+const LANGUAGES: &[(&str, &str)] = &[("English", "English (US)")]; - - #[derive(PartialEq)] - pub struct SettingsLanguage; -@@ -63,8 +61,11 @@ fn language_row( - .color(colors::fg_secondary()), - ), - ) -- .maybe_child( -- is_selected.then(|| Icon::new(IconType::Check).size(20.).color(colors::brand()).into_element()), -- ) -+ .maybe_child(is_selected.then(|| { -+ Icon::new(IconType::Check) -+ .size(20.) -+ .color(colors::brand()) -+ .into_element() -+ })) - .into_element() - } -diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs -index cc518d2..c5420fb 100644 ---- a/packages/oneclient_app/src/view/app/settings/launcher.rs -+++ b/packages/oneclient_app/src/view/app/settings/launcher.rs -@@ -1,8 +1,8 @@ - use freya::prelude::*; - -+use super::settings_page; - use crate::components::{IconType, link_button, toggle}; - use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; --use super::settings_page; - use crate::platform; - use crate::view::app::settings::{section_header, settings_row}; - -@@ -20,17 +20,24 @@ impl Component for SettingsLauncher { - move || v - }); - -+ let crash_reporting = use_state({ -+ let v = settings.crash_reporting; -+ move || v -+ }); -+ - let mut first = use_state(|| true); - { - let settings = settings.clone(); - use_side_effect(move || { - let discord = *discord_rpc.read(); -+ let crash = *crash_reporting.read(); - if *first.peek() { - first.set(false); - return; - } - let mut next = settings.clone(); - next.discord_enabled = discord; -+ next.crash_reporting = crash; - dispatch.set_settings(next); - }); - } -@@ -46,6 +53,12 @@ impl Component for SettingsLauncher { - "Enable Discord Rich Presence.", - toggle(discord_rpc), - )) -+ .child(settings_row( -+ IconType::AlertTriangle, -+ "Crash Reporting", -+ "Send anonymous crash and error reports to help fix bugs. Applies on restart.", -+ toggle(crash_reporting), -+ )) - .child(section_header("FOLDERS AND FILES")) - .child(settings_row( - IconType::Folder, -diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs -index 5b88f8d..bbd2041 100644 ---- a/packages/oneclient_app/src/view/app/settings/minecraft.rs -+++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs -@@ -2,9 +2,9 @@ use freya::prelude::*; - use oneclient_core::Patch; - use oneclient_core::settings::{ProfileUpdate, Resolution}; - -+use super::settings_page; - use crate::components::{Icon, IconType, TextInput, toggle, validate_number}; - use crate::hooks::{use_dispatch, use_settings_snapshot}; --use super::settings_page; - use crate::theme::colors; - use crate::view::app::settings::{section_header, settings_row}; - -@@ -21,11 +21,17 @@ impl Component for SettingsMinecraft { - move || v - }); - let width = use_state({ -- let v = profile.resolution.map(|r| r.width.to_string()).unwrap_or_default(); -+ let v = profile -+ .resolution -+ .map(|r| r.width.to_string()) -+ .unwrap_or_default(); - move || v - }); - let height = use_state({ -- let v = profile.resolution.map(|r| r.height.to_string()).unwrap_or_default(); -+ let v = profile -+ .resolution -+ .map(|r| r.height.to_string()) -+ .unwrap_or_default(); - move || v - }); - let memory = use_state({ -@@ -165,7 +171,11 @@ fn resolution_field(width: State, height: State) -> impl IntoEle - .width(Size::px(70.)) - .text_align(TextAlign::Center), - ) -- .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) -+ .child( -+ Icon::new(IconType::X) -+ .size(14.) -+ .color(colors::fg_secondary()), -+ ) - .child( - TextInput::new(height) - .placeholder("480") -diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs -index 88e39f5..16a2434 100644 ---- a/packages/oneclient_app/src/view/app/settings/mod.rs -+++ b/packages/oneclient_app/src/view/app/settings/mod.rs -@@ -15,14 +15,14 @@ pub use apis::SettingsApis; - pub use changelog::SettingsChangelog; - pub use developer::SettingsDeveloper; - // pub use feedback::SettingsFeedback; --pub use java::SettingsJava; - pub use appearance::SettingsAppearance; -+pub use java::SettingsJava; - pub use language::SettingsLanguage; - pub use launcher::SettingsLauncher; - pub use minecraft::SettingsMinecraft; - - use crate::{ -- components::{IconType, Icon}, -+ components::{Icon, IconType}, - theme::colors, - }; - -diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs -index 121faa4..b9f4b39 100644 ---- a/packages/oneclient_app/src/view/app/skins.rs -+++ b/packages/oneclient_app/src/view/app/skins.rs -@@ -1,4 +1,3 @@ -- - use freya::prelude::*; - - use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; -@@ -120,7 +119,11 @@ fn side_panel() -> impl IntoElement { - - fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { - Button::new() -- .variant(if primary { ButtonVariant::Primary } else { ButtonVariant::Secondary }) -+ .variant(if primary { -+ ButtonVariant::Primary -+ } else { -+ ButtonVariant::Secondary -+ }) - .child(Icon::new(icon).size(16.).color(colors::fg_primary())) - .text(text) - .into_element() -diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs -index cf49c6a..6714e42 100644 ---- a/packages/oneclient_app/src/view/mod.rs -+++ b/packages/oneclient_app/src/view/mod.rs -@@ -1,8 +1,8 @@ - mod not_found; - mod startup; - --pub mod onboarding; - pub mod app; -+pub mod onboarding; - - pub use not_found::NotFound; --pub use startup::Startup; -\ No newline at end of file -+pub use startup::Startup; -diff --git a/packages/oneclient_app/src/view/not_found.rs b/packages/oneclient_app/src/view/not_found.rs -index 4edd701..8dfac53 100644 ---- a/packages/oneclient_app/src/view/not_found.rs -+++ b/packages/oneclient_app/src/view/not_found.rs -@@ -32,4 +32,4 @@ impl Component for NotFound { - .color(theme::colors::fg_secondary()), - ) - } --} -\ No newline at end of file -+} -diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs -index 707bd39..7298282 100644 ---- a/packages/oneclient_app/src/view/onboarding/account.rs -+++ b/packages/oneclient_app/src/view/onboarding/account.rs -@@ -125,12 +125,7 @@ fn sign_in_card( - .size(13.) - .color(colors::danger()), - ) -- .child( -- label() -- .text(message) -- .font_size(12.) -- .color(colors::danger()), -- ) -+ .child(label().text(message).font_size(12.).color(colors::danger())) - .into_element() - })) - .into_element() -diff --git a/packages/oneclient_app/src/view/onboarding/bundles/card.rs b/packages/oneclient_app/src/view/onboarding/bundles/card.rs -index 8398ecf..4263ad2 100644 ---- a/packages/oneclient_app/src/view/onboarding/bundles/card.rs -+++ b/packages/oneclient_app/src/view/onboarding/bundles/card.rs -@@ -2,9 +2,7 @@ use super::*; - - use oneclient_core::clusters::Cluster; - --use crate::components::{ -- Button, DynamicArt, Icon, IconType, --}; -+use crate::components::{Button, DynamicArt, Icon, IconType}; - use crate::theme::colors; - use crate::ui::border_all_color; - -diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs -index dff095c..78a1643 100644 ---- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs -+++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs -@@ -8,8 +8,7 @@ use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; - - use crate::components::ScrollArea; - use crate::hooks::{ -- ClusterBundles, onboarding_bundles_items, -- use_onboarding_bundles, use_onboarding_selection, -+ ClusterBundles, onboarding_bundles_items, use_onboarding_bundles, use_onboarding_selection, - }; - use crate::routes::Route; - use crate::theme::colors; -@@ -321,4 +320,3 @@ impl Component for OnboardingBundles { - .maybe_child(popup) - } - } -- -diff --git a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs -index 5259193..05d888d 100644 ---- a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs -+++ b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs -@@ -6,12 +6,8 @@ use freya::query::QueryStateData; - use oneclient_core::packages::{CachedPackageMeta, ProviderId}; - use oneclient_core::{BundleArchive, BundleFileKind}; - --use crate::components::{ -- Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem, --}; --use crate::hooks::{ -- package_meta_batch, use_cached_image, use_package_meta_batch, --}; -+use crate::components::{Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem}; -+use crate::hooks::{package_meta_batch, use_cached_image, use_package_meta_batch}; - use crate::theme::colors; - use crate::ui::border_all_color; - use crate::utils::format_size; -diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs -index 5e9dcc9..ff5d2c9 100644 ---- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs -+++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs -@@ -46,7 +46,10 @@ pub(super) fn summary_view( - - let migration_section: Element = match migration { - Some((source, folder, target)) => { -- let mut rows = vec![summary_line("From", &source), summary_line("Files", &folder)]; -+ let mut rows = vec![ -+ summary_line("From", &source), -+ summary_line("Files", &folder), -+ ]; - if !target.is_empty() { - rows.push(summary_line("Destination", &target)); - } -diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs -index 8025652..75b15ad 100644 ---- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs -+++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs -@@ -50,7 +50,14 @@ pub(super) fn use_onboarding_tip() -> String { - let Ok(state) = oneclient_core::LauncherState::get() else { - return; - }; -- match state.services.requester.http().get(FUNFACTS_URL).send().await { -+ match state -+ .services -+ .requester -+ .http() -+ .get(FUNFACTS_URL) -+ .send() -+ .await -+ { - Ok(res) if res.status().is_success() => match res.text().await { - Ok(text) => { - let parsed = parse_funfacts(&text); -diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs -index c4bb80a..7166f53 100644 ---- a/packages/oneclient_app/src/view/onboarding/language.rs -+++ b/packages/oneclient_app/src/view/onboarding/language.rs -@@ -14,9 +14,10 @@ struct Language { - percentage: u32, - } - --const LANGUAGES: [Language; 1] = [ -- Language { name: "English", percentage: 100 }, --]; -+const LANGUAGES: [Language; 1] = [Language { -+ name: "English", -+ percentage: 100, -+}]; - - #[derive(PartialEq)] - pub struct OnboardingLanguage; -@@ -29,15 +30,13 @@ impl Component for OnboardingLanguage { - let back = if has_migration_data(&migration_query) { - Route::OnboardingMigration {} - } else { -- Route::OnboardingWelcome {} -+ Route::OnboardingTerms {} - }; - - let rows: Vec = LANGUAGES - .iter() - .enumerate() -- .map(|(index, lang)| { -- language_row(lang, index == *selected.read(), selected, language) -- }) -+ .map(|(index, lang)| language_row(lang, index == *selected.read(), selected, language)) - .collect(); - - let content = rect() -@@ -55,7 +54,13 @@ impl Component for OnboardingLanguage { - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) -- .child(rect().vertical().width(Size::fill()).spacing(4.).children(rows)) -+ .child( -+ rect() -+ .vertical() -+ .width(Size::fill()) -+ .spacing(4.) -+ .children(rows), -+ ) - .into_element(); - - onboarding_page( -@@ -92,7 +97,9 @@ fn language_row( - .padding(Gaps::new_symmetric(14., 20.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(background) -- .maybe(active, |el| el.border(border_all_color(1., colors::brand()))) -+ .maybe(active, |el| { -+ el.border(border_all_color(1., colors::brand())) -+ }) - .a11y_role(AccessibilityRole::Button) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) -diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs -index aca061c..f457a13 100644 ---- a/packages/oneclient_app/src/view/onboarding/migration.rs -+++ b/packages/oneclient_app/src/view/onboarding/migration.rs -@@ -27,9 +27,10 @@ fn build_migrated_selection(source: &[SourceInstance], new: &[ClusterBundles]) - - let mut selected = HashSet::new(); - - for cb in new { -- let Some(instance) = source.iter().find(|o| { -- o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader -- }) else { -+ let Some(instance) = source -+ .iter() -+ .find(|o| o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader) -+ else { - continue; - }; - -@@ -89,7 +90,10 @@ impl Component for OnboardingMigration { - // No detection yet -> render nothing meaningful; the shell only routes - // here when a source was detected, so this is a transient loading state. - let Some(detection) = migration_detection(&migration_query) else { -- return rect().width(Size::fill()).height(Size::fill()).into_element(); -+ return rect() -+ .width(Size::fill()) -+ .height(Size::fill()) -+ .into_element(); - }; - let source_name = detection.source.display_name(); - let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); -@@ -103,8 +107,7 @@ impl Component for OnboardingMigration { - let mut version_cards: Vec = Vec::new(); - for instance in &detection.instances { - let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); -- let dedicated_available = -- matching_new_cluster_id(instance, &new_clusters).is_some(); -+ let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); - - version_cards.push(version_card( - instance, -@@ -259,26 +262,16 @@ fn version_card( - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) -- .child( -- label() -- .text(subtitle) -- .font_size(11.) -- .color(if selected { -- colors::fg_primary() -- } else { -- colors::fg_secondary() -- }), -- ) -- .child( -- label() -- .text(categories) -- .font_size(11.) -- .color(if selected { -- colors::fg_primary() -- } else { -- colors::fg_secondary() -- }), -- ), -+ .child(label().text(subtitle).font_size(11.).color(if selected { -+ colors::fg_primary() -+ } else { -+ colors::fg_secondary() -+ })) -+ .child(label().text(categories).font_size(11.).color(if selected { -+ colors::fg_primary() -+ } else { -+ colors::fg_secondary() -+ })), - ) - // Selection affordance only when the instance actually has files. - .maybe_child(importable.then(|| { -@@ -471,7 +464,7 @@ fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl In - .secondary() - .width(Size::px(128.)) - .on_press(move |_| { -- let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); -+ let _ = RouterContext::get().replace(Route::OnboardingTerms {}); - }) - .text("Back"), - ) -diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs -index d9e14f9..b7403f1 100644 ---- a/packages/oneclient_app/src/view/onboarding/mod.rs -+++ b/packages/oneclient_app/src/view/onboarding/mod.rs -@@ -4,6 +4,7 @@ mod downloading; - mod language; - mod migration; - mod preferences; -+mod terms; - mod welcome; - - pub use account::OnboardingAccount; -@@ -13,6 +14,7 @@ pub use language::OnboardingLanguage; - pub use migration::OnboardingMigration; - pub(crate) use migration::matching_new_cluster_id; - pub use preferences::OnboardingPreferences; -+pub use terms::OnboardingTerms; - pub use welcome::OnboardingWelcome; - - use std::sync::atomic::{AtomicUsize, Ordering}; -@@ -30,7 +32,7 @@ use crate::ui::{border_all_color, entrance_motion_layer}; - /// Number of onboarding steps. The v1-migration step only exists (and is only - /// counted) when old launcher data was detected. - pub fn onboarding_total(has_migration: bool) -> usize { -- if has_migration { 7 } else { 6 } -+ if has_migration { 8 } else { 7 } - } - - pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { -@@ -38,12 +40,13 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { - - match route { - Route::OnboardingWelcome {} => 0, -- Route::OnboardingMigration {} => 1, -- Route::OnboardingLanguage {} => 1 + shift, -- Route::OnboardingAccount {} => 2 + shift, -- Route::OnboardingBundles {} => 3 + shift, -- Route::OnboardingPreferences {} => 4 + shift, -- Route::OnboardingDownloading {} => 5 + shift, -+ Route::OnboardingTerms {} => 1, -+ Route::OnboardingMigration {} => 2, -+ Route::OnboardingLanguage {} => 2 + shift, -+ Route::OnboardingAccount {} => 3 + shift, -+ Route::OnboardingBundles {} => 4 + shift, -+ Route::OnboardingPreferences {} => 5 + shift, -+ Route::OnboardingDownloading {} => 6 + shift, - _ => 0, - } - } -@@ -80,8 +83,16 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { - } - }); - -- let p = if anim_finished { 1.0 } else { anim.get().value() }; -- let slide_x = if motion { direction * (1. - p) * SLIDE_DISTANCE } else { 0. }; -+ let p = if anim_finished { -+ 1.0 -+ } else { -+ anim.get().value() -+ }; -+ let slide_x = if motion { -+ direction * (1. - p) * SLIDE_DISTANCE -+ } else { -+ 0. -+ }; - let opacity = if motion && !anim_finished { p } else { 1. }; - - rect() -@@ -91,7 +102,11 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { - .into_element() - } - --pub(crate) fn onboarding_page(illustration: impl IntoElement, content: impl IntoElement, nav: impl IntoElement) -> impl IntoElement { -+pub(crate) fn onboarding_page( -+ illustration: impl IntoElement, -+ content: impl IntoElement, -+ nav: impl IntoElement, -+) -> impl IntoElement { - rect() - .vertical() - .width(Size::fill()) -@@ -130,7 +145,11 @@ pub(crate) fn onboarding_illustration(icon: IconType) -> impl IntoElement { - Icon::new(icon).size(240.).color(Color::WHITE) - } - --pub(crate) fn onboarding_nav(back: Option, next: Route, next_enabled: bool) -> impl IntoElement { -+pub(crate) fn onboarding_nav( -+ back: Option, -+ next: Route, -+ next_enabled: bool, -+) -> impl IntoElement { - rect() - .horizontal() - .width(Size::fill()) -diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs -new file mode 100644 -index 0000000..7b4d746 ---- /dev/null -+++ b/packages/oneclient_app/src/view/onboarding/terms.rs -@@ -0,0 +1,304 @@ -+use freya::prelude::*; -+use freya::query::UseQuery; -+use freya::router::RouterContext; -+ -+use crate::components::{Button, Icon, IconType, 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, -+}; -+use crate::platform::open_url; -+use crate::routes::Route; -+use crate::theme::colors; -+use crate::ui::border_all_color; -+use crate::view::onboarding::{onboarding_illustration, onboarding_page, step_heading}; -+ -+#[derive(Clone, Copy, PartialEq)] -+enum LegalTab { -+ Terms, -+ Privacy, -+} -+ -+#[derive(PartialEq)] -+pub struct OnboardingTerms; -+ -+impl Component for OnboardingTerms { -+ fn render(&self) -> impl IntoElement { -+ let settings = use_settings_snapshot().settings; -+ let dispatch = use_dispatch(); -+ let query = use_terms(); -+ let migration_query = use_migration(); -+ -+ let document = terms_document(&query); -+ let error = terms_error(&query); -+ let loading = terms_is_loading(&query); -+ -+ let accepted = use_state(|| false); -+ let tab = use_state(|| LegalTab::Terms); -+ -+ let returning = settings.seen_onboarding; -+ let next = if returning { -+ Route::Home {} -+ } else if has_migration_data(&migration_query) { -+ Route::OnboardingMigration {} -+ } else { -+ Route::OnboardingLanguage {} -+ }; -+ let back = (!returning).then_some(Route::OnboardingWelcome {}); -+ -+ let terms_version = document.as_ref().map(|doc| doc.version).unwrap_or(1); -+ let privacy_version = document -+ .as_ref() -+ .map(|doc| doc.privacy_version()) -+ .unwrap_or(1); -+ let terms_url = document -+ .as_ref() -+ .map(|doc| doc.terms_url().to_string()) -+ .unwrap_or_else(|| oneclient_core::constants::TOS_URL.to_string()); -+ let privacy_url = document -+ .as_ref() -+ .map(|doc| doc.privacy_url().to_string()) -+ .unwrap_or_else(|| oneclient_core::constants::PRIVACY_URL.to_string()); -+ -+ let privacy_body = document.as_ref().and_then(|doc| doc.privacy_body()); -+ let tabs = privacy_body.is_some().then(|| tab_switcher(tab)); -+ -+ let body = if loading { -+ loading_body() -+ } else if let Some(document) = document.as_ref() { -+ match (*tab.read(), privacy_body) { -+ (LegalTab::Privacy, Some(privacy)) => markdown_body(privacy), -+ _ => markdown_body(&document.terms), -+ } -+ } else { -+ fallback_body(error.as_deref(), query) -+ }; -+ -+ let content = rect() -+ .vertical() -+ .width(Size::fill()) -+ .spacing(16.) -+ .child(step_heading( -+ "Terms & Privacy", -+ "Please read and accept these before continuing.", -+ )) -+ .maybe_child(tabs) -+ .child(body) -+ .child(link_row(terms_url, privacy_url)) -+ .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 _ = RouterContext::get().replace(next.clone()); -+ }), -+ ) -+ } -+} -+ -+fn panel(child: impl IntoElement) -> impl IntoElement { -+ rect() -+ .vertical() -+ .width(Size::fill()) -+ .height(Size::px(220.)) -+ .content(Content::Flex) -+ .corner_radius(CornerRadius::new_all(12.)) -+ .background(colors::page_elevated()) -+ .border(border_all_color(1., colors::component_border())) -+ .child(child) -+} -+ -+fn loading_body() -> Element { -+ panel( -+ rect() -+ .width(Size::fill()) -+ .height(Size::fill()) -+ .center() -+ .child( -+ label() -+ .text("Loading the Terms of Service and Privacy Policy...") -+ .font_size(13.) -+ .color(colors::fg_secondary()), -+ ), -+ ) -+ .into_element() -+} -+ -+fn tab_switcher(tab: State) -> Element { -+ SegmentedControl::new(tab) -+ .segment(Segment::new(LegalTab::Terms).label("Terms")) -+ .segment(Segment::new(LegalTab::Privacy).label("Privacy")) -+ .into_element() -+} -+ -+fn markdown_body(markdown: &str) -> Element { -+ panel( -+ ScrollArea::new() -+ .width(Size::fill()) -+ .height(Size::flex(1.0)) -+ .padding(Gaps::new_all(16.)) -+ .child( -+ MarkdownViewer::new(markdown.to_string()) -+ .width(Size::fill()) -+ .color(colors::fg_primary()) -+ .color_link(colors::code_info()) -+ .background_code(colors::component_bg()) -+ .color_code(colors::fg_primary()) -+ .background_blockquote(colors::component_bg()) -+ .border_blockquote(colors::brand()) -+ .background_divider(colors::component_border()) -+ .heading_h1(20.) -+ .heading_h2(17.) -+ .heading_h3(15.) -+ .heading_h4(14.) -+ .heading_h5(13.) -+ .heading_h6(12.) -+ .paragraph_size(13.) -+ .code_font_size(12.), -+ ), -+ ) -+ .into_element() -+} -+ -+fn fallback_body(error: Option<&str>, query: UseQuery) -> Element { -+ panel( -+ rect() -+ .vertical() -+ .width(Size::fill()) -+ .height(Size::fill()) -+ .center() -+ .spacing(10.) -+ .padding(Gaps::new_all(20.)) -+ .child( -+ Icon::new(IconType::AlertTriangle) -+ .size(22.) -+ .color(colors::code_warn()), -+ ) -+ .child( -+ label() -+ .text("Couldn't load the Terms of Service or Privacy Policy.") -+ .font_size(14.) -+ .font_weight(FontWeight::MEDIUM) -+ .color(colors::fg_primary()), -+ ) -+ .child( -+ label() -+ .text( -+ "Open them in your browser using the links below, or try again once \ -+ you're back online.", -+ ) -+ .font_size(12.) -+ .text_align(TextAlign::Center) -+ .color(colors::fg_secondary()), -+ ) -+ .maybe_child(error.map(|err| { -+ label() -+ .text(err.to_string()) -+ .font_size(11.) -+ .max_lines(2) -+ .color(colors::fg_secondary().with_a(160)) -+ })) -+ .child( -+ Button::new() -+ .secondary() -+ .small() -+ .on_press(move |_| { -+ query.invalidate(); -+ }) -+ .text("Retry") -+ .child(Icon::new(IconType::RefreshCw01).size(14.)), -+ ), -+ ) -+ .into_element() -+} -+ -+fn link_row(terms_url: String, privacy_url: String) -> impl IntoElement { -+ rect() -+ .horizontal() -+ .width(Size::fill()) -+ .spacing(10.) -+ .child(external_link_button("Terms of Service", terms_url)) -+ .child(external_link_button("Privacy Policy", privacy_url)) -+ .into_element() -+} -+ -+fn external_link_button(text: &'static str, url: String) -> impl IntoElement { -+ Button::new() -+ .secondary() -+ .small() -+ .on_press(move |_| open_url(&url)) -+ .text(text) -+ .child(Icon::new(IconType::LinkExternal01).size(14.)) -+} -+ -+fn accept_row(accepted: State) -> impl IntoElement { -+ rect() -+ .horizontal() -+ .width(Size::fill()) -+ .cross_align(Alignment::Center) -+ .spacing(16.) -+ .content(Content::Flex) -+ .padding(Gaps::new_symmetric(12., 16.)) -+ .corner_radius(CornerRadius::new_all(12.)) -+ .background(colors::page_elevated()) -+ .border(border_all_color(1., colors::component_border())) -+ .child( -+ rect() -+ .vertical() -+ .width(Size::flex(1.0)) -+ .spacing(3.) -+ .child( -+ label() -+ .text("I accept the Terms of Service and Privacy Policy") -+ .font_size(14.) -+ .font_weight(FontWeight::MEDIUM) -+ .color(colors::fg_primary()), -+ ) -+ .child( -+ label() -+ .text("Required to use OneClient.") -+ .font_size(11.) -+ .color(colors::fg_secondary()), -+ ), -+ ) -+ .child(toggle(accepted)) -+ .into_element() -+} -+ -+fn terms_nav( -+ back: Option, -+ next_enabled: bool, -+ on_next: impl FnMut() + 'static, -+) -> impl IntoElement { -+ let mut on_next = on_next; -+ rect() -+ .horizontal() -+ .width(Size::fill()) -+ .main_align(Alignment::End) -+ .cross_align(Alignment::Center) -+ .spacing(12.) -+ .padding(Gaps::new(0., 40., 32., 40.)) -+ .maybe_child(back.map(|route| { -+ Button::new() -+ .secondary() -+ .width(Size::px(128.)) -+ .on_press(move |_| { -+ let _ = RouterContext::get().replace(route.clone()); -+ }) -+ .text("Back") -+ .into_element() -+ })) -+ .child( -+ Button::new() -+ .primary() -+ .width(Size::px(140.)) -+ .enabled(next_enabled) -+ .on_press(move |_| on_next()) -+ .text("Next") -+ .child(Icon::new(IconType::ArrowRight).size(16.)), -+ ) -+ .into_element() -+} -diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs -index 325c883..37ad66e 100644 ---- a/packages/oneclient_app/src/view/onboarding/welcome.rs -+++ b/packages/oneclient_app/src/view/onboarding/welcome.rs -@@ -1,7 +1,6 @@ - use freya::prelude::*; - - use crate::components::IconType; --use crate::hooks::{has_migration_data, use_migration}; - use crate::routes::Route; - use crate::theme::colors; - use crate::view::onboarding::{ -@@ -13,14 +12,6 @@ pub struct OnboardingWelcome; - - impl Component for OnboardingWelcome { - fn render(&self) -> impl IntoElement { -- let migration_query = use_migration(); -- -- let next = if has_migration_data(&migration_query) { -- Route::OnboardingMigration {} -- } else { -- Route::OnboardingLanguage {} -- }; -- - let content = rect() - .vertical() - .width(Size::fill()) -@@ -40,7 +31,7 @@ impl Component for OnboardingWelcome { - onboarding_page( - onboarding_illustration(IconType::OnboardingWelcome), - content, -- onboarding_nav(None, next, true), -+ onboarding_nav(None, Route::OnboardingTerms {}, true), - ) - } - } -diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs -index e5540bf..db41121 100644 ---- a/packages/oneclient_app/src/view/startup.rs -+++ b/packages/oneclient_app/src/view/startup.rs -@@ -3,7 +3,10 @@ use freya::{prelude::*, router::*}; - - use crate::AppAssets; - use crate::Route; --use crate::hooks::{use_launcher, use_notifications_snapshot, use_settings_snapshot}; -+use crate::hooks::{ -+ terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, -+ use_settings_snapshot, use_terms, -+}; - use crate::theme::colors; - - #[derive(PartialEq)] -@@ -14,6 +17,7 @@ impl Component for Startup { - let launcher = use_launcher(); - let settings = use_settings_snapshot(); - let notifications = use_notifications_snapshot(); -+ let terms = use_terms(); - - let intro = use_animation(|conf| { - conf.on_creation(OnCreation::Run); -@@ -34,37 +38,49 @@ impl Component for Startup { - - let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); - -- if launcher.ready && !launcher.fetching { -- let destination = if settings.settings.seen_onboarding { -- Route::Home {} -- } else { -+ if launcher.ready && !launcher.fetching && !terms_is_loading(&terms) { -+ let document = terms_document(&terms); -+ let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); -+ let required_privacy = document -+ .as_ref() -+ .map(|doc| doc.privacy_version()) -+ .unwrap_or(1); -+ -+ let stale = settings.settings.accepted_tos_version < required_terms -+ || settings.settings.accepted_privacy_version < required_privacy; -+ -+ let destination = if !settings.settings.seen_onboarding { - Route::OnboardingWelcome {} -+ } else if stale { -+ Route::OnboardingTerms {} -+ } else { -+ Route::Home {} - }; - let _ = RouterContext::get().replace(destination); - return rect().into_element(); - } - -- let active = notifications -- .inbox -- .iter() -- .find(|entry| entry.is_loading); -- -- let (message, detail): (String, Option) = if let Some(err) = launcher.error.as_deref() { -- ("Couldn't start OneClient".to_string(), Some(err.to_string())) -- -- } else if let Some(entry) = active { -- let detail = entry -- .tasks -- .iter() -- .find(|t| t.total == 0 || t.current < t.total) -- .map(|t| t.label.clone()) -- .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); -- (entry.title.clone(), detail) -- } else if launcher.ready { -- ("Fetching versions and bundles...".to_string(), None) -- } else { -- ("Starting OneClient...".to_string(), None) -- }; -+ let active = notifications.inbox.iter().find(|entry| entry.is_loading); -+ -+ let (message, detail): (String, Option) = -+ if let Some(err) = launcher.error.as_deref() { -+ ( -+ "Couldn't start OneClient".to_string(), -+ Some(err.to_string()), -+ ) -+ } else if let Some(entry) = active { -+ let detail = entry -+ .tasks -+ .iter() -+ .find(|t| t.total == 0 || t.current < t.total) -+ .map(|t| t.label.clone()) -+ .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); -+ (entry.title.clone(), detail) -+ } else if launcher.ready { -+ ("Fetching versions and bundles...".to_string(), None) -+ } else { -+ ("Starting OneClient...".to_string(), None) -+ }; - - let is_error = launcher.error.is_some(); - -diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml -index 89cfd4d..e788dd2 100644 ---- a/packages/oneclient_core/Cargo.toml -+++ b/packages/oneclient_core/Cargo.toml -@@ -56,9 +56,17 @@ path = "examples/launch_game.rs" - name = "notifications" - path = "examples/notifications.rs" - -+[[example]] -+name = "discord_rpc" -+path = "examples/discord_rpc.rs" -+ -+[dev-dependencies] -+async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } -+ - [dependencies] - # core (extra) - # gamemode_rs.workspace = true -+discord-rich-presence.workspace = true - interfrost.workspace = true - oneclient_db.workspace = true - polyio.workspace = true -@@ -68,6 +76,9 @@ tracing.workspace = true - tracing-futures.workspace = true - tracing-subscriber.workspace = true - -+sentry.workspace = true -+sentry-tracing.workspace = true -+ - # database - sqlx.workspace = true - -@@ -76,9 +87,10 @@ async-trait.workspace = true - tokio.workspace = true - tokio-stream.workspace = true - tokio-util.workspace = true --futures-util = { workspace = true, features = ["std"] } -+futures-util = { workspace = true, features = ["std", "sink"] } - futures-lite.workspace = true - reqwest.workspace = true -+reqwest-websocket.workspace = true - async_zip.workspace = true - async-tempfile.workspace = true - async-stream.workspace = true -diff --git a/packages/oneclient_core/examples/discord_rpc.rs b/packages/oneclient_core/examples/discord_rpc.rs -new file mode 100644 -index 0000000..b2fa726 ---- /dev/null -+++ b/packages/oneclient_core/examples/discord_rpc.rs -@@ -0,0 +1,33 @@ -+use std::time::Duration; -+ -+use oneclient_core::discord::{DiscordRpc, Presence}; -+ -+fn main() { -+ tracing_subscriber::fmt() -+ .with_max_level(tracing::Level::DEBUG) -+ .init(); -+ -+ let rpc = DiscordRpc::spawn(true); -+ -+ println!("idle presence for 10s..."); -+ std::thread::sleep(Duration::from_secs(10)); -+ -+ println!("playing presence for 10s..."); -+ rpc.set_presence(Presence::Playing { -+ cluster: "Example Cluster".to_owned(), -+ mc_version: "1.8.9".to_owned(), -+ }); -+ std::thread::sleep(Duration::from_secs(10)); -+ -+ println!("disabling for 5s..."); -+ rpc.set_enabled(false); -+ std::thread::sleep(Duration::from_secs(5)); -+ -+ println!("re-enabling for 10s..."); -+ rpc.set_enabled(true); -+ std::thread::sleep(Duration::from_secs(10)); -+ -+ println!("shutting down"); -+ rpc.shutdown(); -+ std::thread::sleep(Duration::from_secs(1)); -+} -diff --git a/packages/oneclient_core/src/constants.rs b/packages/oneclient_core/src/constants.rs -index ee2411d..6d01a60 100644 ---- a/packages/oneclient_core/src/constants.rs -+++ b/packages/oneclient_core/src/constants.rs -@@ -16,6 +16,14 @@ pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; - pub const SKYCLIENT_BASE_URL: &str = - "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; - pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; -+pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; -+pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; -+pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; -+ -+pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { -+ Some(dsn) => dsn, -+ None => "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040", -+}; - - pub const TARGET_OS: &str = cfg_select! { - target_os = "windows" => "windows", -diff --git a/packages/oneclient_core/src/crypto.rs b/packages/oneclient_core/src/crypto.rs -index 52f04cd..8bc68f5 100644 ---- a/packages/oneclient_core/src/crypto.rs -+++ b/packages/oneclient_core/src/crypto.rs -@@ -33,7 +33,7 @@ pub fn normalize_hash(hash: &str) -> String { - hash.trim().to_ascii_lowercase() - } - --fn to_hex(data: &[u8]) -> String { -+pub(crate) fn to_hex(data: &[u8]) -> String { - data.iter().fold(String::new(), |mut out, b| { - let _ = write!(out, "{b:02x}"); - out -diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs -index 3e6595d..97dbffa 100644 ---- a/packages/oneclient_core/src/dev.rs -+++ b/packages/oneclient_core/src/dev.rs -@@ -239,6 +239,7 @@ pub async fn ephemeral_state() -> LauncherResult> { - versions: Arc::new(crate::versions::VersionsManager::new()), - images: crate::images::ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), -+ discord: crate::discord::DiscordRpc::spawn(false), - })) - } - -diff --git a/packages/oneclient_core/src/discord.rs b/packages/oneclient_core/src/discord.rs -new file mode 100644 -index 0000000..ac18739 ---- /dev/null -+++ b/packages/oneclient_core/src/discord.rs -@@ -0,0 +1,226 @@ -+use std::sync::mpsc::{self, RecvTimeoutError, Sender}; -+use std::time::{Duration, Instant}; -+ -+use discord_rich_presence::activity::{Activity, Assets, Button, Timestamps}; -+use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; -+ -+use crate::constants::DISCORD_CLIENT_ID; -+ -+const RECONNECT_BACKOFF: Duration = Duration::from_secs(60); -+ -+const IDLE_POLL: Duration = Duration::from_secs(3600); -+ -+const LARGE_IMAGE: &str = "onelauncher_logo_512"; -+ -+#[derive(Debug, Clone, PartialEq, Eq, Default)] -+pub enum Presence { -+ #[default] -+ Idle, -+ Playing { -+ cluster: String, -+ mc_version: String, -+ }, -+} -+ -+impl Presence { -+ fn details(&self) -> &'static str { -+ match self { -+ Self::Idle => "Idle", -+ Self::Playing { .. } => "Playing Minecraft", -+ } -+ } -+ -+ fn state(&self) -> String { -+ match self { -+ Self::Idle => "In the launcher".to_owned(), -+ Self::Playing { -+ cluster, -+ mc_version, -+ } => format!("{cluster} ({mc_version})"), -+ } -+ } -+} -+ -+enum Command { -+ SetPresence(Presence), -+ SetEnabled(bool), -+ Shutdown, -+} -+ -+#[derive(Clone)] -+pub struct DiscordRpc { -+ tx: Sender, -+} -+ -+impl DiscordRpc { -+ pub fn spawn(enabled: bool) -> Self { -+ let (tx, rx) = mpsc::channel(); -+ -+ std::thread::Builder::new() -+ .name("discord-rpc".to_owned()) -+ .spawn(move || Worker::new(enabled).run(&rx)) -+ .expect("failed to spawn the Discord RPC thread"); -+ -+ Self { tx } -+ } -+ -+ pub fn set_presence(&self, presence: Presence) { -+ self.send(Command::SetPresence(presence)); -+ } -+ -+ pub fn set_enabled(&self, enabled: bool) { -+ self.send(Command::SetEnabled(enabled)); -+ } -+ -+ pub fn shutdown(&self) { -+ self.send(Command::Shutdown); -+ } -+ -+ fn send(&self, command: Command) { -+ if self.tx.send(command).is_err() { -+ tracing::debug!("Discord RPC worker is gone; dropping command"); -+ } -+ } -+} -+ -+struct Worker { -+ client: DiscordIpcClient, -+ enabled: bool, -+ connected: bool, -+ ever_connected: bool, -+ presence: Presence, -+ presence_since: i64, -+ next_attempt: Instant, -+} -+ -+impl Worker { -+ fn new(enabled: bool) -> Self { -+ Self { -+ client: DiscordIpcClient::new(DISCORD_CLIENT_ID), -+ enabled, -+ connected: false, -+ ever_connected: false, -+ presence: Presence::Idle, -+ presence_since: chrono::Utc::now().timestamp(), -+ next_attempt: Instant::now(), -+ } -+ } -+ -+ fn run(mut self, rx: &mpsc::Receiver) { -+ self.publish(); -+ -+ loop { -+ match rx.recv_timeout(self.poll_interval()) { -+ Ok(Command::SetPresence(presence)) => self.set_presence(presence), -+ Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), -+ Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, -+ Err(RecvTimeoutError::Timeout) => self.publish(), -+ } -+ } -+ -+ self.disconnect(); -+ } -+ -+ fn poll_interval(&self) -> Duration { -+ if !self.enabled || self.connected { -+ return IDLE_POLL; -+ } -+ -+ self.next_attempt -+ .saturating_duration_since(Instant::now()) -+ .max(Duration::from_millis(100)) -+ } -+ -+ fn set_presence(&mut self, presence: Presence) { -+ if self.presence == presence { -+ return; -+ } -+ -+ self.presence = presence; -+ self.presence_since = chrono::Utc::now().timestamp(); -+ self.publish(); -+ } -+ -+ fn set_enabled(&mut self, enabled: bool) { -+ if self.enabled == enabled { -+ return; -+ } -+ -+ self.enabled = enabled; -+ -+ if enabled { -+ self.next_attempt = Instant::now(); -+ self.publish(); -+ } else { -+ self.disconnect(); -+ } -+ } -+ -+ fn publish(&mut self) { -+ if !self.enabled || !self.ensure_connected() { -+ return; -+ } -+ -+ let state = self.presence.state(); -+ let activity = Activity::new() -+ .details(self.presence.details()) -+ .state(&state) -+ .timestamps(Timestamps::new().start(self.presence_since)) -+ .assets( -+ Assets::new() -+ .large_image(LARGE_IMAGE) -+ .large_text("OneClient"), -+ ) -+ .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); -+ -+ if let Err(err) = self.client.set_activity(activity) { -+ tracing::warn!("Discord RPC set_activity failed: {err}"); -+ self.drop_connection(); -+ } -+ } -+ -+ fn ensure_connected(&mut self) -> bool { -+ if self.connected { -+ return true; -+ } -+ -+ if Instant::now() < self.next_attempt { -+ return false; -+ } -+ self.next_attempt = Instant::now() + RECONNECT_BACKOFF; -+ -+ let result = if self.ever_connected { -+ self.client.reconnect() -+ } else { -+ self.client.connect() -+ }; -+ -+ match result { -+ Ok(()) => { -+ tracing::info!("Discord RPC connected"); -+ self.connected = true; -+ self.ever_connected = true; -+ true -+ } -+ Err(err) => { -+ tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); -+ false -+ } -+ } -+ } -+ -+ fn drop_connection(&mut self) { -+ self.connected = false; -+ self.next_attempt = Instant::now() + RECONNECT_BACKOFF; -+ } -+ -+ fn disconnect(&mut self) { -+ if !self.connected { -+ return; -+ } -+ -+ let _ = self.client.clear_activity(); -+ let _ = self.client.close(); -+ self.connected = false; -+ } -+} -diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs -index 08cbe32..cc0b801 100644 ---- a/packages/oneclient_core/src/game/launch.rs -+++ b/packages/oneclient_core/src/game/launch.rs -@@ -12,6 +12,7 @@ use tokio::sync::Mutex as AsyncMutex; - use crate::auth::MinecraftAccount; - use crate::ClusterStage; - use crate::clusters::ClusterManager; -+use crate::discord::Presence; - use crate::game::{ - GameError, arguments, download_minecraft, download_version_info, get_loader_version, - libraries_missing, resolve_minecraft_version, -@@ -298,6 +299,10 @@ pub async fn launch_cluster( - stage(LaunchStage::Running); - state.games.set_pid(cluster_id, pid); - state.games.set_dir(cluster_id, cwd.clone()); -+ state.discord.set_presence(Presence::Playing { -+ cluster: cluster.name.clone(), -+ mc_version: cluster.mc_version.clone(), -+ }); - - let log_path = crate::logs::cluster_output_log(&cluster)?; - if let Some(parent) = log_path.parent() { -@@ -344,6 +349,10 @@ pub async fn launch_cluster( - state.games.remove(cluster_id); - state.services.notifier.game_stage(cluster_id, LaunchStage::Exited); - -+ if state.games.running_ids().is_empty() { -+ state.discord.set_presence(Presence::Idle); -+ } -+ - if played > Duration::from_secs(1) { - let _ = ClusterManager::add_playtime(&state, cluster_id, played).await; - } -diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs -index 1cc5b30..b90f3ba 100644 ---- a/packages/oneclient_core/src/game/shared_dir.rs -+++ b/packages/oneclient_core/src/game/shared_dir.rs -@@ -154,7 +154,7 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { - Ok(()) - } - --const EMPTY_NOTE_NAME: &str = "PLEASE_READ_ME.txt"; -+const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; - - async fn clear_content_files(dir: &Path) { - let Ok(mut entries) = polyio::read_dir(dir).await else { -diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs -index f01bb60..e10f5cb 100644 ---- a/packages/oneclient_core/src/lib.rs -+++ b/packages/oneclient_core/src/lib.rs -@@ -10,6 +10,7 @@ pub mod changelog; - pub mod clusters; - pub mod constants; - pub mod crypto; -+pub mod discord; - mod error; - pub mod game; - pub mod http; -@@ -25,10 +26,13 @@ pub mod os_ext; - pub mod packages; - pub mod patch; - pub mod paths; -+pub mod plus; - pub mod recovery; -+pub mod reporting; - pub mod screenshots; - pub mod settings; - pub mod status; -+pub mod tos; - mod state; - pub mod version; - pub mod versions; -@@ -42,6 +46,8 @@ pub use bundles::{ - BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, - }; - pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; -+pub use tos::{fetch_terms, TermsDocument}; -+pub use discord::{DiscordRpc, Presence}; - pub use clusters::{ - Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, - ensure_from_bundles, ensure_from_versions, estimate_cluster_download, -diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs -index 94adf4d..7f2a554 100644 ---- a/packages/oneclient_core/src/logger.rs -+++ b/packages/oneclient_core/src/logger.rs -@@ -22,11 +22,14 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { - - let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); - -+ let sentry_layer = sentry_tracing::layer(); -+ - #[cfg(debug_assertions)] - { - tracing_subscriber::registry() - .with(filter) - .with(stdout_layer) -+ .with(sentry_layer) - .init(); - } - -@@ -50,6 +53,7 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { - .with(filter) - .with(stdout_layer) - .with(file_layer) -+ .with(sentry_layer) - .init(); - - tracing::info!(path = %log_path.display(), "writing logs to file"); -diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs -index 1e3ae03..2b41edf 100644 ---- a/packages/oneclient_core/src/logs/manage.rs -+++ b/packages/oneclient_core/src/logs/manage.rs -@@ -87,13 +87,22 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> - - push_file( - cluster_output_log(cluster)?, -- "Launcher output".to_string(), -+ "Game output".to_string(), - LogKind::Game { - cluster_id: cluster.id, - }, - &mut out, - ); - -+ if let Ok(logs_dir) = paths::logs_dir() { -+ collect_dir( -+ &logs_dir, -+ LogKind::Launcher, -+ |name| name.ends_with(".log") || name.ends_with(".log.gz"), -+ &mut out, -+ ); -+ } -+ - out.sort_by_key(|info| std::cmp::Reverse(info.modified)); - Ok(out) - } -diff --git a/packages/oneclient_core/src/logs/mod.rs b/packages/oneclient_core/src/logs/mod.rs -index bd20e1d..135bf75 100644 ---- a/packages/oneclient_core/src/logs/mod.rs -+++ b/packages/oneclient_core/src/logs/mod.rs -@@ -15,6 +15,7 @@ pub use parse::parse_level; - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub enum LogKind { - Game { cluster_id: i64 }, -+ Launcher, - Minecraft, - CrashReport, - Other, -diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs -index 93970ec..9956153 100644 ---- a/packages/oneclient_core/src/migration/mod.rs -+++ b/packages/oneclient_core/src/migration/mod.rs -@@ -58,7 +58,7 @@ pub struct MigrationDetection { - /// Where an imported game directory should land in *this* launcher. - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - pub enum ImportTarget { -- /// Copy into the shared `/minecraft`. -+ /// Copy into the shared `/.minecraft`. - Shared, - /// Copy into a specific new cluster's own folder and mark it dedicated so - /// `Cluster::game_dir()` resolves there instead of the shared dir. -diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs -index 1291116..8e1ae8f 100644 ---- a/packages/oneclient_core/src/packages/store/mod.rs -+++ b/packages/oneclient_core/src/packages/store/mod.rs -@@ -346,16 +346,18 @@ fn ensure_compatible( - return Ok(()); - } - -- let cluster_loader = -- GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); -- -- if !version.loaders.is_empty() -- && !version -- .loaders -- .iter() -- .any(|l| cluster_loader.compatible_with(*l)) -- { -- return Err(PackageError::IncompatibleLoader.into()); -+ if project.content_type == ContentType::Mod { -+ let cluster_loader = -+ GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); -+ -+ if !version.loaders.is_empty() -+ && !version -+ .loaders -+ .iter() -+ .any(|l| cluster_loader.compatible_with(*l)) -+ { -+ return Err(PackageError::IncompatibleLoader.into()); -+ } - } - - if !version.game_versions.is_empty() -@@ -369,3 +371,105 @@ fn ensure_compatible( - - Ok(()) - } -+ -+#[cfg(test)] -+mod tests { -+ use super::*; -+ use crate::packages::types::PackageBody; -+ use chrono::Utc; -+ -+ fn project(content_type: ContentType) -> ProjectDetail { -+ ProjectDetail { -+ id: "p".into(), -+ slug: "p".into(), -+ provider: ProviderId::Modrinth, -+ content_type, -+ name: "p".into(), -+ summary: String::new(), -+ author: String::new(), -+ members: Vec::new(), -+ gallery: Vec::new(), -+ body: PackageBody::Raw(String::new()), -+ license: None, -+ links: Vec::new(), -+ version_ids: Vec::new(), -+ game_versions: Vec::new(), -+ loaders: Vec::new(), -+ icon_url: None, -+ created: Utc::now(), -+ updated: Utc::now(), -+ downloads: 0, -+ } -+ } -+ -+ fn version(loaders: Vec, game_versions: Vec<&str>) -> VersionDetail { -+ VersionDetail { -+ version_id: "v".into(), -+ project_id: "p".into(), -+ name: "v".into(), -+ version_number: "1".into(), -+ changelog: None, -+ game_versions: game_versions.into_iter().map(Into::into).collect(), -+ loaders, -+ published: Utc::now(), -+ downloads: 0, -+ files: Vec::new(), -+ } -+ } -+ -+ fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { -+ ClusterRow { -+ id: 1, -+ name: "c".into(), -+ folder_name: "c".into(), -+ setting_profile_name: None, -+ mc_version: mc_version.into(), -+ mc_loader: loader as i64, -+ stage: 0, -+ mc_loader_version: None, -+ created_at: None, -+ last_played: None, -+ overall_played: None, -+ linked_modpack_hash: None, -+ } -+ } -+ -+ #[test] -+ fn resource_pack_installs_into_modded_cluster() { -+ let result = ensure_compatible( -+ &project(ContentType::ResourcePack), -+ &version(vec![GameLoader::Vanilla], vec!["1.21.4"]), -+ &cluster(GameLoader::Fabric, "1.21.4"), -+ ); -+ -+ assert!(result.is_ok(), "resource packs are not bound to the loader"); -+ } -+ -+ #[test] -+ fn mod_still_rejects_incompatible_loader() { -+ let result = ensure_compatible( -+ &project(ContentType::Mod), -+ &version(vec![GameLoader::Forge], vec!["1.21.4"]), -+ &cluster(GameLoader::Fabric, "1.21.4"), -+ ); -+ -+ assert!(matches!( -+ result, -+ Err(LauncherError::PackageError(PackageError::IncompatibleLoader)) -+ )); -+ } -+ -+ #[test] -+ fn resource_pack_still_rejects_incompatible_mc_version() { -+ let result = ensure_compatible( -+ &project(ContentType::ResourcePack), -+ &version(vec![GameLoader::Vanilla], vec!["1.7.10"]), -+ &cluster(GameLoader::Fabric, "1.21.4"), -+ ); -+ -+ assert!(matches!( -+ result, -+ Err(LauncherError::PackageError(PackageError::IncompatibleMcVersion)) -+ )); -+ } -+} -diff --git a/packages/oneclient_core/src/plus.rs b/packages/oneclient_core/src/plus.rs -new file mode 100644 -index 0000000..7e3b442 ---- /dev/null -+++ b/packages/oneclient_core/src/plus.rs -@@ -0,0 +1,333 @@ -+use std::sync::atomic::{AtomicBool, Ordering}; -+use std::time::Duration; -+ -+use bytes::Bytes; -+use futures_util::{SinkExt, TryStreamExt}; -+use rand::Rng; -+use reqwest_websocket::{Message, Upgrade}; -+use uuid::Uuid; -+ -+use crate::LauncherResult; -+use crate::auth::MinecraftAccount; -+use crate::constants; -+ -+const MOJANG_JOIN_URL: &str = "https://sessionserver.mojang.com/session/minecraft/join"; -+ -+const PING_INTERVAL: Duration = Duration::from_secs(30); -+ -+const RECONNECT_DELAY: Duration = Duration::from_secs(15); -+const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); -+ -+static STARTED: AtomicBool = AtomicBool::new(false); -+ -+#[derive(Debug, thiserror::Error)] -+pub enum PlusError { -+ #[error("Mojang rejected the Poly+ session join (HTTP {status}): {body}")] -+ JoinRejected { status: u16, body: String }, -+ -+ #[error("Poly+ rejected the login for {username} (HTTP {status}): {body}")] -+ LoginRejected { -+ username: String, -+ status: u16, -+ body: String, -+ }, -+ -+ #[error(transparent)] -+ Request(#[from] reqwest::Error), -+ -+ #[error(transparent)] -+ Websocket(#[from] reqwest_websocket::Error), -+ -+ #[error(transparent)] -+ Url(#[from] url::ParseError), -+} -+ -+#[derive(serde::Deserialize)] -+struct LoginResponse { -+ token: String, -+} -+ -+enum Outcome { -+ Connected, -+ NoAccount, -+ Failed, -+} -+ -+enum Event { -+ Ping, -+ Message(Option), -+} -+ -+pub fn start() { -+ if STARTED.swap(true, Ordering::SeqCst) { -+ return; -+ } -+ -+ tokio::spawn(async move { -+ let client = match build_client() { -+ Ok(client) => client, -+ Err(err) => { -+ tracing::error!("[plus] could not build the playtime client: {err}"); -+ return; -+ } -+ }; -+ -+ let mut delay = RECONNECT_DELAY; -+ -+ loop { -+ delay = match session(&client).await { -+ Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY, -+ Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY), -+ }; -+ -+ tokio::time::sleep(delay).await; -+ } -+ }); -+} -+ -+fn build_client() -> Result { -+ reqwest::Client::builder() -+ .tcp_keepalive(Some(Duration::from_secs(15))) -+ .connect_timeout(Duration::from_secs(10)) -+ .tls_backend_rustls() -+ .user_agent(format!( -+ "OneClient {} ({})", -+ env!("CARGO_PKG_VERSION"), -+ env!("CARGO_PKG_HOMEPAGE") -+ )) -+ .build() -+} -+ -+fn base_url() -> &'static str { -+ constants::PLUS_BACKEND_URL.trim_end_matches('/') -+} -+ -+async fn session(client: &reqwest::Client) -> Outcome { -+ let account = match current_account().await { -+ Ok(Some(account)) => account, -+ Ok(None) => return Outcome::NoAccount, -+ Err(err) => { -+ tracing::warn!("[plus] no account to track playtime for: {err}"); -+ return Outcome::Failed; -+ } -+ }; -+ -+ let id = account.id; -+ let mut connected = false; -+ -+ match run_session(client, &account, &mut connected).await { -+ Ok(()) => tracing::info!("[plus] playtime session for {id} ended"), -+ Err(err) => tracing::warn!("[plus] playtime session for {id} failed: {err}"), -+ } -+ -+ if connected { -+ Outcome::Connected -+ } else { -+ Outcome::Failed -+ } -+} -+ -+async fn current_account() -> LauncherResult> { -+ let Some(account) = crate::auth::default_account_for_launch().await? else { -+ return Ok(None); -+ }; -+ -+ Ok(account.is_microsoft().then_some(account)) -+} -+ -+async fn run_session( -+ client: &reqwest::Client, -+ account: &MinecraftAccount, -+ connected: &mut bool, -+) -> Result<(), PlusError> { -+ let token = login(client, account).await?; -+ -+ let mut websocket = client -+ .get(format!("{}/websocket", base_url())) -+ .bearer_auth(&token) -+ .upgrade() -+ .send() -+ .await? -+ .into_websocket() -+ .await?; -+ -+ *connected = true; -+ tracing::info!("[plus] playtime websocket connected for {}", account.id); -+ -+ pump(&mut websocket, PING_INTERVAL, account.id).await -+} -+ -+async fn pump( -+ websocket: &mut reqwest_websocket::WebSocket, -+ ping_interval: Duration, -+ account_id: Uuid, -+) -> Result<(), PlusError> { -+ let mut ping = tokio::time::interval(ping_interval); -+ ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); -+ ping.tick().await; -+ -+ loop { -+ let event = tokio::select! { -+ _ = ping.tick() => Event::Ping, -+ message = websocket.try_next() => Event::Message(message?), -+ }; -+ -+ match event { -+ Event::Ping => { -+ if account_changed(account_id).await { -+ tracing::info!( -+ "[plus] default account changed, ending playtime session for {account_id}" -+ ); -+ break; -+ } -+ -+ websocket.send(Message::Ping(Bytes::new())).await?; -+ } -+ Event::Message(Some(Message::Close { code, reason })) => { -+ tracing::info!("[plus] playtime websocket closed by the server: {code:?} {reason}"); -+ break; -+ } -+ Event::Message(Some(_)) => {} -+ Event::Message(None) => break, -+ } -+ } -+ -+ Ok(()) -+} -+ -+async fn login( -+ client: &reqwest::Client, -+ account: &MinecraftAccount, -+) -> Result { -+ let server_id = generate_server_id(); -+ -+ let join = client -+ .post(MOJANG_JOIN_URL) -+ .header("Accept", "application/json") -+ .json(&serde_json::json!({ -+ "accessToken": account.access_token, -+ "selectedProfile": account.id.simple().to_string(), -+ "serverId": server_id, -+ })) -+ .send() -+ .await?; -+ -+ if !join.status().is_success() { -+ return Err(PlusError::JoinRejected { -+ status: join.status().as_u16(), -+ body: join.text().await.unwrap_or_default(), -+ }); -+ } -+ -+ let mut url = reqwest::Url::parse(&format!("{}/account/login", base_url()))?; -+ url.query_pairs_mut() -+ .append_pair("username", &account.username) -+ .append_pair("server_id", &server_id); -+ -+ let login = client -+ .post(url) -+ .header("Accept", "application/json") -+ .send() -+ .await?; -+ -+ if !login.status().is_success() { -+ return Err(PlusError::LoginRejected { -+ username: account.username.clone(), -+ status: login.status().as_u16(), -+ body: login.text().await.unwrap_or_default(), -+ }); -+ } -+ -+ Ok(login.json::().await?.token) -+} -+ -+async fn account_changed(id: Uuid) -> bool { -+ match crate::auth::get_default_account().await { -+ Ok(Some(account)) => account.id != id, -+ Ok(None) => true, -+ Err(_) => false, -+ } -+} -+ -+fn generate_server_id() -> String { -+ let mut bytes = [0u8; 20]; -+ rand::rng().fill_bytes(&mut bytes); -+ crate::crypto::to_hex(&bytes) -+} -+ -+#[cfg(test)] -+mod tests { -+ use std::sync::Arc; -+ use std::sync::atomic::AtomicUsize; -+ -+ use async_tungstenite::tokio::accept_async; -+ use async_tungstenite::tungstenite::Message as ServerMessage; -+ use futures_util::StreamExt; -+ use tokio::net::TcpListener; -+ -+ use super::*; -+ -+ async fn ping_counting_server(close_after_pings: usize) -> (String, Arc) { -+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); -+ let port = listener.local_addr().expect("addr").port(); -+ let pings = Arc::new(AtomicUsize::new(0)); -+ -+ tokio::spawn({ -+ let pings = Arc::clone(&pings); -+ async move { -+ let (stream, _) = listener.accept().await.expect("accept"); -+ let mut server = accept_async(stream).await.expect("handshake"); -+ -+ while let Some(Ok(message)) = server.next().await { -+ if let ServerMessage::Ping(_) = message -+ && pings.fetch_add(1, Ordering::SeqCst) + 1 >= close_after_pings -+ { -+ let _ = server.close(None).await; -+ break; -+ } -+ } -+ } -+ }); -+ -+ (format!("ws://127.0.0.1:{port}/websocket"), pings) -+ } -+ -+ async fn connect(url: &str) -> reqwest_websocket::WebSocket { -+ build_client() -+ .expect("client") -+ .get(url) -+ .upgrade() -+ .send() -+ .await -+ .expect("upgrade") -+ .into_websocket() -+ .await -+ .expect("websocket") -+ } -+ -+ #[tokio::test] -+ async fn pings_on_every_interval_and_stops_when_the_server_closes() { -+ assert!(!account_changed(Uuid::nil()).await); -+ -+ let (url, pings) = ping_counting_server(3).await; -+ let mut websocket = connect(&url).await; -+ -+ let pumped = tokio::time::timeout( -+ Duration::from_secs(10), -+ pump(&mut websocket, Duration::from_millis(50), Uuid::nil()), -+ ) -+ .await -+ .expect("pump should return once the server closes the socket"); -+ -+ pumped.expect("pump should treat a server close as a clean end"); -+ assert_eq!(pings.load(Ordering::SeqCst), 3); -+ } -+ -+ #[test] -+ fn server_ids_are_random_20_byte_hex() { -+ let id = generate_server_id(); -+ assert_eq!(id.len(), 40); -+ assert!(id.chars().all(|c| c.is_ascii_hexdigit())); -+ assert_ne!(id, generate_server_id()); -+ } -+} -diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs -new file mode 100644 -index 0000000..45b3c88 ---- /dev/null -+++ b/packages/oneclient_core/src/reporting.rs -@@ -0,0 +1,76 @@ -+use std::panic; -+use std::time::Duration; -+ -+use sentry::{ClientInitGuard, ClientOptions}; -+ -+use crate::constants::SENTRY_DSN; -+ -+const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); -+ -+const ENVIRONMENT: &str = if cfg!(debug_assertions) { -+ "development" -+} else { -+ "production" -+}; -+ -+pub fn init(enabled: bool) -> Option { -+ if !enabled { -+ tracing::debug!("crash reporting disabled by settings"); -+ return None; -+ } -+ -+ if cfg!(debug_assertions) && option_env!("ONECLIENT_SENTRY_DSN").is_none() { -+ tracing::debug!("crash reporting skipped: debug build without an explicit DSN"); -+ return None; -+ } -+ -+ let guard = sentry::init(( -+ SENTRY_DSN, -+ ClientOptions { -+ release: Some(format!("oneclient@{}", env!("CARGO_PKG_VERSION")).into()), -+ environment: Some(ENVIRONMENT.into()), -+ attach_stacktrace: true, -+ send_default_pii: false, -+ ..Default::default() -+ }, -+ )); -+ -+ if !guard.is_enabled() { -+ tracing::warn!("sentry client failed to start; crash reports will not be sent"); -+ return None; -+ } -+ -+ install_panic_flush(); -+ -+ tracing::info!(environment = ENVIRONMENT, "crash reporting enabled"); -+ Some(guard) -+} -+ -+fn install_panic_flush() { -+ let capture = panic::take_hook(); -+ -+ panic::set_hook(Box::new(move |info| { -+ capture(info); -+ -+ if let Some(client) = sentry::Hub::current().client() { -+ client.flush(Some(PANIC_FLUSH_TIMEOUT)); -+ } -+ })); -+} -+ -+#[cfg(test)] -+mod tests { -+ use super::*; -+ -+ #[test] -+ fn bundled_dsn_parses() { -+ SENTRY_DSN -+ .parse::() -+ .expect("bundled sentry DSN should be valid"); -+ } -+ -+ #[test] -+ fn opting_out_skips_the_client() { -+ assert!(init(false).is_none()); -+ } -+} -diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs -index 9991984..9de8c72 100644 ---- a/packages/oneclient_core/src/settings/launcher.rs -+++ b/packages/oneclient_core/src/settings/launcher.rs -@@ -26,6 +26,7 @@ pub struct LauncherSettings { - pub settings_version: u32, - pub log_debug: bool, - pub auto_update: bool, -+ pub crash_reporting: bool, - pub enable_gamemode: bool, - pub discord_enabled: bool, - pub max_concurrent_requests: usize, -@@ -34,6 +35,8 @@ pub struct LauncherSettings { - pub dynamic_background_enabled: bool, - pub view_states: BTreeMap, - pub seen_onboarding: bool, -+ pub accepted_tos_version: u32, -+ pub accepted_privacy_version: u32, - pub seen_versions: Vec, - pub modrinth_api_key: Option, - pub curseforge_api_key: Option, -@@ -57,6 +60,7 @@ impl Default for LauncherSettings { - settings_version: 1, - log_debug: false, - auto_update: true, -+ crash_reporting: true, - discord_enabled: true, - enable_gamemode: false, - max_concurrent_requests: 25, -@@ -65,6 +69,8 @@ impl Default for LauncherSettings { - dynamic_background_enabled: true, - view_states: BTreeMap::new(), - seen_onboarding: false, -+ accepted_tos_version: 0, -+ accepted_privacy_version: 0, - seen_versions: Vec::new(), - modrinth_api_key: None, - curseforge_api_key: None, -diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs -index 80a187e..2fc0af2 100644 ---- a/packages/oneclient_core/src/state.rs -+++ b/packages/oneclient_core/src/state.rs -@@ -6,6 +6,7 @@ use tokio::sync::{Mutex, OnceCell}; - - use crate::auth::{CredentialsStore, PendingBrowserLogin}; - use crate::bundles::BundlesManager; -+use crate::discord::DiscordRpc; - use crate::http::RequestClient; - use crate::images::ImageCacheStore; - use crate::java::JavaManager; -@@ -38,6 +39,7 @@ pub struct LauncherState { - pub versions: Arc, - pub images: ImageCacheStore, - pub games: crate::game::GameProcessManager, -+ pub discord: DiscordRpc, - } - - impl LauncherState { -@@ -59,6 +61,7 @@ impl LauncherState { - let settings = store::load_settings(Some(&services.notifier)).await; - let auth = CredentialsStore::load().await?; - let java = JavaManager; -+ let discord = DiscordRpc::spawn(settings.discord_enabled); - - let state = Arc::new(Self { - services, -@@ -71,6 +74,7 @@ impl LauncherState { - versions: Arc::new(VersionsManager::new()), - images: ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), -+ discord, - }); - - STATE -diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs -new file mode 100644 -index 0000000..c30bb5e ---- /dev/null -+++ b/packages/oneclient_core/src/tos.rs -@@ -0,0 +1,197 @@ -+use std::path::Path; -+ -+use reqwest::header; -+use reqwest::{Method, StatusCode}; -+use serde::{Deserialize, Serialize}; -+ -+use crate::api_config::meta_url_base; -+use crate::constants; -+use crate::paths; -+use crate::state::LauncherServices; -+use crate::{LauncherError, LauncherResult}; -+ -+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -+pub struct TermsDocument { -+ pub version: u32, -+ #[serde(default)] -+ pub privacy_version: Option, -+ #[serde(default)] -+ pub updated_at: Option, -+ pub terms: String, -+ #[serde(default)] -+ pub privacy: Option, -+ #[serde(default)] -+ pub terms_url: Option, -+ #[serde(default)] -+ pub privacy_url: Option, -+} -+ -+impl TermsDocument { -+ pub fn terms_url(&self) -> &str { -+ self.terms_url -+ .as_deref() -+ .filter(|url| !url.trim().is_empty()) -+ .unwrap_or(constants::TOS_URL) -+ } -+ -+ pub fn privacy_url(&self) -> &str { -+ self.privacy_url -+ .as_deref() -+ .filter(|url| !url.trim().is_empty()) -+ .unwrap_or(constants::PRIVACY_URL) -+ } -+ -+ pub fn privacy_version(&self) -> u32 { -+ self.privacy_version.unwrap_or(self.version) -+ } -+ -+ pub fn privacy_body(&self) -> Option<&str> { -+ self.privacy -+ .as_deref() -+ .filter(|body| !body.trim().is_empty()) -+ } -+} -+ -+#[tracing::instrument(level = "debug", skip(services))] -+pub async fn fetch_terms(services: &LauncherServices) -> LauncherResult { -+ let url = format!("{}/oneclient/tos.json", meta_url_base()); -+ let cache_path = paths::caches_dir()?.join("TERMS.json"); -+ let etag_path = paths::caches_dir()?.join("TERMS.json.etag"); -+ -+ let stored_etag = read_sidecar_etag(&etag_path).await; -+ let url_parsed = url.parse().map_err(LauncherError::UrlError)?; -+ -+ let mut request = reqwest::Request::new(Method::GET, url_parsed); -+ if let Some(etag) = &stored_etag { -+ insert_if_none_match(&mut request, etag); -+ } -+ -+ match services.requester.send(request).await { -+ Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { -+ tracing::debug!("terms cache hit (304)"); -+ read_cached_terms(&cache_path).await -+ } -+ Ok(res) if res.status().is_success() => { -+ let server_etag = etag_from_response(&res); -+ let bytes = res -+ .bytes() -+ .await -+ .map_err(|err| LauncherError::InvalidSettingsProfile { -+ reason: err.to_string(), -+ })?; -+ -+ let document: TermsDocument = serde_json::from_slice(&bytes)?; -+ -+ if let Some(parent) = cache_path.parent() { -+ polyio::create_dir_all(parent).await?; -+ } -+ polyio::write(&cache_path, &bytes).await?; -+ if let Some(etag) = server_etag { -+ let _ = polyio::write(&etag_path, etag.as_bytes()).await; -+ } -+ -+ Ok(document) -+ } -+ Err(err) if cache_path.exists() => { -+ tracing::debug!("falling back to cached terms after remote fetch failed: {err}"); -+ read_cached_terms(&cache_path).await -+ } -+ Err(err) => { -+ tracing::error!("failed to fetch terms from remote: {err}"); -+ Err(LauncherError::InvalidSettingsProfile { -+ reason: err.to_string(), -+ }) -+ } -+ Ok(res) => { -+ tracing::warn!( -+ status = %res.status(), -+ "unexpected terms response; using cache when available" -+ ); -+ if cache_path.exists() { -+ read_cached_terms(&cache_path).await -+ } else { -+ Err(LauncherError::InvalidSettingsProfile { -+ reason: format!("terms request failed with HTTP {}", res.status()), -+ }) -+ } -+ } -+ } -+} -+ -+async fn read_cached_terms(path: &Path) -> LauncherResult { -+ let bytes = polyio::read(path).await?; -+ Ok(serde_json::from_slice(&bytes)?) -+} -+ -+fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { -+ if let Ok(value) = header::HeaderValue::from_str(etag) { -+ request.headers_mut().insert(header::IF_NONE_MATCH, value); -+ } -+} -+ -+fn etag_from_response(res: &reqwest::Response) -> Option { -+ res.headers() -+ .get(header::ETAG) -+ .and_then(|v| v.to_str().ok()) -+ .map(str::to_string) -+} -+ -+async fn read_sidecar_etag(path: &Path) -> Option { -+ polyio::read(path) -+ .await -+ .ok() -+ .and_then(|bytes| String::from_utf8(bytes).ok()) -+ .filter(|etag| !etag.is_empty()) -+} -+ -+#[cfg(test)] -+mod tests { -+ use super::*; -+ -+ #[test] -+ fn parses_minimal_document() { -+ let raw = br###"{"version":2,"terms":"## Terms\n\nBe nice."}"###; -+ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); -+ -+ assert_eq!(document.version, 2); -+ assert_eq!(document.terms, "## Terms\n\nBe nice."); -+ assert_eq!(document.terms_url(), constants::TOS_URL); -+ assert_eq!(document.privacy_url(), constants::PRIVACY_URL); -+ } -+ -+ #[test] -+ fn privacy_version_falls_back_to_terms_version() { -+ let raw = br#"{"version":4,"terms":"x"}"#; -+ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); -+ -+ assert_eq!(document.privacy_version(), 4); -+ assert_eq!(document.privacy_body(), None); -+ } -+ -+ #[test] -+ fn privacy_version_is_independent_when_published() { -+ let raw = br###"{"version":4,"privacy_version":2,"terms":"x","privacy":"## Privacy"}"###; -+ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); -+ -+ assert_eq!(document.version, 4); -+ assert_eq!(document.privacy_version(), 2); -+ assert_eq!(document.privacy_body(), Some("## Privacy")); -+ } -+ -+ #[test] -+ fn blank_privacy_body_is_treated_as_absent() { -+ let raw = br#"{"version":1,"terms":"x","privacy":" "}"#; -+ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); -+ -+ assert_eq!(document.privacy_body(), None); -+ } -+ -+ #[test] -+ fn document_urls_override_constants() { -+ let raw = br#"{"version":1,"terms":"x","terms_url":"https://example.com/tos","privacy_url":" "}"#; -+ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); -+ -+ assert_eq!(document.terms_url(), "https://example.com/tos"); -+ assert_eq!(document.privacy_url(), constants::PRIVACY_URL); -+ } -+}