diff --git a/Cargo.lock b/Cargo.lock index 50ff0fce..d6c4b51d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4706,6 +4706,7 @@ dependencies = [ "async-trait", "async_zip", "futures-lite", + "futures-util", "oneclient_common", "oneclient_events", "oneclient_net", @@ -5073,6 +5074,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 129d8d21..ab35b5b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ trash = {version = "=5.2.6"} percent-encoding = {version = "=2.3.2"} url = {version = "=2.5.8", features = ["serde"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]} +windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]} # util (codegen/macro) anyhow = {version = "=1.0.102"} diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs index e1468bf0..96b22e2b 100644 --- a/packages/oneclient_app/src/components/generic_prompt.rs +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -7,6 +7,7 @@ use oneclient_java::JAVA_CHOICE_DOWNLOAD; use crate::components::{Button, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::microsoft_java::MICROSOFT_JAVA_CHOICE_INSTALL; use crate::notifications::PendingPromptView; use crate::theme::colors; use crate::ui::border_all_color; @@ -15,7 +16,9 @@ use crate::updater::UPDATE_CHOICE_INSTALL; const CARD_BG: Color = Color::from_rgb(26, 34, 41); fn claimed_elsewhere(prompt: &PendingPromptView) -> bool { - prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL) + prompt.has_choice(JAVA_CHOICE_DOWNLOAD) + || prompt.has_choice(UPDATE_CHOICE_INSTALL) + || prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL) } #[derive(PartialEq)] @@ -149,15 +152,17 @@ mod tests { } #[test] - fn the_two_rich_overlays_keep_their_own_prompts() { - assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + fn the_rich_overlays_keep_their_own_prompts() { + for id in [ JAVA_CHOICE_DOWNLOAD, - "Download", - )]))); - assert!(claimed_elsewhere(&prompt(vec![Choice::primary( UPDATE_CHOICE_INSTALL, - "Download", - )]))); + MICROSOFT_JAVA_CHOICE_INSTALL, + ] { + assert!( + claimed_elsewhere(&prompt(vec![Choice::primary(id, "Download")])), + "{id} has its own overlay" + ); + } } #[test] diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs index 16e5987e..0f28d586 100644 --- a/packages/oneclient_app/src/components/java_install_manager.rs +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -8,6 +8,7 @@ use crate::ui::border_all_color; fn providers() -> Vec<(JavaVendor, &'static str)> { vec![ + (JavaVendor::Microsoft, "Microsoft Build of OpenJDK"), (JavaVendor::Zulu, "Azul Zulu"), (JavaVendor::Adoptium, "Eclipse Temurin"), (JavaVendor::Corretto, "Amazon Corretto"), diff --git a/packages/oneclient_app/src/components/microsoft_java_prompt.rs b/packages/oneclient_app/src/components/microsoft_java_prompt.rs new file mode 100644 index 00000000..10e6513d --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_java_prompt.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use freya::prelude::*; +use oneclient_events::Answer; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::microsoft_java::{MICROSOFT_JAVA_CHOICE_INSTALL, MICROSOFT_JAVA_CHOICE_NEVER}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +const HOLD_SECONDS: u8 = 3; + +#[derive(PartialEq)] +pub struct MicrosoftJavaPromptOverlay; + +impl Component for MicrosoftJavaPromptOverlay { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let remaining = use_state(|| HOLD_SECONDS); + + let showing = snapshot + .pending_prompt + .as_ref() + .is_some_and(|prompt| prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL)); + + use_side_effect_with_deps(&showing, move |&showing| { + let mut remaining = remaining; + + if !showing { + remaining.set(HOLD_SECONDS); + return; + } + + spawn(async move { + let mut remaining = remaining; + for step in (0..HOLD_SECONDS).rev() { + tokio::time::sleep(Duration::from_secs(1)).await; + + // Re-armed while this was sleeping so a newer prompt owns the + // hold now and this countdown is stale + if { *remaining.peek() } != step + 1 { + return; + } + + remaining.set(step); + } + }); + }); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + if !prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL) { + return rect().into_element(); + } + + let held = *remaining.read(); + let locked = held > 0; + + let dismiss_label = prompt + .dismiss + .clone() + .unwrap_or_else(|| "Cancel".to_string()); + let never_label = prompt + .choice(MICROSOFT_JAVA_CHOICE_NEVER) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Don't ask again".to_string()); + let install_label = prompt + .choice(MICROSOFT_JAVA_CHOICE_INSTALL) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Proceed".to_string()); + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let never = dispatch.clone(); + let accept = dispatch.clone(); + + OverlayPopup::new() + // Closing from the backdrop is a dismissal too so it waits as well + .on_close(move |_| { + if !locked { + close.dismiss_prompt(); + } + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::DownloadCloud02) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(if locked { + held.to_string() + } else { + String::new() + }) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .child( + Button::new() + .secondary() + .disabled(locked) + .on_press(move |_| cancel.dismiss_prompt()) + .text(dismiss_label), + ) + .child( + Button::new() + .secondary() + .disabled(locked) + .on_press(move |_| { + never.answer_prompt(Answer::new( + MICROSOFT_JAVA_CHOICE_NEVER, + )) + }) + .text(never_label), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + accept.answer_prompt(Answer::new( + MICROSOFT_JAVA_CHOICE_INSTALL, + )) + }) + .child( + Icon::new(IconType::DownloadCloud02) + .size(14.), + ) + .text(install_label), + ), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 75dd453b..e3bf43ad 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -16,6 +16,7 @@ mod icons; mod java_install_manager; mod generic_prompt; mod java_prompt; +mod microsoft_java_prompt; mod link_confirm; mod local_image; mod log_viewer; @@ -62,6 +63,7 @@ pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; +pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use markdown::{Markdown, MarkdownStyle}; diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs index 4f22759e..f4f5d5ba 100644 --- a/packages/oneclient_app/src/components/overlay_popup.rs +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -8,6 +8,7 @@ use freya::{ use crate::hooks::use_overlay_claim; const SCRIM_ALPHA: f32 = 90.; +const SCRIM_MIN_ALPHA: u8 = 1; /// Overlay level of a top level popup. Its scrim sits two levels below it. pub const OVERLAY_BASE_LEVEL: u8 = 12; @@ -116,7 +117,7 @@ impl Component for OverlayPopup { }); let scrim_alpha = if self.backdrop { SCRIM_ALPHA } else { 0. }; - let alpha = (fade.read().value() * scrim_alpha) as u8; + let alpha = ((fade.read().value() * scrim_alpha) as u8).max(SCRIM_MIN_ALPHA); rect() .layer(Layer::Overlay) @@ -130,7 +131,7 @@ impl Component for OverlayPopup { .height(Size::window_percent(100.)) .layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2))) .background(Color::from_argb(alpha, 0, 0, 0)) - .on_press(move |_| { + .on_all_press(move |_| { if let Some(on_close) = scrim_close.as_ref() { on_close.call(()); } diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 43dbddde..56bd0328 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use crate::hooks::PumpSignal; use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; -use crate::state::{AppChannel, AppState, LoginProgress}; +use crate::state::{AppChannel, AppState, LoginProgress, StorageScanProgress}; /// Quiet period before log lines are written without it every line wakes the log view const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); @@ -127,6 +127,7 @@ impl EventPump { let mut logs: Vec<(i64, String)> = Vec::new(); let mut failed: Option<(i64, String)> = None; let mut login: Option> = None; + let mut storage_scan: Option> = None; let mut sync_complete = false; for event in batch { @@ -148,7 +149,6 @@ impl EventPump { cluster_id, message, }) => failed = Some((cluster_id, message)), - // Lifted out so it never reaches the engine the sign-in modal renders it inline Event::Progress(ProgressEvent::Update { id, ref label, @@ -161,6 +161,18 @@ impl EventPump { total, })); } + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => { + storage_scan = Some((current < total).then(|| StorageScanProgress { + label: label.clone(), + current, + total, + })); + } other => engine_events.push(other), } } @@ -197,6 +209,12 @@ impl EventPump { .microsoft_login = progress; } + if let Some(progress) = storage_scan { + self.station + .write_channel(AppChannel::StorageScan) + .storage_scan = progress; + } + if !engine_events.is_empty() { folded.touched_engine = true; let mut guard = self.station.write_channel(AppChannel::Notifications); diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 73855c58..7b2b9ace 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -10,8 +10,9 @@ use std::time::{Duration, Instant}; use freya::prelude::spawn_forever; use freya::radio::RadioStation; +use oneclient_cluster::profiles::list_named_profiles; use oneclient_cluster::{ - ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, + Cluster, ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, }; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_content::packages::{LiveSync, LocalImportReport}; @@ -87,6 +88,26 @@ fn plan_launch_updates( } } +/// Names the clusters that pinned `java_path` to this runtime by hand +fn clusters_pinned_to_java( + profiles: &[GameSettingsProfile], + clusters: &[Cluster], + absolute_path: &str, +) -> Vec { + profiles + .iter() + .filter(|profile| profile.java_path.as_deref() == Some(absolute_path)) + .map(|profile| { + clusters + .iter() + .find(|cluster| { + cluster.setting_profile_name.as_deref() == Some(profile.name.as_str()) + }) + .map_or_else(|| profile.name.clone(), |cluster| cluster.name.clone()) + }) + .collect() +} + /// The pump alone owns the toast timers so adding or removing a toast has to /// tell it to re-arm hover-pause is a timer property not component state #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -275,6 +296,13 @@ impl Actions { } } + pub fn skip_microsoft_java(&self) { + if let Some(updated) = self.mutate_settings(|settings| settings.skip_microsoft_java = true) + { + self.persist(updated); + } + } + pub fn reset_onboarding(&self) { if let Some(updated) = self.mutate_settings(|settings| { settings.seen_onboarding = false; @@ -560,6 +588,29 @@ impl Actions { let path = path.into(); spawn_forever(async move { let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + // Checks if the jdk is pinned to the cluster + match ( + list_named_profiles(&state.services.db).await, + state.clusters.list().await, + ) { + (Ok(profiles), Ok(clusters)) => { + let pinned = clusters_pinned_to_java(&profiles, &clusters, &path); + if !pinned.is_empty() { + events + .notify("Cannot remove this JDK") + .body(format!("It is used by cluster: {}", pinned.join(", "))) + .error() + .send(); + return; + } + } + (Err(err), _) | (_, Err(err)) => { + tracing::error!("could not check whether the java runtime is in use: {err:#}"); + } + } + match state.java.remove_runtime(&path).await { Ok(()) => super::invalidate_java_queries().await, Err(err) => tracing::error!("failed to remove java runtime: {err:#}"), @@ -1748,6 +1799,8 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { return; }; + crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await; + // Before the game process never after Minecraft reads its mods once at // startup actions @@ -1990,4 +2043,88 @@ mod tests { ); } } + + const JDK_25: &str = r"C:\jdk-25\bin\javaw.exe"; + + fn profile(name: &str, java_path: Option<&str>) -> GameSettingsProfile { + GameSettingsProfile { + name: name.into(), + java_path: java_path.map(Into::into), + ..GameSettingsProfile::default_global_profile() + } + } + + fn cluster(id: i64, name: &str, profile_name: Option<&str>) -> Cluster { + Cluster { + id, + name: name.into(), + folder_name: name.into(), + setting_profile_name: profile_name.map(Into::into), + mc_version: "26.2".into(), + mc_loader: oneclient_common::domain::GameLoader::default(), + mc_loader_version: None, + stage: ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: Duration::ZERO, + linked_modpack_hash: None, + } + } + + #[test] + fn a_runtime_nobody_pinned_is_free_to_go() { + let profiles = vec![profile("26.2 Fabric", None)]; + let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))]; + + assert!(clusters_pinned_to_java(&profiles, &clusters, JDK_25).is_empty()); + } + + #[test] + fn a_pinned_runtime_is_reported_under_its_cluster_name() { + let profiles = vec![profile("26.2 Fabric", Some(JDK_25))]; + let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["26.2 Fabric".to_string()], + ); + } + + #[test] + fn only_the_runtime_being_removed_counts() { + let profiles = vec![ + profile("21 pinned", Some(r"C:\jdk-21\bin\javaw.exe")), + profile("25 pinned", Some(JDK_25)), + ]; + let clusters = vec![ + cluster(1, "Old pack", Some("21 pinned")), + cluster(2, "New pack", Some("25 pinned")), + ]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["New pack".to_string()], + ); + } + + #[test] + fn every_cluster_holding_the_runtime_is_named() { + let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))]; + let clusters = vec![cluster(1, "Alpha", Some("a")), cluster(2, "Beta", Some("b"))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &clusters, JDK_25), + vec!["Alpha".to_string(), "Beta".to_string()], + ); + } + + #[test] + fn a_profile_no_cluster_claims_falls_back_to_its_own_name() { + let profiles = vec![profile("orphaned", Some(JDK_25))]; + + assert_eq!( + clusters_pinned_to_java(&profiles, &[], JDK_25), + vec!["orphaned".to_string()], + ); + } } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 2a119519..1d2ef3dd 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -69,7 +69,7 @@ pub use queries::{ use crate::notifications::NotificationSnapshot; use crate::state::{ AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, - SettingsState, + SettingsState, StorageScanProgress, }; use freya::prelude::*; use freya::radio::use_radio; @@ -123,6 +123,13 @@ pub fn use_installs_snapshot() -> InstallState { use_radio(AppChannel::Installs).read().installs.clone() } +pub fn use_storage_scan_progress() -> Option { + use_radio(AppChannel::StorageScan) + .read() + .storage_scan + .clone() +} + pub fn use_pending_launch() -> Option { use_radio(AppChannel::PendingLaunch) .read() diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 92bc474c..b185a70a 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,7 +2,7 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay, NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay, }; @@ -76,6 +76,7 @@ impl Component for RootLayout { .child(Toasts) .child(UpdatePromptOverlay) .child(JavaPromptOverlay) + .child(MicrosoftJavaPromptOverlay) // Must stay last it renders whatever the overlays above did not claim .child(GenericPromptOverlay) .child(ClusterUpdatePopup) diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 67413770..7db65b8e 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -15,6 +15,7 @@ mod launcher; pub mod state; mod transfer; mod layout; +pub mod microsoft_java; mod motion; mod notifications; pub mod platform; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index ce21464e..47c3d41b 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -11,7 +11,7 @@ use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants, events, platform, router, theme, use_provide_actions, use_provide_link_confirm, - use_provide_start_maximized, + use_provide_start_maximized, microsoft_java, }; use std::cell::Cell; use tokio::runtime::Builder; @@ -60,7 +60,10 @@ impl App for OneClientApp { match events::start_launcher(station, events_bus).await { // Must follow startup `sync_bundles` needs the launcher handle and firing // it early leaves `syncing_bundles` stuck disabling every launch button - Ok(()) => startup.sync_bundles(), + Ok(()) => { + startup.sync_bundles(); + microsoft_java::spawn_auto_install(); + } Err(err) => { events::report_startup_failure(&station, &err); oneclient_app::updater::spawn_update_check(false, rescue_bus); diff --git a/packages/oneclient_app/src/microsoft_java.rs b/packages/oneclient_app/src/microsoft_java.rs new file mode 100644 index 00000000..177ed29e --- /dev/null +++ b/packages/oneclient_app/src/microsoft_java.rs @@ -0,0 +1,357 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use freya::prelude::spawn_forever; +use tokio::sync::mpsc::UnboundedSender; +use oneclient_common::Patch; +use oneclient_core::settings::store::save_settings_and_apply; +use oneclient_core::{LauncherState, ProfileUpdate}; +use oneclient_db::models::ClusterId; +use oneclient_events::{Choice, Prompt, Signal}; +use oneclient_java::{JavaRuntime, JavaVendor}; + +use crate::hooks::Actions; +use crate::launcher; + +/// Front-ends match on these +pub const MICROSOFT_JAVA_CHOICE_INSTALL: &str = "java.microsoft.install"; +pub const MICROSOFT_JAVA_CHOICE_NEVER: &str = "java.microsoft.never"; + +/// Cancel works per session, "Don't ask again" is set in settings +static ASKED: Mutex> = Mutex::new(BTreeSet::new()); + +enum MicrosoftJavaAnswer { + Install, + Never, +} + +pub fn spawn_auto_install() { + tokio::spawn(auto_install(ui_refresh_channel())); +} + +fn ui_refresh_channel() -> UnboundedSender<()> { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + spawn_forever(async move { + while rx.recv().await.is_some() { + crate::hooks::invalidate_profile_queries().await; + } + }); + tx +} + +async fn auto_install(refresh: UnboundedSender<()>) { + let Ok(state) = launcher::state() else { + return; + }; + + if opted_out(&state) || already_migrated(&state) { + return; + } + + let Some(cluster_id) = automatic_cluster(&state).await else { + return; + }; + + let major = match oneclient_core::required_java_major(&state, cluster_id).await { + Ok(Some(major)) => major, + Ok(None) => { + tracing::info!(cluster_id, "the cluster's manifest names no Java version"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + return; + } + }; + + match state + .java + .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) + .await + { + Ok(true) => return, + Ok(false) => {} + Err(err) => { + tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); + return; + } + } + + if !publishes(&state, major).await { + return; + } + + tracing::info!( + cluster_id, + major, + "fetching a Microsoft runtime for the cluster on Automatic" + ); + install_and_unpin(cluster_id, major, refresh); +} + +pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { + return; + }; + + if opted_out(&state) { + return; + } + + let Some(pinned) = pinned_runtime(&state, cluster_id).await else { + return; + }; + + if pinned.vendor == JavaVendor::Microsoft { + return; + } + + let major = match oneclient_core::required_java_major(&state, cluster_id).await { + Ok(Some(major)) => major, + Ok(None) => { + tracing::info!(cluster_id, "the cluster's manifest names no Java version"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + return; + } + }; + + let installed = match state + .java + .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) + .await + { + Ok(installed) => installed, + Err(err) => { + tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); + false + } + }; + + if !installed && !publishes(&state, major).await { + return; + } + + if !claim_ask(cluster_id) { + return; + } + + match actions.events().ask(offer_prompt(installed)).await { + Ok(Some(chosen)) => match chosen.value { + MicrosoftJavaAnswer::Install => { + if installed { + unpin_to_automatic(&state, cluster_id, major, true, ui_refresh_channel()).await; + } else { + install_and_unpin(cluster_id, major, ui_refresh_channel()); + } + } + MicrosoftJavaAnswer::Never => actions.skip_microsoft_java(), + }, + Ok(None) => tracing::info!(cluster_id, "Microsoft Java offer dismissed"), + Err(err) => tracing::warn!(cluster_id, "Microsoft Java offer failed: {err:#}"), + } +} + +fn offer_prompt(installed: bool) -> Prompt { + let closing = if installed { + "You already have it installed, so this cluster switches over right away." + } else { + "Minecraft will launch with Microsoft OpenJDK next time." + }; + + Prompt::new( + "Microsoft Java runtime", + format!( + "Based on our research, we now recommend Microsoft's OpenJDK \ + as they have specific optimizations for Minecraft. \ + Would you like to use Microsoft OpenJDK as your default Java installation? \ + {closing}" + ), + ) + .option( + Choice::new(MICROSOFT_JAVA_CHOICE_NEVER, "Don't ask again"), + MicrosoftJavaAnswer::Never, + ) + .option( + Choice::primary(MICROSOFT_JAVA_CHOICE_INSTALL, "Proceed"), + MicrosoftJavaAnswer::Install, + ) + .dismiss("Cancel") +} + +fn install_and_unpin(cluster_id: ClusterId, major: u32, refresh: UnboundedSender<()>) { + tokio::spawn(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + let runtime = match state + .java + .install_runtime_from(&JavaVendor::Microsoft, major) + .await + { + Ok(runtime) => runtime, + Err(err) => { + events + .notify("Java install failed") + .body(err.to_string()) + .error() + .send(); + return; + } + }; + + events.signal(Signal::JavaChanged); + tracing::info!(cluster_id, version = %runtime.version, "installed a Microsoft runtime"); + + unpin_to_automatic(&state, cluster_id, major, false, refresh).await; + }); +} + +async fn unpin_to_automatic( + state: &Arc, + cluster_id: ClusterId, + major: u32, + immediate: bool, + refresh: UnboundedSender<()>, +) { + let events = state.services.events.clone(); + + mark_migrated(state).await; + + // Cleared rather than pointed at the new runtime Automatic ranks the + // default vendor first so it lands on this one anyway and the cluster + // keeps following later Microsoft installs instead of freezing on one + let update = ProfileUpdate { + java_path: Patch::Clear, + ..Default::default() + }; + + match state.clusters.update_profile(cluster_id, update).await { + Ok(_) => { + let _ = refresh.send(()); + let body = if immediate { + format!( + "This cluster is back on Automatic and runs on Microsoft {major} from now \ + on." + ) + } else { + format!( + "This cluster is back on Automatic and picks Microsoft {major} from its next \ + launch." + ) + }; + events.notify("Java switched").body(body).send(); + } + // Only when installation was successfull and the cluster is not pointing to the new version + Err(err) => events + .notify("Cluster not switched") + .body(format!( + "Microsoft {major} is ready but the cluster still points at its old runtime: {err}" + )) + .error() + .send(), + } +} + +fn already_migrated(state: &Arc) -> bool { + state.settings.read().microsoft_java_migrated +} + +async fn mark_migrated(state: &Arc) { + let snapshot = { + let mut settings = state.settings.write(); + if settings.microsoft_java_migrated { + return; + } + settings.microsoft_java_migrated = true; + settings.clone() + }; + + if let Err(err) = save_settings_and_apply(&state.services, &snapshot).await { + tracing::warn!("could not record the Microsoft Java migration: {err:#}"); + } +} + +fn opted_out(state: &Arc) -> bool { + let settings = state.settings.read(); + settings.skip_microsoft_java || !settings.seen_onboarding +} + +fn claim_ask(cluster_id: ClusterId) -> bool { + ASKED + .lock() + .map(|mut asked| asked.insert(cluster_id)) + .unwrap_or(false) +} + +/// If there is not Microsoft build for given host, the offer is abandoned +async fn publishes(state: &Arc, major: u32) -> bool { + match state + .java + .latest_package(&JavaVendor::Microsoft, major) + .await + { + Ok(Some(_)) => true, + Ok(None) => { + tracing::info!(major, "Microsoft publishes no build for this host"); + false + } + Err(err) => { + tracing::warn!("could not reach Microsoft's downloads: {err:#}"); + false + } + } +} + +/// Only the cluster the launcher opens on the one the home panel preselects last played first and newest version when nothing has been played yet +async fn automatic_cluster(state: &Arc) -> Option { + let clusters = match state.clusters.list().await { + Ok(clusters) => clusters, + Err(err) => { + tracing::warn!("could not read the cluster list: {err:#}"); + return None; + } + }; + + let cluster = crate::utils::sort_clusters_for_home(clusters) + .into_iter() + .next()?; + + let global = state.settings.read().global_game_settings.clone(); + + match state.clusters.resolve_settings(&global, &cluster).await { + Ok(profile) => { + let automatic = profile.java_path.is_none(); + tracing::info!( + cluster_id = cluster.id, + name = %cluster.name, + automatic, + "checked the Java of the cluster the launcher opens on" + ); + automatic.then_some(cluster.id) + } + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "could not resolve cluster settings: {err:#}" + ); + None + } + } +} + +/// `None` when the cluster is on Automatic or when its pin points at a runtime that is no longer on disk +async fn pinned_runtime(state: &Arc, cluster_id: ClusterId) -> Option { + let cluster = state.clusters.get(cluster_id).await.ok()?; + let global = state.settings.read().global_game_settings.clone(); + let profile = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok()?; + + let path = profile.java_path?; + state.java.runtime_for_profile(Some(&path)).await.ok()? +} diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 5bc1d3fe..c5ab97e5 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -465,9 +465,11 @@ impl NotificationState { ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - // The sign-in modal renders this progress itself it must not also become a toast + Event::Progress(ProgressEvent::Update { id, .. }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => {} Event::Progress(ProgressEvent::Update { id, label, diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 82c3654e..51eeed8b 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -22,6 +22,7 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + StorageScan, Relocation, PendingLaunch, } @@ -43,6 +44,7 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub storage_scan: Option, pub relocation: RelocationState, pub pending_launch: Option, } @@ -121,6 +123,13 @@ pub struct LoginProgress { pub total: u64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorageScanProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LaunchBlock { Starting(i64), 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 29153440..441657b0 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 @@ -479,6 +479,11 @@ impl Component for PackageManager { cluster_id, package_type, )) + .maybe_child( + content_type + .is_global() + .then(|| views::global_notice(noun_plural)), + ) .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) .child(ContentBox::new( filtered, 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 0c3548c4..ea2c99f8 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 @@ -225,6 +225,16 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp ), }; + notice_bar(text) +} + +pub(super) fn global_notice(noun_plural: &'static str) -> Element { + notice_bar(format!( + "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." + )) +} + +fn notice_bar(text: String) -> Element { rect() .horizontal() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index 85d6015f..28b9f99f 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -5,7 +5,9 @@ use super::{section_header, settings_page}; use crate::components::{Button, Icon, IconType, open_folder_button}; use crate::hooks::{ StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, + use_storage_scan_progress, }; +use crate::state::StorageScanProgress; use crate::theme::colors; use crate::utils::plural; @@ -19,9 +21,12 @@ impl Component for SettingsStorage { fn render(&self) -> impl IntoElement { // Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order let report_query = use_storage_report(); + let scan = use_storage_scan_progress(); let Some(report) = try_storage_report(&report_query) else { - return settings_page().child(hero_placeholder()).into_element(); + return settings_page() + .child(scan_card(scan.as_ref())) + .into_element(); }; let refresh = Button::new() @@ -32,9 +37,13 @@ impl Component for SettingsStorage { }) .child(label().text("Refresh")); - settings_page() - .child(hero(&report, refresh.into_element())) - .child(section_header("FREE UP SPACE")) + let mut page = settings_page().child(hero(&report, refresh.into_element())); + + if scan.is_some() { + page = page.child(scan_card(scan.as_ref())); + } + + page.child(section_header("FREE UP SPACE")) .child( ReclaimRow { icon: IconType::FileX02, @@ -123,18 +132,47 @@ fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { .into_element() } -fn hero_placeholder() -> impl IntoElement { - rect() +// the first-load placeholder and the strip shown while a refresh rescans +fn scan_card(scan: Option<&StorageScanProgress>) -> impl IntoElement { + let counting = scan.filter(|scan| scan.total > 0); + let fraction = counting.map_or(0.0, |scan| scan.current as f32 / scan.total as f32); + + let mut header = rect() + .horizontal() .width(Size::fill()) - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) .child( + rect().width(Size::flex(1.0)).child( + label() + .text(scan.map_or_else( + || "Measuring disk usage…".to_string(), + |scan| format!("{}…", scan.label), + )) + .font_size(14.) + .color(colors::fg_secondary()), + ), + ); + + if let Some(scan) = counting { + header = header.child( label() - .text("Measuring disk usage…") - .font_size(16.) + .text(format!("{} / {}", scan.current, scan.total)) + .font_size(12.) .color(colors::fg_secondary()), - ) + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_symmetric(16., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(header) + .child(proportion_bar(fraction, colors::brand())) .into_element() } diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 4c6111bc..60a89fa7 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -15,6 +15,23 @@ use crate::stage::ClusterStage; pub use oneclient_common::paths::DEDICATED_MARKER; +// takes a cluster out of the shared `mods` folder +pub async fn remove_mods_link(folder_name: &str) { + let Ok(link) = paths::shared_mods_link(folder_name) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&link).await { + tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); + } + } + + _ => {} + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { pub id: ClusterId, diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 825353fb..904113bd 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -13,7 +13,7 @@ pub mod logs; pub mod profiles; pub mod screenshots; -pub use cluster::{Cluster, ClusterLinkTarget}; +pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; pub use manager::ClusterManager; pub use options::{ClusterUpdate, CreateClusterOptions}; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 489d625d..bc3b719a 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -13,7 +13,7 @@ use crate::error::ClusterResult; use oneclient_db::DbPool; use tokio::sync::Mutex; -use crate::cluster::Cluster; +use crate::cluster::{Cluster, remove_mods_link}; use crate::error::ClusterError; use crate::options::{ClusterUpdate, CreateClusterOptions}; use crate::stage::ClusterStage; @@ -168,6 +168,8 @@ impl ClusterManager { return Err(ClusterError::NotFound(cluster_id)); } + remove_mods_link(&cluster.folder_name).await; + if remove_files { let path = cluster.dir()?; if path.exists() { diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index bf25db06..c6015105 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -56,6 +56,12 @@ impl ContentType { } } + // installed once for the whole launcher rather than per cluster + #[must_use] + pub const fn is_global(self) -> bool { + matches!(self, Self::ResourcePack | Self::Shader) + } + #[must_use] pub const fn reloads_in_game(self) -> bool { matches!(self, Self::ResourcePack | Self::Shader) diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 10c3a754..20ce4fda 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -139,6 +139,22 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult { } } +pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { + Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) +} + +pub fn shared_mods_dir() -> PathsResult { + Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) +} + +pub fn global_content_dir(content_type: ContentType) -> PathsResult { + Ok(shared_minecraft_dir()?.join(content_type.folder_name())) +} + +pub fn shared_mods_link(folder_name: &str) -> PathsResult { + Ok(shared_mods_dir()?.join(folder_name)) +} + pub fn packages_cache_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("packages")) } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index df8f1693..f126d790 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -1,5 +1,6 @@ use futures_util::StreamExt; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; @@ -598,12 +599,45 @@ pub async fn reconcile_duplicate_activity( Ok(()) } +// which clusters have to record what the user just did +async fn override_scope( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(ContentType::is_global); + + if !global { + return Ok(vec![cluster_id]); + } + + let mut ids: Vec = cluster_dao::list_all(&ctx.db) + .await? + .into_iter() + .map(|row| row.id) + .collect(); + + if !ids.contains(&cluster_id) { + ids.push(cluster_id); + } + + Ok(ids) +} + +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn on_user_disable_artifact( cluster_id: i64, hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await + for id in override_scope(cluster_id, hash, ctx).await? { + handle_user_artifact_action(id, hash, ctx, OverrideType::Disabled).await?; + } + + Ok(()) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -612,10 +646,13 @@ pub async fn on_user_enable_artifact( hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? - && let Some(package_id) = tracked.package_id { - clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; - } + for id in override_scope(cluster_id, hash, ctx).await? { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await? + && let Some(package_id) = tracked.package_id { + clear_suppressing_overrides(id, &package_id, ctx).await?; + } + } + Ok(()) } diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index c223c3e3..566bc63c 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -87,31 +87,55 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> { Ok(()) } +fn materialized_root( + cluster: &ClusterRow, + content_type: ContentType, +) -> Option<(std::path::PathBuf, &'static str)> { + if content_type.is_global() { + return paths::shared_minecraft_dir() + .ok() + .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); + } + + if content_type == ContentType::Mod { + return paths::cluster_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MODS_MANIFEST_NAME)); + } + + paths::cluster_game_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MANIFEST_NAME)) +} + #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( cluster: &ClusterRow, content_type: ContentType, file_name: &str, ) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + let Some((root, manifest_name)) = materialized_root(cluster, content_type) else { return false; }; let _guard = manifest::lock().await; - let Some(mut loaded) = manifest::load(&game_dir).await else { + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { return false; }; - // The shared game dir belongs to whichever cluster played last - // touching a file we did not put there would delete another cluster's or - // the user's content let relative = manifest::entry_path(content_type.folder_name(), file_name); - if !loaded.owns(cluster.id, &relative) { + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; + + if !ours { return false; } - let path = game_dir.join(content_type.folder_name()).join(file_name); + let path = root.join(&relative); if let Err(err) = remove_entry(&path).await { tracing::debug!( file = file_name, @@ -122,7 +146,7 @@ pub async fn try_unlink_materialized( } loaded.entries.retain(|entry| entry.path != relative); - manifest::save(&game_dir, &loaded).await; + manifest::save(&root, manifest_name, &loaded).await; true } @@ -141,7 +165,7 @@ pub async fn try_link_materialized( return LiveSync::Skipped; } - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + let Some((root, manifest_name)) = materialized_root(cluster, content_type) else { return LiveSync::Deferred; }; @@ -157,19 +181,25 @@ pub async fn try_link_materialized( let _guard = manifest::lock().await; // No manifest means nothing is playing out of this folder right now - let Some(mut loaded) = manifest::load(&game_dir).await else { + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { return LiveSync::Skipped; }; - if loaded.cluster_id != cluster.id { + if !content_type.is_global() && loaded.cluster_id != cluster.id { return LiveSync::Deferred; } let relative = manifest::entry_path(content_type.folder_name(), file_name); - let dest = game_dir.join(content_type.folder_name()).join(file_name); + let dest = root.join(&relative); + + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; // Same rule as the unlink path: never write over a file we did not place - if !loaded.owns(cluster.id, &relative) && polyio::symlink_metadata(&dest).await.is_ok() { + if !ours && polyio::symlink_metadata(&dest).await.is_ok() { tracing::debug!( file = file_name, "a file we do not own already sits in the game folder; leaving it to the next launch" @@ -191,7 +221,7 @@ pub async fn try_link_materialized( path: relative, hash: artifact.hash.clone(), }); - manifest::save(&game_dir, &loaded).await; + manifest::save(&root, manifest_name, &loaded).await; LiveSync::Applied } @@ -259,6 +289,7 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[cfg(not(windows))] #[tokio::test] async fn remove_entry_clears_a_dangling_link() { let root = polyio::testing::ScratchDir::new("dangling_link"); @@ -283,6 +314,27 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[cfg(windows)] + #[tokio::test] + async fn remove_entry_clears_a_hard_link() { + let root = polyio::testing::ScratchDir::new("hard_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); + + assert!(link.exists(), "the link is still holding the file"); + + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + #[tokio::test] async fn replacing_a_pack_leaves_only_the_pack() { let root = polyio::testing::ScratchDir::new("atomic_replace"); diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index 99b12a64..ec7bbe5e 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; +pub const MODS_MANIFEST_NAME: &str = ".oneclient-mods.json"; +pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json"; const MANIFEST_VERSION: u32 = 1; @@ -48,14 +50,14 @@ impl MaterializedManifest { } #[must_use] -pub fn manifest_path(game_dir: &Path) -> PathBuf { - game_dir.join(MANIFEST_NAME) +pub fn manifest_path(dir: &Path, name: &str) -> PathBuf { + dir.join(name) } /// An unparseable manifest reads as `None` not an error /// stashing links as user content is recoverable refusing to launch is not -pub async fn load(game_dir: &Path) -> Option { - let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; +pub async fn load(dir: &Path, name: &str) -> Option { + let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?; match serde_json::from_str::(&raw) { Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), @@ -73,7 +75,7 @@ pub async fn load(game_dir: &Path) -> Option { } } -pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { +pub async fn save(dir: &Path, name: &str, manifest: &MaterializedManifest) { let body = match serde_json::to_vec_pretty(manifest) { Ok(body) => body, Err(err) => { @@ -82,13 +84,13 @@ pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { } }; - if let Err(err) = polyio::write(manifest_path(game_dir), body).await { + if let Err(err) = polyio::write(manifest_path(dir, name), body).await { tracing::warn!(error = %err, "failed to write materialized manifest"); } } -pub async fn clear(game_dir: &Path) { - polyio::remove_file(manifest_path(game_dir)).await.ok(); +pub async fn clear(dir: &Path, name: &str) { + polyio::remove_file(manifest_path(dir, name)).await.ok(); } static MANIFEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -102,6 +104,11 @@ pub fn entry_path(content_folder: &str, file_name: &str) -> String { format!("{content_folder}/{file_name}") } +// whether this cluster keeps its mods in its own folder rather than in the game directory +pub async fn mods_live_in_cluster(cluster_dir: &Path) -> bool { + load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -131,15 +138,40 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - assert!(load(dir).await.is_none(), "no manifest yet"); + assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); - save(dir, &manifest()).await; - let loaded = load(dir).await.expect("manifest should load"); + save(dir, MANIFEST_NAME, &manifest()).await; + let loaded = load(dir, MANIFEST_NAME).await.expect("manifest should load"); assert_eq!(loaded.cluster_id, 7); assert!(loaded.contains("mods/sodium.jar")); - clear(dir).await; - assert!(load(dir).await.is_none(), "cleared manifest should be gone"); + clear(dir, MANIFEST_NAME).await; + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "cleared manifest should be gone" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn the_two_manifests_do_not_collide() { + let root = polyio::testing::ScratchDir::new("manifest_two_names"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + save(dir, MODS_MANIFEST_NAME, &manifest()).await; + + assert!( + load(dir, MODS_MANIFEST_NAME).await.is_some(), + "the mods manifest is there" + ); + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "and it is not mistaken for the game-dir one" + ); + + clear(dir, MODS_MANIFEST_NAME).await; std::fs::remove_dir_all(root.path()).ok(); } @@ -149,11 +181,11 @@ mod tests { let root = polyio::testing::ScratchDir::new("manifest_garbage"); let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - polyio::write(manifest_path(dir), b"not json".as_slice()) + polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) .await .unwrap(); - assert!(load(dir).await.is_none()); + assert!(load(dir, MANIFEST_NAME).await.is_none()); std::fs::remove_dir_all(root.path()).ok(); } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 5b0c3276..22c27c51 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -282,6 +282,10 @@ impl PackageStore { ) .await?; + if content_type.is_global() { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, i64::from(enabled)).await?; + } + // Only the enable side has an outcome to report; a pack that is not in // the running folder needs no removing from it let live = if enabled { diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 8c484704..37be52b9 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -4,7 +4,9 @@ mod provision; mod unlink_legacy; pub use migrate::apply_remote_migrations; -pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; +pub use prepare::{ + estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major, +}; pub use provision::{ensure_from_bundles, ensure_from_versions}; pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 5f4e73f0..cda56181 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -151,6 +151,35 @@ async fn cached_assets_index( polyio::read_json(&path).await.ok() } +#[tracing::instrument(level = "debug", skip(state))] +pub async fn required_java_major( + state: &Arc, + cluster_id: i64, +) -> LauncherResult> { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let info = { + let mut metadata = state.metadata.lock().await; + let (version, _index, _updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false) + .await? + }; + + Ok(info.java_version.map(|java| java.major_version)) +} + #[tracing::instrument(level = "debug", skip(state, bundles))] pub async fn estimate_cluster_download( state: &Arc, diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 1387f98e..49d94c71 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -10,12 +10,16 @@ use oneclient_content::packages::store::manifest::{ }; use oneclient_content::packages::store::artifact_absolute_path; -const SWEPT_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const SWEPT_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; +const SWEPT_TYPES_REDIRECTED: [ContentType; 1] = [ContentType::DataPack]; + +async fn swept_types(cluster_root: &Path) -> &'static [ContentType] { + if manifest::mods_live_in_cluster(cluster_root).await { + &SWEPT_TYPES_REDIRECTED + } else { + &SWEPT_TYPES + } +} #[derive(Debug, Default, Clone, Copy)] pub struct SweepReport { @@ -24,8 +28,6 @@ pub struct SweepReport { pub skipped: usize, } -/// User-triggered only the "hash matches a cached artifact so it is ours" rule -/// is true during the transition and false once cluster folders hold user content #[tracing::instrument(skip(state))] pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { let mut report = SweepReport::default(); @@ -36,10 +38,12 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes continue; }; + let swept = swept_types(&cluster_root).await; + if dedicated { - adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; } else { - sweep_shared(state, &cluster_root, &mut report).await; + sweep_shared(state, &cluster_root, swept, &mut report).await; } } @@ -55,8 +59,13 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes Ok(report) } -async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { - for content_type in SWEPT_TYPES { +async fn sweep_shared( + state: &LauncherState, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, +) { + for content_type in swept { let dir = cluster_root.join(content_type.folder_name()); let Ok(mut entries) = polyio::read_dir(&dir).await else { continue; @@ -105,11 +114,15 @@ async fn adopt_dedicated( state: &LauncherState, cluster: &crate::clusters::Cluster, cluster_root: &Path, + swept: &[ContentType], report: &mut SweepReport, ) { - let _manifest = manifest::lock().await; - - if manifest::load(cluster_root).await.is_some() { + let _manifest = manifest::lock().await; + + if manifest::load(cluster_root, manifest::MANIFEST_NAME) + .await + .is_some() + { return; } @@ -128,7 +141,7 @@ async fn adopt_dedicated( let mut entries = Vec::new(); for link in linked { - if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + if !link.enabled || !swept.contains(&link.content_type) { continue; } @@ -148,7 +161,12 @@ async fn adopt_dedicated( } report.adopted += entries.len(); - manifest::save(cluster_root, &MaterializedManifest::new(cluster.id, entries)).await; + manifest::save( + cluster_root, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; } enum CacheMatch { diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs new file mode 100644 index 00000000..325dd724 --- /dev/null +++ b/packages/oneclient_core/src/game/fabric.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use oneclient_common::domain::GameLoader; + +const MODS_FOLDER_PROPERTY: &str = "fabric.modsFolder"; + +// version 0.15.0 is required for fabric.modsFolder to work +const MIN_LOADER_VERSION: Version = Version(0, 15, 0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Version(u32, u32, u32); + +fn parse_version(raw: &str) -> Option { + let core = raw.trim().split(['+', '-']).next()?; + let mut parts = core.split('.'); + + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + + Some(Version(major, minor, patch)) +} + +#[must_use] +pub fn uses_cluster_mods_folder( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, +) -> bool { + if loader != GameLoader::Fabric { + return false; + } + + if custom_args.contains(MODS_FOLDER_PROPERTY) { + tracing::info!("launch args already set {MODS_FOLDER_PROPERTY}; leaving the layout alone"); + return false; + } + + parse_version(loader_version.unwrap_or_default()).is_some_and(|v| v >= MIN_LOADER_VERSION) +} + +#[must_use] +pub fn mods_folder_argument( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, + mods_dir: &Path, +) -> Option { + if !uses_cluster_mods_folder(loader, loader_version, custom_args) { + return None; + } + + Some(format!("-D{MODS_FOLDER_PROPERTY}={}", mods_dir.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_compare_by_component_not_by_string() { + assert!(parse_version("0.9.0") < parse_version("0.15.0")); + assert!(parse_version("0.16.5") > parse_version("0.15.0")); + assert_eq!(parse_version("0.15"), Some(Version(0, 15, 0))); + } + + #[test] + fn build_metadata_is_ignored() { + assert_eq!(parse_version("0.16.0+build.1"), Some(Version(0, 16, 0))); + assert_eq!(parse_version("0.16.0-rc.1"), Some(Version(0, 16, 0))); + } + + #[test] + fn only_fabric_and_only_new_enough() { + assert!(uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.16.5"), + "" + )); + assert!(!uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.11.0"), + "" + )); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, None, "")); + + for loader in [ + GameLoader::Vanilla, + GameLoader::Forge, + GameLoader::NeoForge, + GameLoader::Quilt, + GameLoader::LegacyFabric, + ] { + assert!(!uses_cluster_mods_folder(loader, Some("0.16.5"), "")); + } + } + + #[test] + fn a_user_set_property_takes_the_whole_layout_with_it() { + let dir = Path::new("/clusters/one/mods"); + let mine = "-Dfabric.modsFolder=/elsewhere"; + + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), "-Xmx4G", dir).is_some()); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, Some("0.16.5"), mine)); + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), mine, dir).is_none()); + } +} diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs index 7104ef1e..71571762 100644 --- a/packages/oneclient_core/src/game/heal.rs +++ b/packages/oneclient_core/src/game/heal.rs @@ -22,6 +22,27 @@ const SWEPT_CONTENT: [(ContentType, bool); 3] = [ (ContentType::Shader, false), ]; +#[tracing::instrument(level = "debug")] +pub async fn clear_zeroed_mods(cluster_dir: &Path) -> usize { + let mods_dir = cluster_dir.join(ContentType::Mod.folder_name()); + + tokio::task::spawn_blocking(move || { + let cleared = sweep_dir(&mods_dir, true); + + if cleared > 0 { + tracing::info!( + cleared, + mods_dir = %mods_dir.display(), + "cleared zero-filled files; affected mods will regenerate defaults" + ); + } + + cleared + }) + .await + .unwrap_or(0) +} + #[tracing::instrument(level = "debug")] pub async fn clear_zeroed_files(game_dir: &Path) -> usize { let game_dir = game_dir.to_path_buf(); diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index baa10624..f9ef2d0a 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -253,15 +253,22 @@ async fn start( tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); } - // The one moment nothing holds the content open so this is where a package - // removed or disabled mid-session actually leaves the folder - if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + let custom_args = profile.launch_args.clone().unwrap_or_default(); + let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str()); + + let mods_in_cluster = crate::game::uses_cluster_mods_folder( + cluster.mc_loader, + loader_version_id, + &custom_args, + ); + + if let Err(err) = + crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await + { tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } if !dedicated { - // Redirects the shared dir's `logs`/`crash-reports` into this cluster's - // folder so output is attributable unlinked on exit crate::game::link_cluster_logs(&cluster, &cwd).await; } @@ -283,7 +290,7 @@ async fn start( updated, )?; - let jvm_args = arguments::java_arguments( + let mut jvm_args = arguments::java_arguments( updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, @@ -296,6 +303,17 @@ async fn start( java.major, )?; + let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; + if let Some(arg) = crate::game::mods_folder_argument( + cluster.mc_loader, + loader_version_id, + &custom_args, + &mods_dir, + ) { + tracing::debug!(cluster_id, mods_dir = %mods_dir.display(), "redirecting fabric mods folder"); + jvm_args.push(arg); + } + let mut mc_args = arguments::minecraft_arguments( updated, arg_map.get(&ArgumentType::Game).map(Vec::as_slice), diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 8c6754a4..83b0894a 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,5 +1,6 @@ mod analytics; mod error; +pub mod fabric; #[cfg(any(target_os = "linux", test))] mod gpu; mod heal; @@ -38,6 +39,7 @@ pub use oneclient_mc::{ validate_rules, verify_game_files, }; +pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use shared_dir::{ dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, unlink_cluster_logs, write_allowed_symlinks, diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index d4155bdd..22f6d84b 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -3,10 +3,13 @@ use std::fs::FileType; use std::path::{Path, PathBuf}; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use crate::LauncherResult; use crate::clusters::Cluster; +use oneclient_cluster::remove_mods_link; use oneclient_common::domain::ContentType; +use oneclient_common::paths; use oneclient_content::packages::store::manifest::{ self, ManifestEntry, MaterializedManifest, }; @@ -18,11 +21,13 @@ use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; -const SWAP_TYPES: [ContentType; 3] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, -]; +const GLOBAL_TYPES: [ContentType; 2] = [ContentType::ResourcePack, ContentType::Shader]; + +const SWAP_TYPES: [ContentType; 1] = [ContentType::Mod]; + +fn swap_types(mods_in_cluster: bool) -> &'static [ContentType] { + if mods_in_cluster { &[] } else { &SWAP_TYPES } +} const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; @@ -40,23 +45,97 @@ impl Desired { } } -/// Safe to run over a directory left by a crashed session another cluster or -/// a launcher version predating the manifest #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn materialize_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, + mods_in_cluster: bool, ) -> LauncherResult<()> { let dedicated = cluster.uses_dedicated_dir(); + let cluster_dir = cluster.dir()?; + let global_root = paths::shared_minecraft_dir()?; + polyio::create_dir_all(game_dir).await.ok(); + polyio::create_dir_all(&global_root).await.ok(); + + if mods_in_cluster { + polyio::create_dir_all(paths::cluster_mods_dir(&cluster.folder_name)?) + .await + .ok(); + ensure_mods_link(cluster).await; + prune_mods_links(services).await; + } else { + unwind_cluster_mods(cluster, &cluster_dir).await; + } + + adopt_into_global(game_dir, &global_root).await; + ensure_global_links(game_dir, &global_root).await; + let mods_swapped = !dedicated && !mods_in_cluster; + drop_stale_notes(&[game_dir, &cluster_dir, &global_root], mods_swapped).await; + + // In the shared directory this often belongs to another cluster so every + // use of it checks the id + let previous = manifest::load(game_dir, manifest::MANIFEST_NAME) + .await + .map(without_global_entries); + let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; + let previous_global = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; crate::game::heal::clear_zeroed_files(game_dir).await; + if mods_in_cluster { + crate::game::heal::clear_zeroed_mods(&cluster_dir).await; + } - import_manual_content(services, cluster, game_dir).await; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + if mods_in_cluster && previous_mods.is_none() && !dedicated { + let from = game_dir.join(ContentType::Mod.folder_name()); + let into = cluster_dir.join(ContentType::Mod.folder_name()); + let ours = ours_in_folder(ContentType::Mod, &linked, previous.as_ref()); + + tracing::info!(cluster_id = cluster.id, "moving mods out of the shared game directory"); + stash_content_files(&from, &into, &ours).await; + } + + if mods_in_cluster { + let mods_dir = cluster_dir.join(ContentType::Mod.folder_name()); + let disabled = disable_hand_removed( + services, + cluster, + &mods_dir, + ContentType::Mod, + previous_mods.as_ref(), + ) + .await; + + if !disabled.is_empty() { + let (title, body) = removal_notice(&disabled, Some(&cluster.name)); + services.events.notify(title).body(body).send(); + } + } + + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let disabled = disable_hand_removed( + services, + cluster, + &dir, + content_type, + previous_global.as_ref(), + ) + .await; + + if !disabled.is_empty() { + let (title, body) = removal_notice(&disabled, None); + services.events.notify(title).body(body).send(); + } + } + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await; - // Before the folder is built not after handing the game several enabled - // versions of one mod is a classloader conflict if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( cluster.id, &services.content(), @@ -67,55 +146,390 @@ pub async fn materialize_content( tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); } + let (mods, rest): (Vec, Vec) = desired_mods(services, cluster) + .await? + .into_iter() + .partition(|_| mods_in_cluster); + + // read across every cluster rather than this one so a pack installed anywhere is present here too + let packs = desired_global(services).await?; + // Held from the database snapshot through the save so a package removed // mid-launch is not resurrected by our own write; the two calls above take // it themselves so it cannot be taken any earlier let _manifest = manifest::lock().await; - // In the shared directory this often belongs to another cluster so every - // use of it checks the id - let previous = manifest::load(game_dir).await; - - let desired = desired_content(services, cluster).await?; - let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); - + let mods_root = if mods_in_cluster { &cluster_dir } else { game_dir }; for content_type in SWAP_TYPES { - sweep_staging_files(&game_dir.join(content_type.folder_name())).await; + sweep_staging_files(&mods_root.join(content_type.folder_name())).await; + } + for content_type in GLOBAL_TYPES { + sweep_staging_files(&global_root.join(content_type.folder_name())).await; } // While the game is still closed this is what lands a package removed // mid-session and clears another cluster's content from the shared dir - prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + let mod_paths: HashSet = mods.iter().map(Desired::relative_path).collect(); + let rest_paths: HashSet = rest.iter().map(Desired::relative_path).collect(); + let pack_paths: HashSet = packs.iter().map(Desired::relative_path).collect(); - if !dedicated { - let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) - .await - .unwrap_or_default(); + prune_previous(&cluster_dir, previous_mods.as_ref(), &mod_paths).await; + prune_previous(game_dir, previous.as_ref(), &rest_paths).await; + prune_previous(&global_root, previous_global.as_ref(), &pack_paths).await; - for content_type in SWAP_TYPES { + if !dedicated { + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - // Whatever is still here belongs to whoever played (or crashed) - // last so take it into this cluster rather than deleting it - let ours = ours_in_folder(content_type, &linked, previous.as_ref()); + let ours = ours_in_folder(*content_type, &linked, previous.as_ref()); stash_content_files(&dir, &stash, &ours).await; - ensure_note(&dir, content_type).await; - restore_stashed(&stash, &dir, content_type, &ours).await; + ensure_note(&dir, *content_type).await; + restore_stashed(&stash, &dir, *content_type, &ours).await; } } - let entries = link_desired(game_dir, &desired).await; - manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + if mods_in_cluster { + let mod_entries = link_desired(&cluster_dir, &mods).await; + manifest::save( + &cluster_dir, + manifest::MODS_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, mod_entries), + ) + .await; + } + + let pack_entries = link_desired(&global_root, &packs).await; + manifest::save( + &global_root, + manifest::GLOBAL_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, pack_entries), + ) + .await; + + let entries = link_desired(game_dir, &rest).await; + manifest::save( + game_dir, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; sync_fabric_dep_overrides(cluster, game_dir).await?; Ok(()) } -/// Only for shared directories a dedicated cluster's content stays put between -/// sessions and [`materialize_content`] reconciles it at the next launch +// every enabled pack across every cluster +async fn desired_global(services: &LauncherServices) -> LauncherResult> { + let mut desired = Vec::new(); + + for content_type in GLOBAL_TYPES { + for row in artifact_dao::list_global_artifacts(&services.db, content_type as i64).await? { + if row.enabled == 0 { + continue; + } + + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? + else { + continue; + }; + + let src = artifact_absolute_path(&artifact.path)?; + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %row.hash, "cached artifact missing; skipping"); + continue; + } + + desired.push(Desired { + content_type, + file_name: row.file_name, + hash: row.hash, + src, + }); + } + } + + Ok(desired) +} + +fn without_global_entries(mut manifest: MaterializedManifest) -> MaterializedManifest { + let prefixes: Vec = GLOBAL_TYPES + .iter() + .map(|content_type| format!("{}/", content_type.folder_name())) + .collect(); + + manifest + .entries + .retain(|entry| !prefixes.iter().any(|prefix| entry.path.starts_with(prefix))); + + manifest +} + +// moves a cluster's own pack folders into the shared one before [`ensure_global_links`] replaces them with links +async fn adopt_into_global(game_dir: &Path, global_root: &Path) { + if game_dir == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let own = game_dir.join(content_type.folder_name()); + let shared = global_root.join(content_type.folder_name()); + + match polyio::symlink_metadata(&own).await { + Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {} + _ => continue, + } + + polyio::create_dir_all(&shared).await.ok(); + + let Ok(mut entries) = polyio::read_dir(&own).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let dest = shared.join(&name); + + if polyio::symlink_metadata(&dest).await.is_ok() { + continue; + } + + if let Err(err) = move_entry(&entry.path(), &dest).await { + tracing::warn!( + file = %name.to_string_lossy(), + error = %err, + "failed to move content into the shared folder; leaving it in place" + ); + } + } + } +} + +// points a cluster's pack folders at the shared ones +async fn ensure_global_links(game_dir: &Path, global_root: &Path) { + if game_dir == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let link = game_dir.join(content_type.folder_name()); + let target = global_root.join(content_type.folder_name()); + polyio::create_dir_all(&target).await.ok(); + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + continue; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(meta) if meta.is_dir() => { + if polyio::remove_dir_all(&link).await.is_err() { + tracing::warn!( + dir = %link.display(), + "cannot clear the cluster's own pack folder; leaving it unlinked" + ); + continue; + } + } + + Ok(_) => continue, + Err(_) => {} + } + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + dir = %link.display(), + error = %err, + "failed to link the shared pack folder into the game directory" + ); + } + } +} + +const MASS_REMOVAL_FLOOR: usize = 3; + +// names in a manifest whose file is no longer on disk paired with the hash that identifies the row to disable +async fn hand_removed_content( + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec<(String, String)> { + let Some(previous) = previous else { + return Vec::new(); + }; + + if polyio::read_dir(dir).await.is_err() { + tracing::warn!( + dir = %dir.display(), + "cannot read the content folder; leaving activity alone" + ); + return Vec::new(); + } + + let prefix = format!("{}/", content_type.folder_name()); + let mut considered = 0usize; + let mut removed = Vec::new(); + + for entry in &previous.entries { + let Some(name) = entry.path.strip_prefix(&prefix) else { + continue; + }; + considered += 1; + + if polyio::symlink_metadata(dir.join(name)).await.is_ok() { + continue; + } + + removed.push((name.to_owned(), entry.hash.clone())); + } + + if removed.len() == considered && considered >= MASS_REMOVAL_FLOOR { + tracing::warn!( + count = considered, + dir = %dir.display(), + "everything materialized here is missing; reading that as a folder problem, not as deletions" + ); + return Vec::new(); + } + + removed +} + +// turns hand-removed content off so the next launch stops putting it back +async fn disable_hand_removed( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec { + let removed = hand_removed_content(dir, content_type, previous).await; + if removed.is_empty() { + return Vec::new(); + } + + let ctx = services.content(); + let mut disabled = Vec::new(); + + for (name, hash) in removed { + let outcome = if content_type.is_global() { + disable_globally(cluster, &hash, &ctx).await + } else { + oneclient_content::bundles::set_artifact_enabled_to(cluster.id, &hash, false, &ctx) + .await + .map(|_| ()) + .map_err(Into::into) + }; + + match outcome { + Ok(()) => { + tracing::info!( + cluster_id = cluster.id, + file = %name, + ?content_type, + "removed by hand; disabling it instead of restoring it" + ); + disabled.push(name); + } + Err(err) => tracing::warn!( + cluster_id = cluster.id, + file = %name, + error = %err, + "failed to disable hand-removed content; it will be restored" + ), + } + } + + disabled +} + +// switches a globally installed artifact off for every cluster that has it +async fn disable_globally( + cluster: &Cluster, + hash: &str, + ctx: &oneclient_content::ContentCtx, +) -> LauncherResult<()> { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, 0).await?; + oneclient_content::bundles::on_user_disable_artifact(cluster.id, hash, ctx).await?; + Ok(()) +} + +// at most three names +fn removal_summary(disabled: &[String]) -> String { + const SHOWN: usize = 3; + + let names = disabled + .iter() + .take(SHOWN) + .map(String::as_str) + .collect::>() + .join(", "); + + match disabled.len().saturating_sub(SHOWN) { + 0 => names, + rest => format!("{names} and {rest} more"), + } +} + +fn removal_notice(disabled: &[String], cluster_name: Option<&str>) -> (&'static str, String) { + let names = removal_summary(disabled); + + let folder = match cluster_name { + Some(name) => format!("{name}'s folder"), + None => "your shared folder".to_string(), + }; + + let scope = if cluster_name.is_some() { + "" + } else { + " on every cluster" + }; + + if disabled.len() == 1 { + return ( + "Content disabled", + format!( + "{names} is gone from {folder}, so it has been switched off{scope}. \ + Turn it back on in OneClient to restore it." + ), + ); + } + + ( + "Content disabled", + format!( + "{names} are gone from {folder}, so they have been switched off{scope}. \ + Turn them back on in OneClient to restore them." + ), + ) +} + +// puts a cluster back on the old layout after its loader stopped supporting `fabric.modsFolder` (a downgrade or a switch away from Fabric 0.15.0) +async fn unwind_cluster_mods(cluster: &Cluster, cluster_dir: &Path) { + remove_mods_link(&cluster.folder_name).await; + + let Some(previous) = manifest::load(cluster_dir, manifest::MODS_MANIFEST_NAME).await else { + return; + }; + + tracing::info!( + cluster_id = cluster.id, + "loader cannot be redirected; returning mods to the game directory" + ); + + prune_previous(cluster_dir, Some(&previous), &HashSet::new()).await; + manifest::clear(cluster_dir, manifest::MODS_MANIFEST_NAME).await; +} + #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn dematerialize_content( services: &LauncherServices, @@ -128,27 +542,30 @@ pub async fn dematerialize_content( import_manual_content(services, cluster, game_dir).await; let _manifest = manifest::lock().await; - let current = manifest::load(game_dir).await; + let cluster_dir = cluster.dir()?; + let current = manifest::load(game_dir, manifest::MANIFEST_NAME).await; let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) .await .unwrap_or_default(); - for content_type in SWAP_TYPES { + let mods_in_cluster = manifest::mods_live_in_cluster(&cluster_dir).await; + + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - let ours = ours_in_folder(content_type, &linked, current.as_ref()); + let ours = ours_in_folder(*content_type, &linked, current.as_ref()); stash_content_files(&dir, &stash, &ours).await; sweep_staging_files(&dir).await; - ensure_note(&dir, content_type).await; + ensure_note(&dir, *content_type).await; } - manifest::clear(game_dir).await; + manifest::clear(game_dir, manifest::MANIFEST_NAME).await; Ok(()) } -async fn desired_content( +async fn desired_mods( services: &LauncherServices, cluster: &Cluster, ) -> LauncherResult> { @@ -156,7 +573,7 @@ async fn desired_content( let mut desired = Vec::with_capacity(linked.len()); for link in linked { - if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + if !link.enabled || link.content_type != ContentType::Mod { continue; } @@ -182,13 +599,11 @@ async fn desired_content( Ok(desired) } -/// Only files that made it are recorded so a failed link is never later -/// mistaken for ours and deleted out from under the user -async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { +async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { let mut entries = Vec::with_capacity(desired.len()); for item in desired { - let dest = game_dir + let dest = root .join(item.content_type.folder_name()) .join(&item.file_name); @@ -211,7 +626,7 @@ async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec, keep: &HashSet, ) { @@ -224,7 +639,7 @@ async fn prune_previous( continue; } - let path = game_dir.join(&entry.path); + let path = root.join(&entry.path); if let Err(err) = remove_entry(&path).await { tracing::warn!( file = %entry.path, @@ -268,6 +683,20 @@ pub async fn import_manual_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, +) { + let mods_in_cluster = match cluster.dir() { + Ok(dir) => manifest::mods_live_in_cluster(&dir).await, + Err(_) => false, + }; + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await; +} + +async fn import_manual_content_with( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, + mods_in_cluster: bool, ) { let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { Ok(linked) => linked, @@ -280,73 +709,141 @@ pub async fn import_manual_content( // Not held across the import loop below, which is long and does not need it let manifest = { let _guard = manifest::lock().await; - manifest::load(game_dir).await + manifest::load(game_dir, manifest::MANIFEST_NAME).await }; - for content_type in SWAP_TYPES { - let dir = game_dir.join(content_type.folder_name()); - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + // under the old layout mods sit in the game directory and are matched + // against its manifest exactly like resource packs and shaders + let cluster_dir = cluster.dir().ok(); + let mods_manifest = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => manifest::load(dir, manifest::MODS_MANIFEST_NAME).await, + _ => None, + }; - let known: HashSet<&str> = linked - .iter() - .filter(|link| link.content_type == content_type) - .map(|link| link.cluster_file_name.as_str()) - .collect(); + let (mods_dir, mods_manifest) = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => ( + dir.join(ContentType::Mod.folder_name()), + mods_manifest.as_ref(), + ), + _ => ( + game_dir.join(ContentType::Mod.folder_name()), + manifest.as_ref(), + ), + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; - if !file_type.is_file() { - continue; - } + import_from_dir( + services, + cluster, + &mods_dir, + ContentType::Mod, + &names_linked_here(&linked, ContentType::Mod), + mods_manifest, + ) + .await; - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.starts_with('.') || !has_content_extension(content_type, name) { - continue; - } - if known.contains(name) { - continue; - } + let Ok(global_root) = paths::shared_minecraft_dir() else { + return; + }; + let global_manifest = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; - // Ours from a previous launch `prune_previous` handles it - // Taking it as a user drop-in would reinstall a just-deleted package - let relative = manifest::entry_path(content_type.folder_name(), name); - if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let known = match artifact_dao::list_global_artifacts(&services.db, content_type as i64) + .await + { + Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list global content; skipping its import"); continue; } + }; - // No manifest a directory from a launcher version predating it - // If the cache holds this exact file the launcher put it here - if manifest.is_none() && is_cached_artifact(services, &path).await { - tracing::debug!(file = name, "discarding stale launcher content in game dir"); - if let Err(err) = polyio::remove_file(&path).await { - tracing::warn!(file = name, error = %err, "failed to discard stale content"); - } - continue; + import_from_dir( + services, + cluster, + &dir, + content_type, + &known, + global_manifest.as_ref(), + ) + .await; + } +} + +fn names_linked_here( + linked: &[oneclient_content::packages::LinkedArtifactInfo], + content_type: ContentType, +) -> HashSet { + linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect() +} + +async fn import_from_dir( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + known: &HashSet, + manifest: Option<&MaterializedManifest>, +) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.is_some_and(|m| m.contains(&relative)) { + continue; + } + + if manifest.is_none() && is_cached_artifact(services, &path).await { + tracing::debug!( + file = name, + dir = %dir.display(), + "discarding stale launcher content; the cache still holds it" + ); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale content"); } + continue; + } - match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await { - Ok(_) => { - tracing::debug!(file = name, "registered manually-added content") - } - Err(err) => tracing::warn!( - file = name, - error = %err, - "failed to register manually-added content" - ), + match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()) + .await + { + Ok(_) => { + tracing::debug!(file = name, "registered manually-added content") } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ), } } } -/// Already in the artifact cache i.e. the launcher put it in the game dir -/// rather than the user dropping it there async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { return false; @@ -368,6 +865,108 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool { } } +// puts this cluster's mods folder into the shared `mods` directory +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] +async fn ensure_mods_link(cluster: &Cluster) { + let (Ok(link), Ok(target)) = ( + paths::shared_mods_link(&cluster.folder_name), + paths::cluster_mods_dir(&cluster.folder_name), + ) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + return; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(_) => { + tracing::warn!( + folder = %cluster.folder_name, + "shared mods folder holds a real entry under this name; not linking" + ); + return; + } + + Err(_) => {} + } + + if let Some(parent) = link.parent() { + polyio::create_dir_all(parent).await.ok(); + ensure_links_note(parent).await; + } + polyio::create_dir_all(&target).await.ok(); + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + folder = %cluster.folder_name, + error = %err, + "failed to link cluster mods into the shared minecraft folder" + ); + } +} + +async fn points_into_clusters_dir(path: &Path) -> bool { + let (Ok(target), Ok(root)) = (polyio::read_link(path).await, paths::clusters_dir()) else { + return false; + }; + + target.starts_with(root) +} + +// drops links a deleted cluster left behind +#[tracing::instrument(skip(services), level = "debug")] +async fn prune_mods_links(services: &LauncherServices) { + let Ok(root) = paths::shared_mods_dir() else { + return; + }; + + let Ok(mut entries) = polyio::read_dir(&root).await else { + return; + }; + + let known: HashSet = match cluster_dao::list_all(&services.db).await { + Ok(rows) => rows.into_iter().map(|row| row.folder_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list clusters; leaving shared mods links alone"); + return; + } + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_symlink() { + continue; + } + + let name = entry.file_name().to_string_lossy().into_owned(); + if known.contains(&name) { + continue; + } + + if !points_into_clusters_dir(&entry.path()).await { + continue; + } + + match polyio::remove_symlink_dir(entry.path()).await { + Ok(()) => tracing::debug!(link = %name, "cleared mods link for a deleted cluster"), + Err(err) => { + tracing::warn!(link = %name, error = %err, "failed to clear stale cluster mods link") + } + } + } +} + const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; #[tracing::instrument(level = "debug")] @@ -404,10 +1003,35 @@ fn allowed_symlinks_body(roots: &[PathBuf]) -> String { } const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; +const LINKS_NOTE_NAME: &str = "EACH_FOLDER_IS_A_CLUSTER.txt"; + +fn is_note(name: &str) -> bool { + name == EMPTY_NOTE_NAME || name == LINKS_NOTE_NAME +} + +async fn ensure_links_note(dir: &Path) { + let note = dir.join(LINKS_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + polyio::write( + ¬e, + "Every folder in here is one of your OneClient clusters.\n\ + \n\ + They're shortcuts. Open one and you land in that cluster's own mods \ + folder, which is where its mods really live. Drop a jar in there and \ + that cluster will pick it up the next time you play - and only that \ + cluster.\n\ + \n\ + Loose jars sitting directly in this folder aren't read by anything, so \ + put them inside a cluster's folder instead.\n", + ) + .await + .ok(); +} -/// Launcher-owned content is dropped (the cache has it) everything else -/// (sidecars unzipped packs stray configs) is *moved* into the cluster folder -/// so it stays attached to that cluster and [`restore_stashed`] links it back async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Ok(mut entries) = polyio::read_dir(dir).await else { return; @@ -418,7 +1042,7 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') { + if is_note(&name) || name.starts_with('.') { continue; } @@ -426,9 +1050,16 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { continue; }; - // Ours and the cache has it covered - // On Windows `symlink_file` hard-links so ours is not always a symlink - if file_type.is_symlink() || ours.contains(&name) { + if file_type.is_symlink() { + if points_into_clusters_dir(&path).await { + continue; + } + + remove_dir_or_file(&path, file_type).await; + continue; + } + + if ours.contains(&name) { remove_dir_or_file(&path, file_type).await; continue; } @@ -444,8 +1075,6 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { } } -/// Links stashed leftovers back so the game writes straight through into the -/// cluster folder even if we never get to run on exit async fn restore_stashed( stash: &Path, dir: &Path, @@ -461,7 +1090,7 @@ async fn restore_stashed( let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { + if is_note(&name) || name.starts_with('.') || ours.contains(&name) { continue; } @@ -533,6 +1162,45 @@ async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { Ok(()) } +async fn drop_stale_notes(roots: &[&Path], mods_swapped: bool) { + let mut swept: Vec<&Path> = Vec::new(); + + for root in roots { + // The shared game directory *is* the global root for a cluster without + // a dedicated directory, and both are the cluster folder for one with + // it visiting a root twice would only walk the same folders again + if swept.contains(root) { + continue; + } + swept.push(*root); + + for content_type in GLOBAL_TYPES { + drop_note(&root.join(content_type.folder_name())).await; + } + + if !mods_swapped { + drop_note(&root.join(ContentType::Mod.folder_name())).await; + } + } +} + +async fn drop_note(dir: &Path) { + let note = dir.join(EMPTY_NOTE_NAME); + if polyio::symlink_metadata(¬e).await.is_err() { + return; + } + + match polyio::remove_file(¬e).await { + Ok(()) => tracing::debug!(dir = %dir.display(), "removed a stale empty-folder note"), + // Nothing downstream reads it the next launch tries again + Err(err) => tracing::debug!( + dir = %dir.display(), + error = %err, + "could not remove the stale empty-folder note" + ), + } +} + async fn ensure_note(dir: &Path, content_type: ContentType) { let note = dir.join(EMPTY_NOTE_NAME); @@ -956,6 +1624,222 @@ mod tests { names } + #[test] + fn mods_are_swapped_only_while_they_still_live_in_the_game_dir() { + assert!( + !swap_types(true).contains(&ContentType::Mod), + "redirected mods are not the game dir's to swap" + ); + assert!( + swap_types(false).contains(&ContentType::Mod), + "un-redirected mods still have to leave the shared dir on exit" + ); + } + + #[test] + fn global_content_is_never_swapped() { + for types in [swap_types(true), swap_types(false)] { + for content_type in GLOBAL_TYPES { + assert!( + !types.contains(&content_type), + "{content_type:?} is shared and must survive a session" + ); + } + } + + for content_type in GLOBAL_TYPES { + assert!(content_type.is_global()); + } + assert!(!ContentType::Mod.is_global()); + } + + #[test] + fn the_game_dir_manifest_stops_claiming_packs() { + let manifest = manifest_of( + 1, + &[ + "mods/sodium.jar", + "resourcepacks/faithful.zip", + "shaderpacks/bsl.zip", + ], + ); + + let stripped = without_global_entries(manifest); + let paths = stripped.paths(); + + assert!(paths.contains("mods/sodium.jar")); + assert!(!paths.contains("resourcepacks/faithful.zip")); + assert!(!paths.contains("shaderpacks/bsl.zip")); + } + + #[test] + fn the_global_notice_owns_up_to_its_reach() { + let (_, body) = removal_notice(&["bsl.zip".into()], None); + + assert!(body.contains("every cluster"), "{body}"); + assert!(!body.contains("'s folder"), "{body}"); + } + + async fn mods_scratch(name: &str, present: &[&str]) -> polyio::testing::ScratchDir { + let root = polyio::testing::ScratchDir::new(name); + polyio::create_dir_all(root.path()).await.unwrap(); + + for file in present { + polyio::write(root.join(file), b"jar".as_slice()).await.unwrap(); + } + + root + } + + fn mods_manifest(files: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + 1, + files + .iter() + .map(|name| ManifestEntry { + path: manifest::entry_path(ContentType::Mod.folder_name(), name), + hash: format!("hash-{name}"), + }) + .collect(), + ) + } + + #[tokio::test] + async fn a_jar_the_user_deleted_is_reported_with_its_hash() { + let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await; + let manifest = mods_manifest(&["kept.jar", "gone.jar", "also_kept.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed, vec![("gone.jar".into(), "hash-gone.jar".into())]); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn renaming_a_jar_out_of_the_way_counts_as_removing_it() { + let dir = mods_scratch("renamed_away", &["sodium.jar.disabled", "other.jar"]).await; + let manifest = mods_manifest(&["sodium.jar", "other.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].0, "sodium.jar"); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn an_unreadable_folder_disables_nothing() { + let manifest = mods_manifest(&["a.jar", "b.jar"]); + let missing = Path::new("definitely-not-a-directory-ю"); + + assert!(hand_removed_content(missing, ContentType::Mod, Some(&manifest)).await.is_empty()); + } + + #[tokio::test] + async fn a_wholesale_disappearance_reads_as_a_folder_problem() { + let dir = mods_scratch("all_gone", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar", "c.jar", "d.jar"]); + + assert!( + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.is_empty(), + "an empty folder where everything was is not four deliberate deletions" + ); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn clearing_a_short_list_is_still_taken_at_face_value() { + let dir = mods_scratch("small_clear", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar"]); + + assert_eq!(hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.len(), 2); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn a_first_launch_concludes_nothing() { + let dir = mods_scratch("no_manifest", &[]).await; + + assert!(hand_removed_content(dir.path(), ContentType::Mod, None).await.is_empty()); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[test] + fn the_notice_names_a_few_and_counts_the_rest() { + let (title, body) = removal_notice(&["sodium.jar".into()], Some("26.2 Fabric")); + assert_eq!(title, "Mod disabled"); + assert!(body.contains("sodium.jar is gone"), "{body}"); + + let many: Vec = (0..6).map(|i| format!("mod{i}.jar")).collect(); + let (title, body) = removal_notice(&many, Some("26.2 Fabric")); + assert_eq!(title, "Mods disabled"); + assert!(body.contains("and 3 more"), "{body}"); + assert!(!body.contains("mod5.jar"), "{body}"); + } + + #[test] + fn notes_are_never_user_content() { + assert!(is_note(EMPTY_NOTE_NAME)); + assert!(is_note(LINKS_NOTE_NAME)); + assert!(!is_note("sodium.jar")); + } + + #[tokio::test] + async fn stale_notes_go_but_the_folder_is_left_alone() { + let root = polyio::testing::ScratchDir::new("stale_notes"); + let dir = root.path(); + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + polyio::create_dir_all(&sub).await.unwrap(); + polyio::write(sub.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(sub.join("keep.jar"), b"jar".as_slice()) + .await + .unwrap(); + } + + drop_stale_notes(&[dir], false).await; + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + assert!(!sub.join(EMPTY_NOTE_NAME).exists(), "{folder}"); + assert!(sub.join("keep.jar").exists(), "{folder}"); + } + + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn a_swapped_mods_folder_keeps_its_note() { + let root = polyio::testing::ScratchDir::new("swapped_note"); + let dir = root.path(); + + let mods = dir.join("mods"); + let packs = dir.join("resourcepacks"); + polyio::create_dir_all(&mods).await.unwrap(); + polyio::create_dir_all(&packs).await.unwrap(); + polyio::write(mods.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(packs.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + + drop_stale_notes(&[dir], true).await; + + assert!(mods.join(EMPTY_NOTE_NAME).exists()); + assert!(!packs.join(EMPTY_NOTE_NAME).exists()); + + std::fs::remove_dir_all(dir).ok(); + } + #[test] fn ownership_spans_the_manifest_and_the_database() { let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); @@ -964,8 +1848,6 @@ mod tests { let ours = ours_in_folder(ContentType::Mod, &linked, Some(&manifest)); assert!(ours.contains("from_manifest.jar")); - // Scoped to the folder a shaderpack entry must not make a mod of the - // same name look managed assert!(!ours.contains("bsl.zip")); } } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 63d15d2d..55cb511c 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -41,7 +41,7 @@ pub use tos::{fetch_terms, TermsDocument}; pub use oneclient_discord::{DiscordRpc, Presence}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, - ensure_from_bundles, ensure_from_versions, estimate_cluster_download, + ensure_from_bundles, ensure_from_versions, estimate_cluster_download, required_java_major, }; pub use error::{LauncherError, LauncherResult, SentryExclusion}; pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 699552a3..9c712a26 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -249,6 +249,14 @@ async fn relink_cluster_files( for content_type in FILE_CONTENT_TYPES { let dir = cluster_root.join(content_type.folder_name()); + + if polyio::symlink_metadata(&dir) + .await + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + continue; + } + let files = match list_files(&dir).await { Ok(files) => files, Err(_) => continue, diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs index b7d21654..ccf8bfdf 100644 --- a/packages/oneclient_core/src/relocate.rs +++ b/packages/oneclient_core/src/relocate.rs @@ -352,21 +352,29 @@ async fn drop_materialized_content(state: &LauncherState) { let mut dirs = Vec::new(); if let Ok(shared) = paths::shared_minecraft_dir() { - dirs.push(shared); + dirs.push((shared.clone(), manifest::MANIFEST_NAME)); + dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); } match state.clusters.list().await { - Ok(clusters) => dirs.extend( - clusters - .iter() - .filter(|cluster| cluster.uses_dedicated_dir()) - .filter_map(|cluster| cluster.game_dir().ok()), - ), + Ok(clusters) => { + for cluster in &clusters { + if let Ok(dir) = cluster.dir() { + dirs.push((dir, manifest::MODS_MANIFEST_NAME)); + } + + if cluster.uses_dedicated_dir() + && let Ok(dir) = cluster.game_dir() + { + dirs.push((dir, manifest::MANIFEST_NAME)); + } + } + } Err(err) => tracing::warn!(%err, "could not list clusters; only clearing the shared folder"), } - for dir in dirs { - let Some(loaded) = manifest::load(&dir).await else { + for (dir, manifest_name) in dirs { + let Some(loaded) = manifest::load(&dir, manifest_name).await else { continue; }; @@ -379,7 +387,7 @@ async fn drop_materialized_content(state: &LauncherState) { } } - manifest::clear(&dir).await; + manifest::clear(&dir, manifest_name).await; } if let Ok(shared) = paths::shared_minecraft_dir() { diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 92f16c6c..48c7276b 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -52,6 +52,8 @@ pub struct LauncherSettings { pub animations_enabled: bool, pub view_states: BTreeMap, pub seen_onboarding: bool, + pub skip_microsoft_java: bool, + pub microsoft_java_migrated: bool, pub accepted_tos_version: u32, pub accepted_privacy_version: u32, pub declined_tos: bool, @@ -93,6 +95,8 @@ impl Default for LauncherSettings { animations_enabled: true, view_states: BTreeMap::new(), seen_onboarding: false, + skip_microsoft_java: false, + microsoft_java_migrated: false, accepted_tos_version: 0, accepted_privacy_version: 0, declined_tos: false, diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 5da23d98..e149dbde 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -2,21 +2,24 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::LauncherResult; +use crate::clusters::Cluster; use crate::state::LauncherState; use oneclient_common::domain::ContentType; use oneclient_common::paths; +use oneclient_content::packages::store::manifest; use oneclient_content::packages::store::{ find_unreferenced_files, remove_unreferenced_files, }; +use oneclient_events::EventBus; -const LEGACY_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const LEGACY_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; + +// stable id for the storage scan's progress +pub const STORAGE_SCAN_PROGRESS: Uuid = + Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageEntry { @@ -59,35 +62,57 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult LauncherResult { + events: &'a EventBus, + done: u64, + total: u64, +} + +impl ScanSteps<'_> { + fn begin(&mut self, label: &str) { + self.events.progress( + STORAGE_SCAN_PROGRESS, + format!("Measuring {label}"), + self.done, + self.total, + ); + self.done += 1; + } +} + +impl Drop for ScanSteps<'_> { + fn drop(&mut self) { + self.events + .progress(STORAGE_SCAN_PROGRESS, "Done", self.total, self.total); + } +} + async fn natives_in_use(state: &LauncherState) -> Option> { let clusters = state.clusters.list().await.ok()?; let metadata = state.metadata.lock().await; @@ -191,11 +241,10 @@ async fn unused_natives(state: &LauncherState) -> ReclaimableEntry { /// Content is materialized from the cache now so anything in a cluster's own /// folder is an inert leftover from an older launcher space not correctness -async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { +async fn legacy_cluster_content(clusters: &[Cluster]) -> ReclaimableEntry { let mut found = ReclaimableEntry::default(); - for cluster in state.clusters.list().await? { - // A dedicated cluster's folder *is* its game directory so content there belongs + for cluster in clusters { if cluster.uses_dedicated_dir() { continue; } @@ -203,7 +252,13 @@ async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult LauncherResult bool { @@ -247,18 +302,20 @@ fn is_content_file(content_type: ContentType, name: &str) -> bool { } } -async fn entry(label: &str, path: PathBuf) -> StorageEntry { +async fn entry(label: &str, path: PathBuf, seen: &mut SeenFiles) -> StorageEntry { StorageEntry { label: label.to_string(), - bytes: dir_size(&path).await, + bytes: dir_size_seen(&path, seen).await, path, files: None, } } -/// Links are not followed so a materialized game directory does not appear to -/// double the size of the package cache pub async fn dir_size(root: impl AsRef) -> u64 { + dir_size_seen(root, &mut SeenFiles::default()).await +} + +async fn dir_size_seen(root: impl AsRef, seen: &mut SeenFiles) -> u64 { let mut total = 0; let mut stack = vec![root.as_ref().to_path_buf()]; @@ -278,7 +335,9 @@ pub async fn dir_size(root: impl AsRef) -> u64 { if file_type.is_dir() { stack.push(entry.path()); - } else if let Ok(meta) = entry.metadata().await { + } else if let Ok(meta) = entry.metadata().await + && seen.first_sighting(&entry.path()).await + { total += meta.len(); } } @@ -287,8 +346,37 @@ pub async fn dir_size(root: impl AsRef) -> u64 { total } -/// Cleanup refuses to run while fixture numbers are shown the report bears no -/// relation to disk so the only thing it could delete is the user's real data +#[derive(Default)] +struct SeenFiles(HashSet<(u64, u64)>); + +impl SeenFiles { + async fn first_sighting(&mut self, path: &Path) -> bool { + match file_identity(path).await { + Some(id) => self.0.insert(id), + None => true, + } + } +} + +async fn file_identity(path: &Path) -> Option<(u64, u64)> { + if !can_be_materialized(path) { + return None; + } + + polyio::file_id(path).await.ok() +} + +fn can_be_materialized(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + lower.ends_with(".jar") || lower.ends_with(".zip") +} + fn showing_fixture() -> bool { #[cfg(debug_assertions)] { @@ -487,14 +575,14 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir.join("nested")).await.unwrap(); - polyio::write(dir.join("a.bin"), vec![0u8; 1000]).await.unwrap(); - polyio::write(dir.join("nested").join("b.bin"), vec![0u8; 500]) + polyio::write(dir.join("a.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(dir.join("nested").join("b.jar"), vec![0u8; 500]) .await .unwrap(); assert_eq!(dir_size(dir).await, 1500); - polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + polyio::symlink_file(dir.join("a.jar"), dir.join("link.jar")) .await .unwrap(); assert_eq!( @@ -505,4 +593,29 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + + #[tokio::test] + async fn a_file_in_two_folders_is_paid_for_once() { + let root = polyio::testing::ScratchDir::new("shared_size"); + let dir = root.path(); + let store = dir.join("store"); + let cluster = dir.join("cluster"); + polyio::create_dir_all(&store).await.unwrap(); + polyio::create_dir_all(&cluster).await.unwrap(); + + polyio::write(store.join("mod.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::symlink_file(store.join("mod.jar"), cluster.join("mod.jar")) + .await + .unwrap(); + + let mut seen = SeenFiles::default(); + assert_eq!(dir_size_seen(&store, &mut seen).await, 1000); + assert_eq!( + dir_size_seen(&cluster, &mut seen).await, + 0, + "the cache already paid for it" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 1c2ed553..af35351a 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -72,6 +72,59 @@ pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result< Ok(true) } +use crate::models::GlobalArtifactRow; + +/// One row per hash across every cluster for content that is installed globally +/// +/// `enabled` is the OR over the clusters: one cluster still having a pack on is +/// enough to keep it in the folder, because there is one folder and it can only +/// have one answer +/// +/// Switching a pack off still reaches every cluster it goes through +/// [`set_enabled_for_hash`], which leaves no row for this to read as on +/// +/// Written with the runtime-checked builder rather than `query!` so it needs no +/// entry in the offline cache +pub async fn list_global_artifacts( + pool: &SqlitePool, + content_type: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, GlobalArtifactRow>( + r#" + SELECT + ca.hash AS hash, + MIN(ca.cluster_file_name) AS file_name, + MAX(ca.enabled) AS enabled + FROM cluster_artifacts ca + JOIN artifacts a ON a.hash = ca.hash + WHERE a.content_type = ? + GROUP BY ca.hash + "#, + ) + .bind(content_type) + .fetch_all(pool) + .await +} + +/// Sets the flag on every cluster that has this artifact +/// +/// Globally installed content has one folder so writing only the row of +/// whichever cluster the user happened to be looking at would leave the rest +/// disagreeing with what is on disk +pub async fn set_enabled_for_hash( + pool: &SqlitePool, + hash: &str, + enabled: i64, +) -> Result { + let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") + .bind(enabled) + .bind(hash) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as::<_, ArtifactRow>( r#" diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs index c82b67a8..ebcfdd25 100644 --- a/packages/oneclient_db/src/models/artifact.rs +++ b/packages/oneclient_db/src/models/artifact.rs @@ -77,3 +77,11 @@ pub struct LinkedArtifactRow { pub display_version: Option, pub published_at: Option, } + +// used for resourcepacks and shaders +#[derive(Debug, Clone, FromRow)] +pub struct GlobalArtifactRow { + pub hash: String, + pub file_name: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 3ba3cb26..9e83ac64 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -10,7 +10,7 @@ mod package_metadata; mod setting_profile; pub use artifact::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, + ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, GlobalArtifactRow }; pub use browser_package_update::BrowserPackageUpdateRow; pub use package_metadata::PackageMetadataRow; diff --git a/packages/oneclient_java/Cargo.toml b/packages/oneclient_java/Cargo.toml index b677d596..667ec4db 100644 --- a/packages/oneclient_java/Cargo.toml +++ b/packages/oneclient_java/Cargo.toml @@ -24,6 +24,7 @@ async-tempfile.workspace = true async_zip.workspace = true astral-tokio-tar.workspace = true futures-lite.workspace = true +futures-util.workspace = true tokio.workspace = true reqwest.workspace = true diff --git a/packages/oneclient_java/examples/install_provider.rs b/packages/oneclient_java/examples/install_provider.rs index b9ef8b9c..6b39611b 100644 --- a/packages/oneclient_java/examples/install_provider.rs +++ b/packages/oneclient_java/examples/install_provider.rs @@ -6,7 +6,7 @@ use oneclient_common::paths::java_dir; use oneclient_events::EventBus; use oneclient_java::vendors::{ AdoptiumRuntimeProvider, CorrettoRuntimeProvider, JavaRuntimeProvider, LibericaRuntimeProvider, - ZuluRuntimeProvider, + MicrosoftRuntimeProvider, ZuluRuntimeProvider, }; use oneclient_java::{JavaResult, JavaService, MemoryJavaStore, check_java_runtime}; use oneclient_net::{NetConfig, RequestClient}; @@ -15,7 +15,7 @@ use oneclient_net::{NetConfig, RequestClient}; async fn main() -> JavaResult<()> { let mut args = env::args().skip(1); let vendor = args.next().unwrap_or_else(|| { - eprintln!("usage: install_provider [major]"); + eprintln!("usage: install_provider [major]"); std::process::exit(1); }); @@ -34,8 +34,11 @@ async fn main() -> JavaResult<()> { "adoptium" => Box::new(AdoptiumRuntimeProvider), "corretto" => Box::new(CorrettoRuntimeProvider), "liberica" => Box::new(LibericaRuntimeProvider), + "microsoft" => Box::new(MicrosoftRuntimeProvider), other => { - eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); + eprintln!( + "unknown vendor '{other}', use zulu, adoptium, corretto, liberica, or microsoft" + ); std::process::exit(1); } }; diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs index 50e94a48..381d9f00 100644 --- a/packages/oneclient_java/src/service.rs +++ b/packages/oneclient_java/src/service.rs @@ -64,6 +64,20 @@ impl JavaService { Ok(self.store.list().await?) } + /// Checks if file wasn't deleted manually + #[tracing::instrument(level = "debug", skip(self))] + pub async fn has_vendor_runtime( + &self, + vendor: &JavaVendor, + major: Option, + ) -> JavaResult { + Ok(self.list_runtimes().await?.iter().any(|runtime| { + &runtime.vendor == vendor + && major.is_none_or(|major| runtime.major == major) + && Path::new(&runtime.absolute_path).is_file() + })) + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn runtime_for_profile( &self, @@ -140,6 +154,20 @@ impl JavaService { Ok(available) } + /// `None` when the vendor publishes nothing for this major on this host + #[tracing::instrument(level = "debug", skip(self))] + pub async fn latest_package( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(None); + }; + + provider.latest_package_by_major(major, &self.net).await + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn rescan(&self) -> JavaResult<()> { self.register_located(&crate::locate::locate_java().await?) @@ -181,14 +209,7 @@ impl JavaService { auto_install: bool, progress: Option<&GroupedProgressSession>, ) -> JavaResult { - let recorded = loop { - let Some(runtime) = self.store.latest_by_major(major).await? else { - break None; - }; - if let Some(valid) = self.revalidate(runtime).await? { - break Some(valid); - } - }; + let recorded = self.best_recorded_for_major(major).await?; if let Some(runtime) = &recorded && runtime.is_jdk @@ -219,6 +240,28 @@ impl JavaService { self.prompt_and_install(major, progress).await } + #[tracing::instrument(level = "debug", skip(self))] + async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { + let preferred = vendors::default_vendor(); + + let mut candidates: Vec = self + .list_runtimes() + .await? + .into_iter() + .filter(|runtime| runtime.major == major) + .collect(); + + candidates.sort_by(|a, b| pick_order(b, &preferred).cmp(&pick_order(a, &preferred))); + + for candidate in candidates { + if let Some(valid) = self.revalidate(candidate).await? { + return Ok(Some(valid)); + } + } + + Ok(None) + } + #[tracing::instrument(skip(self, progress))] pub async fn install_runtime( &self, @@ -400,6 +443,25 @@ impl JavaService { } } +/// Ranked highest-first the default vendor beats every other, a kit beats a +/// runtime of the same vendor, and only then does the newest build win +fn pick_order(runtime: &JavaRuntime, preferred: &JavaVendor) -> (bool, bool, Vec) { + ( + &runtime.vendor == preferred, + runtime.is_jdk, + version_key(&runtime.version), + ) +} + +/// `21.0.9` sorts above `21.0.12` as text which is backwards so the components +/// are compared as numbers Legacy `1.8.0_412` keeps its build as the last one +fn version_key(version: &str) -> Vec { + version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse().ok()) + .collect() +} + fn provider_for_vendor(vendor: &JavaVendor) -> Option> { vendors::runtime_providers() .into_iter() @@ -445,4 +507,69 @@ mod tests { assert!(parse_major_version("not-a-version").is_err()); assert!(parse_major_version("1.x").is_err()); } + + fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { + JavaRuntime { + absolute_path: format!("/java/{vendor}-{version}/bin/java"), + major: parse_major_version(version).unwrap(), + version: version.to_string(), + vendor, + os_arch: "x64".to_string(), + is_jdk, + probe_version: PROBE_VERSION, + } + } + + /// Highest first the same way `best_recorded_for_major` sorts + fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { + candidates.sort_by(|a, b| pick_order(b, preferred).cmp(&pick_order(a, preferred))); + candidates.remove(0) + } + + #[test] + fn patch_numbers_are_compared_as_numbers_not_as_text() { + assert!(version_key("21.0.12") > version_key("21.0.9")); + assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); + } + + #[test] + fn the_default_vendor_wins_even_against_a_newer_build() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.99", true), + runtime(JavaVendor::Microsoft, "21.0.1", true), + ], + &JavaVendor::Microsoft, + ); + + assert_eq!(picked.vendor, JavaVendor::Microsoft); + } + + #[test] + fn a_kit_beats_a_runtime_of_the_same_vendor() { + let picked = best( + vec![ + runtime(JavaVendor::Microsoft, "21.0.5", false), + runtime(JavaVendor::Microsoft, "21.0.2", true), + ], + &JavaVendor::Microsoft, + ); + + assert!(picked.is_jdk); + } + + /// Nothing from the preferred vendor means the ranking falls through to the + /// newest build rather than to whatever the store happened to return + #[test] + fn without_the_default_vendor_the_newest_build_wins() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.9", true), + runtime(JavaVendor::Adoptium, "21.0.12", true), + ], + &JavaVendor::Microsoft, + ); + + assert_eq!(picked.version, "21.0.12"); + } } diff --git a/packages/oneclient_java/src/vendors/microsoft.rs b/packages/oneclient_java/src/vendors/microsoft.rs new file mode 100644 index 00000000..a66a5ba0 --- /dev/null +++ b/packages/oneclient_java/src/vendors/microsoft.rs @@ -0,0 +1,286 @@ +use futures_util::StreamExt; +use reqwest::{Method, Request}; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct MicrosoftRuntimeProvider; + +/// Microsoft publishes no metadata API at all only static `aka.ms` vanity +/// links so the majors it still builds have to be listed here +const MICROSOFT_MAJORS: &[u32] = &[25, 21, 17, 11]; + +/// Alpine is the one platform Microsoft never filled out x64 only and nothing +/// newer than 17 +const MICROSOFT_ALPINE_MAJORS: &[u32] = &[17, 11]; + +/// Four small redirects when no major is pinned nowhere near enough to be +/// worth throttling harder +const MAJOR_LOOKUP_CONCURRENCY: usize = 4; + +#[async_trait::async_trait] +impl JavaRuntimeProvider for MicrosoftRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Microsoft + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let Some(arch) = MICROSOFT_ARCH else { + tracing::debug!( + "Microsoft publishes no build for this architecture; listing nothing" + ); + return Ok(Vec::new()); + }; + + let wanted: Vec = MICROSOFT_MAJORS + .iter() + .copied() + .filter(|candidate| major.is_none_or(|filter| filter == *candidate)) + .filter(|candidate| publishes_host_build(*candidate, arch)) + .collect(); + + let mut packages: Vec = futures_util::stream::iter( + wanted + .into_iter() + .map(|major| async move { resolve_package(major, arch, net).await }), + ) + .buffer_unordered(MAJOR_LOOKUP_CONCURRENCY) + .collect::>() + .await + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect(); + + packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0))); + tracing::debug!(count = packages.len(), "listed Microsoft packages"); + Ok(packages) + } +} + +/// The checksum file names the build it belongs to so one request resolves the +/// exact version, the filename and the hash the major-only link would download +#[tracing::instrument(level = "debug", skip(net))] +async fn resolve_package( + major: u32, + arch: &str, + net: &RequestClient, +) -> JavaResult> { + let url = checksum_url(major, arch)?; + let response = net.send(Request::new(Method::GET, url)).await?; + let status = response.status(); + let body = response + .text() + .await + .map_err(oneclient_net::RequestError::from)?; + + if !status.is_success() { + tracing::debug!( + major, + status = status.as_u16(), + "Microsoft checksum file is unavailable; skipping the major" + ); + return Ok(None); + } + + let Some(build) = parse_checksum_file(&body, major) else { + tracing::debug!( + major, + "Microsoft published no build under this link; skipping the major" + ); + return Ok(None); + }; + + Ok(Some( + JavaPackage { + archive: MICROSOFT_EXT.1, + download_url: download_url(&build.filename), + java_version: build.java_version, + name: build.filename, + vendor: JavaVendor::Microsoft, + checksum: None, + size: None, + } + .with_checksum(Some(build.checksum)), + )) +} + +struct MicrosoftBuild { + filename: String, + java_version: Vec, + checksum: Checksum, +} + +/// `aka.ms` answers a link it does not know with a 200 and a search page +/// rather than a 404 so only the body can say whether a build exists +fn parse_checksum_file(body: &str, major: u32) -> Option { + let line = body.lines().next()?.trim(); + let (hex, filename) = line.split_once(char::is_whitespace)?; + let filename = filename.trim(); + + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + + let java_version = version_from_filename(filename)?; + + // A link that quietly moved to another major would otherwise install the + // wrong runtime under the requested one's name + if java_version.first() != Some(&major) { + tracing::warn!( + major, + filename, + "Microsoft's major link resolved to a different major; skipping it" + ); + return None; + } + + Some(MicrosoftBuild { + filename: filename.to_string(), + java_version, + checksum: Checksum::sha256(hex), + }) +} + +/// Splitting the whole filename the way the other vendors split their version +/// strings would fold the `64` of `x64` into the version +fn version_from_filename(filename: &str) -> Option> { + let (version, _) = filename + .strip_prefix("microsoft-jdk-")? + .split_once('-')?; + + let parts: Vec = version + .split('.') + .map(str::parse) + .collect::>() + .ok()?; + + (!parts.is_empty()).then_some(parts) +} + +/// Microsoft never published a JRE so every link is a kit by construction +fn checksum_url(major: u32, arch: &str) -> JavaResult { + Ok(Url::parse(&format!( + "https://aka.ms/download-jdk/microsoft-jdk-{major}-{MICROSOFT_OS}-{arch}.{}.sha256sum.txt", + MICROSOFT_EXT.0 + ))?) +} + +/// Prefer the versioned link the checksum names over the major-only one it was +/// read through so the download cannot drift onto a newer build mid-release +fn download_url(filename: &str) -> String { + format!("https://aka.ms/download-jdk/{filename}") +} + +fn publishes_host_build(major: u32, arch: &str) -> bool { + if HostTarget::CURRENT.is_musl() { + return arch == "x64" && MICROSOFT_ALPINE_MAJORS.contains(&major); + } + true +} + +/// Microsoft doesn't supply 32-bit +const MICROSOFT_ARCH: Option<&str> = match HostTarget::CURRENT.arch { + HostArch::X86_64 => Some("x64"), + HostArch::Aarch64 => Some("aarch64"), + HostArch::X86 | HostArch::Arm => None, +}; + +/// Microsoft files musl builds under `alpine` where the other vendors say +/// `linux-musl` +const MICROSOFT_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "alpine", + HostOs::Linux { musl: false } => "linux", +}; + +const MICROSOFT_EXT: (&str, PackageArchive) = ( + HostTarget::CURRENT.archive_ext(), + HostTarget::CURRENT.archive(), +); + +#[cfg(test)] +mod tests { + use super::*; + + const LINE: &str = + "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; + + #[test] + fn only_kits_are_ever_requested() { + let url = checksum_url(21, "x64").expect("a valid url"); + + assert!(url.as_str().contains("microsoft-jdk-21-")); + assert!(!url.as_str().contains("jre")); + } + + #[test] + fn a_checksum_file_carries_the_build_it_belongs_to() { + let build = parse_checksum_file(LINE, 21).expect("a build"); + + assert_eq!(build.filename, "microsoft-jdk-21.0.12-windows-x64.zip"); + assert_eq!(build.java_version, vec![21, 0, 12]); + assert_eq!(build.checksum.algorithm, polyio::ChecksumAlgorithm::Sha256); + assert!(build.checksum.is_well_formed()); + } + + #[test] + fn the_search_page_a_dead_link_redirects_to_is_not_a_build() { + assert!(parse_checksum_file("", 21).is_none()); + assert!(parse_checksum_file("", 21).is_none()); + } + + #[test] + fn a_link_resolving_to_another_major_is_refused() { + assert!(parse_checksum_file(LINE, 17).is_none()); + } + + #[test] + fn the_architecture_never_leaks_into_the_version() { + assert_eq!( + version_from_filename("microsoft-jdk-21.0.12-windows-x64.zip"), + Some(vec![21, 0, 12]) + ); + assert_eq!( + version_from_filename("microsoft-jdk-25.0.4-linux-aarch64.tar.gz"), + Some(vec![25, 0, 4]) + ); + } + + #[test] + fn debug_symbol_builds_are_never_mistaken_for_runtimes() { + assert_eq!( + version_from_filename("microsoft-jdk-debugsymbols-21.0.12-windows-x64.zip"), + None + ); + } + + #[test] + fn alpine_only_gets_the_majors_microsoft_actually_built_for_it() { + for major in MICROSOFT_MAJORS { + let published = publishes_host_build(*major, "x64"); + if HostTarget::CURRENT.is_musl() { + assert_eq!(published, MICROSOFT_ALPINE_MAJORS.contains(major)); + } else { + assert!(published); + } + } + + if HostTarget::CURRENT.is_musl() { + assert!(!publishes_host_build(17, "aarch64")); + } + } +} diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs index b1c5e872..8a83cfff 100644 --- a/packages/oneclient_java/src/vendors/mod.rs +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -10,16 +10,19 @@ use crate::error::JavaResult; mod adoptium; mod corretto; mod liberica; +mod microsoft; mod zulu; pub use adoptium::AdoptiumRuntimeProvider; pub use corretto::CorrettoRuntimeProvider; pub use liberica::LibericaRuntimeProvider; +pub use microsoft::MicrosoftRuntimeProvider; use serde::{Deserialize, Deserializer, Serialize}; pub use zulu::ZuluRuntimeProvider; pub fn runtime_providers() -> Vec> { vec![ + Box::new(MicrosoftRuntimeProvider), Box::new(ZuluRuntimeProvider), Box::new(AdoptiumRuntimeProvider), Box::new(CorrettoRuntimeProvider), @@ -27,6 +30,15 @@ pub fn runtime_providers() -> Vec> { ] } +// For making sure microsoft jdk provider is first +#[must_use] +pub fn default_vendor() -> JavaVendor { + runtime_providers() + .first() + .expect("the provider list is never empty") + .vendor() +} + #[async_trait::async_trait] pub trait JavaRuntimeProvider: Send + Sync { fn vendor(&self) -> JavaVendor; diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 3c8961c0..d13badf3 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -46,4 +46,5 @@ sha2.workspace = true [target.'cfg(windows)'.dependencies] junction.workspace = true +windows-sys.workspace = true diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 09aed9b6..f80453cc 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -647,9 +647,70 @@ pub async fn symlink_file( }) } -/// Windows gets a junction which needs no elevated privilege unlike a real -/// directory symlink -/// Remove with [`remove_symlink_dir`] +// what a file is on disk rather than what it is called +#[cfg(windows)] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let path = path.as_ref().to_path_buf(); + let display = path.to_string_lossy().to_string(); + + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut info = BY_HANDLE_FILE_INFORMATION { + dwFileAttributes: 0, + ftCreationTime: unsafe { std::mem::zeroed() }, + ftLastAccessTime: unsafe { std::mem::zeroed() }, + ftLastWriteTime: unsafe { std::mem::zeroed() }, + dwVolumeSerialNumber: 0, + nFileSizeHigh: 0, + nFileSizeLow: 0, + nNumberOfLinks: 0, + nFileIndexHigh: 0, + nFileIndexLow: 0, + }; + + let filled = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) + }; + + if filled == 0 { + return Err(std::io::Error::last_os_error()); + } + + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { + source: e, + path: display, + }) +} + +#[cfg(not(windows))] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + + let meta = stat(path).await?; + Ok((meta.dev(), meta.ino())) +} + #[tracing::instrument( level = "debug", skip(original, link), @@ -685,7 +746,34 @@ pub async fn symlink_dir( }) } -/// A Windows junction must be removed with `remove_dir` not `remove_file` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_link(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + let target = tokio::fs::read_link(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + + #[cfg(windows)] + { + let text = target.to_string_lossy().into_owned(); + for prefix in [r"\??\", r"\\?\"] { + if let Some(rest) = text.strip_prefix(prefix) { + return Ok(std::path::PathBuf::from(rest)); + } + } + } + + Ok(target) +} + #[tracing::instrument( level = "debug", skip(path),