diff --git a/Cargo.lock b/Cargo.lock index 176b72b8..1ee0b3d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4746,6 +4746,7 @@ dependencies = [ "async-trait", "async_zip", "futures-lite", + "futures-util", "oneclient_common", "oneclient_events", "oneclient_net", 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 af74c911..9e52887f 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -13,6 +13,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; @@ -52,6 +53,7 @@ pub use icons::{Icon, 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 log_viewer::LogViewer; diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index aee585fc..a85a9a6f 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -9,8 +9,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_core::settings::LauncherSettings; @@ -61,6 +62,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)] @@ -232,6 +253,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 accept_tos(&self, terms_version: u32, privacy_version: u32) { if let Some(updated) = self.mutate_settings(|settings| { settings.accepted_tos_version = terms_version; @@ -473,6 +501,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:#}"), @@ -1240,6 +1291,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 @@ -1406,4 +1459,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/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 7489dcaf..3f080a6e 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -3,8 +3,8 @@ use freya::router::*; use crate::components::{ AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, - UpdatePromptOverlay, + MicrosoftJavaPromptOverlay, NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, + Toasts, UpdatePromptOverlay, }; use crate::hooks::{SplashState, use_provide_splash}; use crate::layout::HomeArtPrefetch; @@ -69,6 +69,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 cb3c2d34..997db380 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -12,6 +12,7 @@ mod launcher; pub mod state; mod transfer; mod layout; +pub mod microsoft_java; mod notifications; pub mod platform; pub mod recovery; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index fffbaf56..46a38921 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -8,8 +8,8 @@ use freya::prelude::*; use freya::radio::use_init_radio_station; use oneclient_app::state::{AppChannel, AppState}; use oneclient_app::{ - Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, - use_provide_actions, use_provide_link_confirm, + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, microsoft_java, + router, theme, use_provide_actions, use_provide_link_confirm, }; use tokio::runtime::Builder; @@ -40,7 +40,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), } }); diff --git a/packages/oneclient_app/src/microsoft_java.rs b/packages/oneclient_app/src/microsoft_java.rs new file mode 100644 index 00000000..9e8d7c9b --- /dev/null +++ b/packages/oneclient_app/src/microsoft_java.rs @@ -0,0 +1,268 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use freya::prelude::spawn_forever; +use oneclient_common::Patch; +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() { + spawn_forever(auto_install()); +} + +async fn auto_install() { + let Ok(state) = launcher::state() else { + return; + }; + + if opted_out(&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); +} + +/// Asked before the game starts the install that may follow lands on the next +/// launch not this one +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; + } + + if !publishes(&state, pinned.major).await || !claim_ask(cluster_id) { + return; + } + + match actions.events().ask(offer_prompt()).await { + Ok(Some(chosen)) => match chosen.value { + MicrosoftJavaAnswer::Install => install_and_unpin(cluster_id, pinned.major), + 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() -> Prompt { + Prompt::new( + "Microsoft Java runtime", + "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? \ + Minecraft will launch with Microsoft OpenJDK next time.", + ) + .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) { + spawn_forever(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"); + + // 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(_) => { + crate::hooks::invalidate_profile_queries().await; + events + .notify("Java switched") + .body(format!( + "This cluster is back on Automatic and picks Microsoft {major} from its \ + next launch." + )) + .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} was installed but the cluster still points at its old \ + runtime: {err}" + )) + .error() + .send(), + } + }); +} + +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_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 e3f929ea..1234f378 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/lib.rs b/packages/oneclient_core/src/lib.rs index 2cc161d2..67a18373 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -37,7 +37,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/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 702ad598..854af603 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -35,6 +35,7 @@ pub struct LauncherSettings { pub dynamic_background_enabled: bool, pub view_states: BTreeMap, pub seen_onboarding: bool, + pub skip_microsoft_java: bool, pub accepted_tos_version: u32, pub accepted_privacy_version: u32, pub seen_versions: Vec, @@ -70,6 +71,7 @@ impl Default for LauncherSettings { dynamic_background_enabled: true, view_states: BTreeMap::new(), seen_onboarding: false, + skip_microsoft_java: false, accepted_tos_version: 0, accepted_privacy_version: 0, seen_versions: Vec::new(), 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 37ba938c..697d9551 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?) @@ -169,14 +197,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 @@ -207,6 +228,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, @@ -372,6 +415,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() @@ -417,4 +479,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;