feat: added microsoft jdk and new shared mods folder layout (#812)
* feat: added microsoft sdk provider * feat: changed the default provider to microsoft everywhere * feat: added checking for microsoft jdks and prompting user to install them (ui for discussion) * feat: added toast popup when user tries to remove runtime that is pinned to some cluster * feat: added 'migrating' user to the microsoft jdks * feat: changed reading mods from cluster folders on launch to having cluster folders symlinks in the shared .minecraft folder where mods stay per given cluster; changed resource packs and shader packs from working per cluster to global, every resource pack and shader pack is now being shared between clusters; added mods disabling whenever a mod is manually removed from the mods folder * fix/feat: fixed hardlinks counting as a duplicate of files it was pointing to (windows); added a progress bar to the disk storage view in settings * chore: connected microsoft jdk branch and package folder management one --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
parent
a6c03c6f6d
commit
ba74bedf92
51 changed files with 3013 additions and 265 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
189
packages/oneclient_app/src/components/microsoft_java_prompt.rs
Normal file
189
packages/oneclient_app/src/components/microsoft_java_prompt.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Option<LoginProgress>> = None;
|
||||
let mut storage_scan: Option<Option<StorageScanProgress>> = 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);
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,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;
|
||||
|
|
@ -124,6 +124,13 @@ pub fn use_installs_snapshot() -> InstallState {
|
|||
use_radio(AppChannel::Installs).read().installs.clone()
|
||||
}
|
||||
|
||||
pub fn use_storage_scan_progress() -> Option<StorageScanProgress> {
|
||||
use_radio(AppChannel::StorageScan)
|
||||
.read()
|
||||
.storage_scan
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn use_pending_launch() -> Option<String> {
|
||||
use_radio(AppChannel::PendingLaunch)
|
||||
.read()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod launcher;
|
|||
pub mod state;
|
||||
mod transfer;
|
||||
mod layout;
|
||||
pub mod microsoft_java;
|
||||
mod motion;
|
||||
mod notifications;
|
||||
pub mod platform;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
357
packages/oneclient_app/src/microsoft_java.rs
Normal file
357
packages/oneclient_app/src/microsoft_java.rs
Normal file
|
|
@ -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<BTreeSet<ClusterId>> = 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<MicrosoftJavaAnswer> {
|
||||
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<LauncherState>,
|
||||
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<LauncherState>) -> bool {
|
||||
state.settings.read().microsoft_java_migrated
|
||||
}
|
||||
|
||||
async fn mark_migrated(state: &Arc<LauncherState>) {
|
||||
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<LauncherState>) -> 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<LauncherState>, 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<LauncherState>) -> Option<ClusterId> {
|
||||
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<LauncherState>, cluster_id: ClusterId) -> Option<JavaRuntime> {
|
||||
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()?
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<LoginProgress>,
|
||||
pub installs: InstallState,
|
||||
pub storage_scan: Option<StorageScanProgress>,
|
||||
pub relocation: RelocationState,
|
||||
pub pending_launch: Option<String>,
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
.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(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(
|
||||
label()
|
||||
.text("Measuring disk usage…")
|
||||
.font_size(16.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(header)
|
||||
.child(proportion_bar(fraction, colors::brand()))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,22 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult<PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn cluster_mods_dir(folder_name: &str) -> PathsResult<PathBuf> {
|
||||
Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name()))
|
||||
}
|
||||
|
||||
pub fn shared_mods_dir() -> PathsResult<PathBuf> {
|
||||
Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name()))
|
||||
}
|
||||
|
||||
pub fn global_content_dir(content_type: ContentType) -> PathsResult<PathBuf> {
|
||||
Ok(shared_minecraft_dir()?.join(content_type.folder_name()))
|
||||
}
|
||||
|
||||
pub fn shared_mods_link(folder_name: &str) -> PathsResult<PathBuf> {
|
||||
Ok(shared_mods_dir()?.join(folder_name))
|
||||
}
|
||||
|
||||
pub fn packages_cache_dir() -> PathsResult<PathBuf> {
|
||||
Ok(data_dir()?.join("metadata").join("packages"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<i64>> {
|
||||
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<i64> = 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<MaterializedManifest> {
|
||||
let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?;
|
||||
pub async fn load(dir: &Path, name: &str) -> Option<MaterializedManifest> {
|
||||
let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?;
|
||||
|
||||
match serde_json::from_str::<MaterializedManifest>(&raw) {
|
||||
Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest),
|
||||
|
|
@ -73,7 +75,7 @@ pub async fn load(game_dir: &Path) -> Option<MaterializedManifest> {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LauncherState>,
|
||||
cluster_id: i64,
|
||||
) -> LauncherResult<Option<u32>> {
|
||||
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<LauncherState>,
|
||||
|
|
|
|||
|
|
@ -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<SweepReport> {
|
||||
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 {
|
||||
|
|
|
|||
109
packages/oneclient_core/src/game/fabric.rs
Normal file
109
packages/oneclient_core/src/game/fabric.rs
Normal file
|
|
@ -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<Version> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -251,15 +251,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;
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +288,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,
|
||||
|
|
@ -294,6 +301,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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ pub struct LauncherSettings {
|
|||
pub animations_enabled: bool,
|
||||
pub view_states: BTreeMap<String, ViewState>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<StorageRepo
|
|||
let data = paths::data_dir()?;
|
||||
let config = paths::config_dir()?;
|
||||
|
||||
let mut categories = vec![
|
||||
entry("Packages", paths::packages_cache_dir()?).await,
|
||||
entry("Minecraft versions", paths::versions_dir()?).await,
|
||||
entry("Libraries", paths::libraries_dir()?).await,
|
||||
entry("Assets", paths::assets_dir()?).await,
|
||||
entry("Natives", paths::natives_dir()?).await,
|
||||
entry("Java runtimes", paths::java_dir()?).await,
|
||||
entry("Shared game directory", paths::shared_minecraft_dir()?).await,
|
||||
entry("Logs", paths::logs_dir()?).await,
|
||||
entry("Image cache", paths::images_cache_dir()?).await,
|
||||
entry("Bundles", paths::bundles_dir()?).await,
|
||||
let category_dirs = [
|
||||
("Packages", paths::packages_cache_dir()?),
|
||||
("Minecraft versions", paths::versions_dir()?),
|
||||
("Libraries", paths::libraries_dir()?),
|
||||
("Assets", paths::assets_dir()?),
|
||||
("Natives", paths::natives_dir()?),
|
||||
("Java runtimes", paths::java_dir()?),
|
||||
("Shared game directory", paths::shared_minecraft_dir()?),
|
||||
("Logs", paths::logs_dir()?),
|
||||
("Image cache", paths::images_cache_dir()?),
|
||||
("Bundles", paths::bundles_dir()?),
|
||||
];
|
||||
|
||||
let cluster_list = state.clusters.list().await?;
|
||||
|
||||
let mut steps = ScanSteps {
|
||||
events: &state.services.events,
|
||||
done: 0,
|
||||
total: (category_dirs.len() + cluster_list.len() + 3) as u64,
|
||||
};
|
||||
|
||||
let mut seen = SeenFiles::default();
|
||||
|
||||
let mut categories = Vec::new();
|
||||
for (label, path) in category_dirs {
|
||||
steps.begin(label);
|
||||
categories.push(entry(label, path, &mut seen).await);
|
||||
}
|
||||
categories.sort_by_key(|entry| std::cmp::Reverse(entry.bytes));
|
||||
|
||||
let mut clusters = Vec::new();
|
||||
for cluster in state.clusters.list().await? {
|
||||
for cluster in &cluster_list {
|
||||
steps.begin(&cluster.name);
|
||||
let Ok(dir) = cluster.dir() else {
|
||||
continue;
|
||||
};
|
||||
clusters.push(entry(&cluster.name, dir).await);
|
||||
clusters.push(entry(&cluster.name, dir, &mut seen).await);
|
||||
}
|
||||
clusters.sort_by_key(|entry| std::cmp::Reverse(entry.bytes));
|
||||
|
||||
steps.begin("unreferenced cache files");
|
||||
let unreferenced = find_unreferenced_files(&state.services.content()).await?;
|
||||
let unreferenced_cache = ReclaimableEntry {
|
||||
bytes: unreferenced.iter().map(|(_, size)| size).sum(),
|
||||
files: unreferenced.len(),
|
||||
};
|
||||
|
||||
steps.begin("leftover cluster content");
|
||||
let legacy = legacy_cluster_content(&cluster_list).await;
|
||||
|
||||
steps.begin("total size");
|
||||
let mut total_bytes = dir_size(data).await;
|
||||
if data != config {
|
||||
total_bytes += dir_size(paths::logs_dir()?).await;
|
||||
|
|
@ -98,11 +123,36 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult<StorageRepo
|
|||
categories,
|
||||
clusters,
|
||||
unreferenced_cache,
|
||||
legacy_cluster_content: legacy_cluster_content(state).await?,
|
||||
legacy_cluster_content: legacy,
|
||||
unused_natives: unused_natives(state).await,
|
||||
})
|
||||
}
|
||||
|
||||
struct ScanSteps<'a> {
|
||||
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<HashSet<String>> {
|
||||
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<ReclaimableEntry> {
|
||||
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<Reclaim
|
|||
continue;
|
||||
};
|
||||
|
||||
let mods_live_here = manifest::mods_live_in_cluster(&root).await;
|
||||
|
||||
for content_type in LEGACY_TYPES {
|
||||
if content_type == ContentType::Mod && mods_live_here {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir = root.join(content_type.folder_name());
|
||||
let Ok(mut entries) = polyio::read_dir(&dir).await else {
|
||||
continue;
|
||||
|
|
@ -231,7 +286,7 @@ async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult<Reclaim
|
|||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
found
|
||||
}
|
||||
|
||||
fn is_content_file(content_type: ContentType, name: &str) -> 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<Path>) -> u64 {
|
||||
dir_size_seen(root, &mut SeenFiles::default()).await
|
||||
}
|
||||
|
||||
async fn dir_size_seen(root: impl AsRef<Path>, 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<Path>) -> 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<Path>) -> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<GlobalArtifactRow>, 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<u64, sqlx::Error> {
|
||||
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<Vec<ArtifactRow>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ArtifactRow>(
|
||||
r#"
|
||||
|
|
|
|||
|
|
@ -77,3 +77,11 @@ pub struct LinkedArtifactRow {
|
|||
pub display_version: Option<String>,
|
||||
pub published_at: Option<String>,
|
||||
}
|
||||
|
||||
// used for resourcepacks and shaders
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct GlobalArtifactRow {
|
||||
pub hash: String,
|
||||
pub file_name: String,
|
||||
pub enabled: i64,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <zulu|adoptium|corretto|liberica> [major]");
|
||||
eprintln!("usage: install_provider <zulu|adoptium|corretto|liberica|microsoft> [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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
) -> JavaResult<bool> {
|
||||
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<Option<JavaPackage>> {
|
||||
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<JavaRuntime> {
|
||||
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<Option<JavaRuntime>> {
|
||||
let preferred = vendors::default_vendor();
|
||||
|
||||
let mut candidates: Vec<JavaRuntime> = 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<u32>) {
|
||||
(
|
||||
&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<u32> {
|
||||
version
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter_map(|part| part.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_for_vendor(vendor: &JavaVendor) -> Option<Box<dyn vendors::JavaRuntimeProvider>> {
|
||||
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<JavaRuntime>, 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
286
packages/oneclient_java/src/vendors/microsoft.rs
vendored
Normal file
286
packages/oneclient_java/src/vendors/microsoft.rs
vendored
Normal file
|
|
@ -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<u32>,
|
||||
net: &RequestClient,
|
||||
) -> JavaResult<Vec<JavaPackage>> {
|
||||
let Some(arch) = MICROSOFT_ARCH else {
|
||||
tracing::debug!(
|
||||
"Microsoft publishes no build for this architecture; listing nothing"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let wanted: Vec<u32> = 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<JavaPackage> = futures_util::stream::iter(
|
||||
wanted
|
||||
.into_iter()
|
||||
.map(|major| async move { resolve_package(major, arch, net).await }),
|
||||
)
|
||||
.buffer_unordered(MAJOR_LOOKUP_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<JavaResult<Vec<Option<JavaPackage>>>>()?
|
||||
.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<Option<JavaPackage>> {
|
||||
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<u32>,
|
||||
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<MicrosoftBuild> {
|
||||
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<Vec<u32>> {
|
||||
let (version, _) = filename
|
||||
.strip_prefix("microsoft-jdk-")?
|
||||
.split_once('-')?;
|
||||
|
||||
let parts: Vec<u32> = version
|
||||
.split('.')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.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<Url> {
|
||||
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("<!doctype html><html lang=\"en\">", 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/oneclient_java/src/vendors/mod.rs
vendored
12
packages/oneclient_java/src/vendors/mod.rs
vendored
|
|
@ -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<Box<dyn JavaRuntimeProvider>> {
|
||||
vec![
|
||||
Box::new(MicrosoftRuntimeProvider),
|
||||
Box::new(ZuluRuntimeProvider),
|
||||
Box::new(AdoptiumRuntimeProvider),
|
||||
Box::new(CorrettoRuntimeProvider),
|
||||
|
|
@ -27,6 +30,15 @@ pub fn runtime_providers() -> Vec<Box<dyn JavaRuntimeProvider>> {
|
|||
]
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -46,4 +46,5 @@ sha2.workspace = true
|
|||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
junction.workspace = true
|
||||
windows-sys.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -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<std::path::Path>) -> 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<std::path::Path>) -> 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<std::path::Path>) -> PolyIOResult<std::path::PathBuf> {
|
||||
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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue