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); + } +}