feat: auto-repair as well as verify cluster button in cluster settings
This commit is contained in:
parent
4c6de0d2c4
commit
394146d552
35 changed files with 3167 additions and 113 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5109,6 +5109,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.10.6",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
|
|
|||
198
packages/oneclient_app/src/components/generic_prompt.rs
Normal file
198
packages/oneclient_app/src/components/generic_prompt.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! The fallback renderer for prompts nothing else claims.
|
||||
//!
|
||||
//! [`Prompt`](oneclient_events::Prompt) lets any part of the core ask the user a
|
||||
//! question, and two overlays render particular ones richly: the Java one and
|
||||
//! the updater's. Everything else used to render as nothing at all — the core
|
||||
//! would sit on `ask()` forever waiting for a reply no one could give, and
|
||||
//! because the app holds a single pending prompt at a time, the stuck one also
|
||||
//! kept every later prompt (the Java one included) from appearing.
|
||||
//!
|
||||
//! So this is a safety net, not a style: anything not claimed above is rendered
|
||||
//! from the prompt's own choices, and a new prompt in the core needs no UI work
|
||||
//! to be answerable.
|
||||
|
||||
use freya::prelude::*;
|
||||
use oneclient_events::{Answer, ChoiceInput, ChoiceStyle};
|
||||
use oneclient_java::JAVA_CHOICE_DOWNLOAD;
|
||||
|
||||
use crate::components::{Button, OverlayPopup};
|
||||
use crate::hooks::{use_dispatch, use_notifications_snapshot};
|
||||
use crate::notifications::PendingPromptView;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::updater::UPDATE_CHOICE_INSTALL;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
|
||||
/// Prompts with an overlay of their own, recognised the same way those overlays
|
||||
/// recognise themselves: by the choice they offer.
|
||||
fn claimed_elsewhere(prompt: &PendingPromptView) -> bool {
|
||||
prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL)
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct GenericPromptOverlay;
|
||||
|
||||
impl Component for GenericPromptOverlay {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
// Hooks run unconditionally, before any early return, so the hook count
|
||||
// stays stable whether or not a prompt is pending.
|
||||
let snapshot = use_notifications_snapshot();
|
||||
let dispatch = use_dispatch();
|
||||
|
||||
let Some(prompt) = snapshot.pending_prompt.clone() else {
|
||||
return rect().into_element();
|
||||
};
|
||||
|
||||
if claimed_elsewhere(&prompt) {
|
||||
return rect().into_element();
|
||||
}
|
||||
|
||||
let close = dispatch.clone();
|
||||
|
||||
let mut buttons = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.main_align(Alignment::End)
|
||||
.spacing(8.);
|
||||
|
||||
if let Some(dismiss) = prompt.dismiss.clone() {
|
||||
let cancel = dispatch.clone();
|
||||
buttons = buttons.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.text(dismiss)
|
||||
.on_press(move |_| cancel.dismiss_prompt()),
|
||||
);
|
||||
}
|
||||
|
||||
for choice in &prompt.choices {
|
||||
let dispatch = dispatch.clone();
|
||||
let id = choice.id;
|
||||
let input = choice.input.clone();
|
||||
|
||||
let button = Button::new().text(choice.label.clone());
|
||||
let button = match choice.style {
|
||||
ChoiceStyle::Primary => button.primary(),
|
||||
ChoiceStyle::Danger => button.danger(),
|
||||
ChoiceStyle::Secondary => button.secondary(),
|
||||
};
|
||||
|
||||
buttons = buttons.child(button.on_press(move |_| match &input {
|
||||
// A folder choice cannot be answered until the user has picked
|
||||
// one, so the answer is sent from the dialog's callback rather
|
||||
// than from the press.
|
||||
Some(ChoiceInput::Folder { title }) => {
|
||||
let dispatch = dispatch.clone();
|
||||
let title = title.clone();
|
||||
spawn(async move {
|
||||
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||
.set_title(title)
|
||||
.pick_folder()
|
||||
.await
|
||||
{
|
||||
dispatch.answer_prompt(
|
||||
Answer::new(id).with_folder(handle.path()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
// A selection needs a picker only the prompt's own overlay could
|
||||
// supply. Answering without one is still better than leaving the
|
||||
// core waiting forever; the caller sees `selection() == None`.
|
||||
Some(ChoiceInput::Selection { hint }) => {
|
||||
tracing::warn!(
|
||||
choice = id,
|
||||
hint,
|
||||
"a prompt asked for a selection with no overlay to collect it"
|
||||
);
|
||||
dispatch.answer_prompt(Answer::new(id));
|
||||
}
|
||||
None => dispatch.answer_prompt(Answer::new(id)),
|
||||
}));
|
||||
}
|
||||
|
||||
OverlayPopup::new()
|
||||
.on_close(move |_| 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(
|
||||
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(buttons),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oneclient_events::Choice;
|
||||
|
||||
fn prompt(choices: Vec<Choice>) -> PendingPromptView {
|
||||
PendingPromptView {
|
||||
title: "Title".to_string(),
|
||||
question: "Question".to_string(),
|
||||
choices,
|
||||
dismiss: Some("Cancel".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_rich_overlays_keep_their_own_prompts() {
|
||||
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
|
||||
JAVA_CHOICE_DOWNLOAD,
|
||||
"Download",
|
||||
)])));
|
||||
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
|
||||
UPDATE_CHOICE_INSTALL,
|
||||
"Download",
|
||||
)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_other_prompt_falls_through_to_the_generic_overlay() {
|
||||
// The bug this overlay exists for: a prompt whose choices no overlay
|
||||
// recognised rendered as nothing, the core waited on `ask()` forever,
|
||||
// and the stuck prompt blocked every later one. Any new prompt in the
|
||||
// core has to land here without UI work.
|
||||
for choices in [
|
||||
vec![Choice::new("launch", "Launch anyway")],
|
||||
vec![Choice::primary("verify", "Verify and repair")],
|
||||
vec![Choice::danger("delete", "Delete")],
|
||||
Vec::new(),
|
||||
] {
|
||||
assert!(
|
||||
!claimed_elsewhere(&prompt(choices)),
|
||||
"an unclaimed prompt must render somewhere"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ mod dynamic_art;
|
|||
mod file_drop;
|
||||
mod icons;
|
||||
mod java_install_manager;
|
||||
mod generic_prompt;
|
||||
mod java_prompt;
|
||||
mod link_confirm;
|
||||
mod local_image;
|
||||
|
|
@ -49,6 +50,7 @@ pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
|
|||
pub use file_drop::FileDropOverlay;
|
||||
pub use icons::{Icon, IconType};
|
||||
pub use java_install_manager::JavaInstallManager;
|
||||
pub use generic_prompt::GenericPromptOverlay;
|
||||
pub use java_prompt::JavaPromptOverlay;
|
||||
pub use link_confirm::ConfirmLinkOverlay;
|
||||
pub use local_image::LocalImage;
|
||||
|
|
|
|||
|
|
@ -1333,6 +1333,63 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
|||
.await;
|
||||
|
||||
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
|
||||
// A launch that failed because a file simply is not there is the one
|
||||
// failure the launcher can fix by itself. Showing the user a path inside
|
||||
// our own metadata folder and stopping leaves them with nothing to act
|
||||
// on, so repair first and only report if that does not help.
|
||||
if err.indicates_missing_files() {
|
||||
repair_and_relaunch(&state, cluster_id, &account, err).await;
|
||||
return;
|
||||
}
|
||||
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a verify-and-repair pass, then tries the launch once more.
|
||||
///
|
||||
/// Only ever one retry: if the game still cannot start after every file has
|
||||
/// been hashed and the broken ones re-downloaded, the problem is not missing
|
||||
/// files, and trying again would just spend another few minutes to show the
|
||||
/// same error.
|
||||
async fn repair_and_relaunch(
|
||||
state: &std::sync::Arc<oneclient_core::LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
account: &oneclient_auth::MinecraftAccount,
|
||||
original: oneclient_core::LauncherError,
|
||||
) {
|
||||
let events = state.services.events.clone();
|
||||
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"launch failed on a missing file; repairing: {original:#}"
|
||||
);
|
||||
events
|
||||
.notify("Repairing installation")
|
||||
.body("Some game files are missing. Checking and re-downloading them.")
|
||||
.send();
|
||||
|
||||
let report = match oneclient_core::verify_cluster_files(state, cluster_id).await {
|
||||
Ok(report) => report,
|
||||
Err(repair_err) => {
|
||||
tracing::error!(cluster_id, "repair failed: {repair_err:#}");
|
||||
// The original failure is what the user was trying to do, so lead
|
||||
// with it; the repair failure explains why it was not fixed.
|
||||
events.game_failed(
|
||||
cluster_id,
|
||||
format!("{original:#} (repair also failed: {repair_err:#})"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
events
|
||||
.notify("Repair complete")
|
||||
.body(report.summary())
|
||||
.send();
|
||||
|
||||
if let Err(err) = oneclient_core::launch_cluster(state, cluster_id, account, true).await {
|
||||
tracing::error!(cluster_id, "launch failed again after repair: {err:#}");
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ pub enum ClusterAction {
|
|||
cluster_id: ClusterId,
|
||||
dedicated: bool,
|
||||
},
|
||||
/// Rehash every installed file and re-download whatever no longer matches.
|
||||
VerifyFiles {
|
||||
cluster_id: ClusterId,
|
||||
},
|
||||
}
|
||||
|
||||
impl MutationCapability for ClusterMutation {
|
||||
|
|
@ -155,6 +159,26 @@ impl MutationCapability for ClusterMutation {
|
|||
reason: err.to_string(),
|
||||
})
|
||||
}
|
||||
ClusterAction::VerifyFiles { cluster_id } => {
|
||||
// Reports its own outcome rather than going through the generic
|
||||
// failure toast: a verify that finds nothing wrong is a useful
|
||||
// result the user asked for, not a silent no-op.
|
||||
match oneclient_core::verify_cluster_files(&state, *cluster_id).await {
|
||||
Ok(report) => {
|
||||
let notify = services.events.notify("Verification complete");
|
||||
let notify = notify.body(report.summary());
|
||||
if report.unrepairable.is_empty() {
|
||||
notify.send();
|
||||
} else {
|
||||
notify.error().send();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(oneclient_content::ContentError::InvalidData {
|
||||
reason: err.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
};
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ use freya::prelude::*;
|
|||
use freya::router::*;
|
||||
|
||||
use crate::components::{
|
||||
AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, PackageUpdatePopup,
|
||||
SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay,
|
||||
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay,
|
||||
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
|
||||
UpdatePromptOverlay,
|
||||
};
|
||||
use crate::hooks::{SplashState, use_provide_splash};
|
||||
use crate::layout::HomeArtPrefetch;
|
||||
|
|
@ -71,6 +72,9 @@ impl Component for RootLayout {
|
|||
.child(Toasts)
|
||||
.child(UpdatePromptOverlay)
|
||||
.child(JavaPromptOverlay)
|
||||
// Last of the prompt overlays: it renders whatever the two above
|
||||
// did not claim, so no prompt can reach the user as silence.
|
||||
.child(GenericPromptOverlay)
|
||||
.child(ClusterUpdatePopup)
|
||||
.child(PackageUpdatePopup)
|
||||
.child(StatusBar)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ use crate::components::{
|
|||
validate_number,
|
||||
};
|
||||
use crate::hooks::{
|
||||
ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation,
|
||||
use_dispatch, use_game_profile, use_java_runtimes, use_loader_versions, use_settings_snapshot,
|
||||
ClusterAction, java_runtimes, loader_versions, mutation_is_running, try_game_profile,
|
||||
use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes, use_loader_versions,
|
||||
use_settings_snapshot,
|
||||
};
|
||||
use crate::layout::cluster_content;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -127,7 +128,9 @@ impl Component for ClusterSettings {
|
|||
.child(section_header("PROCESS"))
|
||||
.child(text_row(cluster_id, TextField::Pre, &profile, &global))
|
||||
.child(text_row(cluster_id, TextField::Wrapper, &profile, &global))
|
||||
.child(text_row(cluster_id, TextField::Post, &profile, &global)),
|
||||
.child(text_row(cluster_id, TextField::Post, &profile, &global))
|
||||
.child(section_header("REPAIR"))
|
||||
.child(VerifyFilesRow { cluster_id }.into_element()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
|
@ -241,6 +244,41 @@ impl Component for ToggleRow {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct VerifyFilesRow {
|
||||
cluster_id: i64,
|
||||
}
|
||||
|
||||
impl Component for VerifyFilesRow {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let cluster_id = self.cluster_id;
|
||||
let mutation = use_cluster_mutation();
|
||||
let running = mutation_is_running(&mutation);
|
||||
|
||||
let on_press = move |_| {
|
||||
mutation.mutate(ClusterAction::VerifyFiles { cluster_id });
|
||||
};
|
||||
|
||||
// The pass reads every installed file, so it is worth saying that it
|
||||
// takes a while — otherwise a button that sits there for a minute reads
|
||||
// as broken rather than busy.
|
||||
let button = Button::new()
|
||||
.small()
|
||||
.secondary()
|
||||
.enabled(!running)
|
||||
.maybe(!running, |el| el.on_press(on_press))
|
||||
.text(if running { "Verifying..." } else { "Verify Files" });
|
||||
|
||||
settings_row(
|
||||
IconType::ClipboardCheck,
|
||||
"Verify Files",
|
||||
"Check every installed file against its official checksum and \
|
||||
re-download anything corrupt or missing. Takes a few minutes.",
|
||||
button,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct DedicatedDirRow {
|
||||
cluster_id: i64,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use freya::router::RouterContext;
|
|||
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
|
||||
|
||||
use oneclient_auth::preview_samples;
|
||||
use oneclient_core::simulate::Damage;
|
||||
use oneclient_net::status::{self, ServiceStatus};
|
||||
|
||||
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
|
||||
|
|
@ -87,6 +88,11 @@ impl Component for Debug {
|
|||
vec![AuthGuidancePreview.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Corruption Simulator",
|
||||
vec![CorruptionSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section("SQL Console", vec![SqlConsole.into_element()]))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
|
|
@ -655,6 +661,297 @@ impl Component for AuthGuidancePreview {
|
|||
}
|
||||
}
|
||||
|
||||
/// Breaks an installation on purpose, so the repair paths can be driven without
|
||||
/// waiting for a genuinely bad connection.
|
||||
///
|
||||
/// The presets are grouped by *which layer is supposed to catch them*, because
|
||||
/// that is the thing worth testing: same-length corruption is invisible to the
|
||||
/// launch-time size check and only the hashing pass finds it, truncation is
|
||||
/// caught cheaply on the next launch, and a deleted assets tree is the case that
|
||||
/// used to surface as a raw "No such file or directory".
|
||||
#[derive(PartialEq)]
|
||||
struct CorruptionSimulator;
|
||||
|
||||
/// `(label, icon, what it does, which check is meant to catch it)`
|
||||
type DamagePreset = (&'static str, IconType, DamageKind, &'static str);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DamageKind {
|
||||
Assets(usize, Damage),
|
||||
Libraries(usize, Damage),
|
||||
Content(usize, Damage),
|
||||
DeleteAssetsTree,
|
||||
}
|
||||
|
||||
const DAMAGE_PRESETS: [DamagePreset; 6] = [
|
||||
(
|
||||
"Corrupt 25 assets",
|
||||
IconType::AlertTriangle,
|
||||
DamageKind::Assets(25, Damage::Corrupt),
|
||||
"Survives launch; only Verify Files finds it",
|
||||
),
|
||||
(
|
||||
"Truncate 25 assets",
|
||||
IconType::FileX02,
|
||||
DamageKind::Assets(25, Damage::Truncate),
|
||||
"Caught at launch by the size check",
|
||||
),
|
||||
(
|
||||
"Delete 25 assets",
|
||||
IconType::Trash01,
|
||||
DamageKind::Assets(25, Damage::Delete),
|
||||
"Caught at launch, re-downloaded",
|
||||
),
|
||||
(
|
||||
"Corrupt 5 libraries",
|
||||
IconType::AlertCircle,
|
||||
DamageKind::Libraries(5, Damage::Corrupt),
|
||||
"Crashes with ZipException, then offers repair",
|
||||
),
|
||||
(
|
||||
"Corrupt 5 mods",
|
||||
IconType::File02,
|
||||
DamageKind::Content(5, Damage::Corrupt),
|
||||
"Repaired from the provider, not Mojang",
|
||||
),
|
||||
(
|
||||
"Delete the assets tree",
|
||||
IconType::Folder,
|
||||
DamageKind::DeleteAssetsTree,
|
||||
"The reported bug; auto-repairs on launch",
|
||||
),
|
||||
];
|
||||
|
||||
impl Component for CorruptionSimulator {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let dispatch = use_dispatch();
|
||||
let cluster_id = use_state(|| "1".to_string());
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for chunk in DAMAGE_PRESETS.chunks(3) {
|
||||
let mut row = rect().horizontal().width(Size::fill()).spacing(8.);
|
||||
|
||||
for (text, icon, kind, hint) in chunk {
|
||||
let dispatch = dispatch.clone();
|
||||
let kind = *kind;
|
||||
let hint = *hint;
|
||||
|
||||
row = row.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.spacing(2.)
|
||||
.child(
|
||||
Button::new()
|
||||
.danger()
|
||||
.width(Size::fill())
|
||||
.child(Icon::new(*icon).size(16.))
|
||||
.text(*text)
|
||||
.on_press(move |_| {
|
||||
let target =
|
||||
cluster_id.read().trim().parse::<i64>().unwrap_or(1);
|
||||
run_damage(&dispatch, kind, target);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(hint)
|
||||
.font_size(11.)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(row.into_element());
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
label()
|
||||
.text("Cluster (for mods)")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::px(120.))
|
||||
.child(TextInput::new(cluster_id).placeholder("Cluster ID")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(8.)
|
||||
.children(rows),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("Modals: the real prompts, built by the code that raises them for real.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.)
|
||||
.child(prompt_button(
|
||||
"Failed assets prompt",
|
||||
IconType::AlertTriangle,
|
||||
3,
|
||||
0,
|
||||
))
|
||||
.child(prompt_button(
|
||||
"Failed libraries prompt",
|
||||
IconType::AlertCircle,
|
||||
0,
|
||||
2,
|
||||
))
|
||||
.child(prompt_button("Mixed failures prompt", IconType::File02, 4, 1)),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.)
|
||||
.child(crash_repair_button(
|
||||
"Crash repair prompt (named jar)",
|
||||
Some("fabric-loader-0.15.11.jar"),
|
||||
cluster_id,
|
||||
))
|
||||
.child(crash_repair_button(
|
||||
"Crash repair prompt (no jar named)",
|
||||
None,
|
||||
cluster_id,
|
||||
)),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Raises the genuine "some files could not be downloaded" prompt.
|
||||
///
|
||||
/// Calls the same function the download path calls, rather than rebuilding the
|
||||
/// copy here — a simulated modal that has drifted from the real one is worse
|
||||
/// than no simulator at all.
|
||||
fn prompt_button(
|
||||
text: &'static str,
|
||||
icon: IconType,
|
||||
failed_assets: usize,
|
||||
failed_libraries: usize,
|
||||
) -> impl IntoElement {
|
||||
Button::new()
|
||||
.secondary()
|
||||
.child(Icon::new(icon).size(16.))
|
||||
.text(text)
|
||||
.on_press(move |_| {
|
||||
spawn(async move {
|
||||
let Ok(state) = crate::launcher::state() else {
|
||||
return;
|
||||
};
|
||||
let answer = oneclient_core::game::confirm_incomplete_install(
|
||||
&state.services.mc(),
|
||||
failed_assets,
|
||||
failed_libraries,
|
||||
)
|
||||
.await;
|
||||
tracing::info!(?answer, "incomplete install prompt answered");
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Raises the post-crash repair offer, as if the game had just died on a
|
||||
/// `java.util.zip.ZipException`.
|
||||
///
|
||||
/// Accepting really does run the verify pass, so this exercises the whole path
|
||||
/// and not just the copy. To test the detection itself rather than the offer,
|
||||
/// corrupt some libraries above and launch the game — the JVM will raise the
|
||||
/// real exception and the log watcher will read it out of the output.
|
||||
fn crash_repair_button(
|
||||
text: &'static str,
|
||||
jar: Option<&'static str>,
|
||||
cluster_id: State<String>,
|
||||
) -> impl IntoElement {
|
||||
Button::new()
|
||||
.secondary()
|
||||
.child(Icon::new(IconType::AlertCircle).size(16.))
|
||||
.text(text)
|
||||
.on_press(move |_| {
|
||||
let target = cluster_id.read().trim().parse::<i64>().unwrap_or(1);
|
||||
spawn(async move {
|
||||
let Ok(state) = crate::launcher::state() else {
|
||||
return;
|
||||
};
|
||||
let diagnosis = oneclient_core::game::CrashDiagnosis::CorruptArchive {
|
||||
file: jar.map(str::to_string),
|
||||
};
|
||||
oneclient_core::game::offer_repair(&state, target, &diagnosis).await;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) {
|
||||
let dispatch = dispatch.clone();
|
||||
spawn(async move {
|
||||
let result = match kind {
|
||||
DamageKind::Assets(count, damage) => oneclient_core::simulate::damage_assets(count, damage)
|
||||
.await
|
||||
.map(|report| (report, damage.verb())),
|
||||
DamageKind::Libraries(count, damage) => {
|
||||
oneclient_core::simulate::damage_libraries(count, damage)
|
||||
.await
|
||||
.map(|report| (report, damage.verb()))
|
||||
}
|
||||
DamageKind::Content(count, damage) => match crate::launcher::state() {
|
||||
Ok(state) => oneclient_core::simulate::damage_cluster_content(
|
||||
&state, cluster_id, count, damage,
|
||||
)
|
||||
.await
|
||||
.map(|report| (report, damage.verb())),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
DamageKind::DeleteAssetsTree => oneclient_core::simulate::delete_assets_tree()
|
||||
.await
|
||||
.map(|report| (report, "Deleted")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((report, verb)) => {
|
||||
dispatch
|
||||
.notify("Damage simulated")
|
||||
.body(report.summary(verb))
|
||||
.icon(IconType::AlertTriangle)
|
||||
.error()
|
||||
.send();
|
||||
}
|
||||
Err(err) => {
|
||||
dispatch
|
||||
.notify("Simulation failed")
|
||||
.body(err.to_string())
|
||||
.error()
|
||||
.send();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SqlConsole;
|
||||
|
||||
|
|
|
|||
|
|
@ -295,12 +295,13 @@ async fn download_bundle_if_needed(
|
|||
}
|
||||
|
||||
tracing::debug!("downloading bundle from remote: {url}");
|
||||
let expected = polyio::Checksum::sha1(expected_sha1);
|
||||
oneclient_net::download_verified(
|
||||
&ctx.net,
|
||||
&ctx.events,
|
||||
url,
|
||||
disk_path,
|
||||
Some(expected_sha1),
|
||||
Some(&expected),
|
||||
0,
|
||||
None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,14 +22,16 @@ pub async fn ensure_artifact_file(
|
|||
}
|
||||
|
||||
tracing::debug!("downloading artifact file");
|
||||
// Modrinth and CurseForge both index by SHA-1.
|
||||
let expected = polyio::Checksum::sha1(hash);
|
||||
oneclient_net::download_verified(
|
||||
&ctx.net,
|
||||
&ctx.events,
|
||||
url,
|
||||
dest,
|
||||
Some(hash),
|
||||
Some(&expected),
|
||||
0,
|
||||
child.cloned(),
|
||||
child.cloned().map(oneclient_net::ResponseNotifyOptions::grouped),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! is why they stay here.
|
||||
|
||||
mod migrate;
|
||||
mod prepare;
|
||||
pub(crate) mod prepare;
|
||||
mod provision;
|
||||
mod unlink_legacy;
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,39 @@ impl LauncherError {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this failure reads as "the install is missing pieces", and so is
|
||||
/// worth repairing rather than just reporting.
|
||||
///
|
||||
/// Something like
|
||||
/// `An error occurred whilst accessing path '.../metadata/assets': No such
|
||||
/// file or directory (os error 2)` is a dead end as a message: it names a
|
||||
/// path the user did not create and cannot fix, when the actual answer is
|
||||
/// that a file the install needs is not there and should be downloaded
|
||||
/// again.
|
||||
///
|
||||
/// Deliberately narrow. Only a genuine not-found reaches here: a permission
|
||||
/// error, a full disk, or an unreachable network share would all survive a
|
||||
/// repair unchanged, and re-downloading the game to rediscover that would
|
||||
/// waste a lot of the user's time.
|
||||
#[must_use]
|
||||
pub fn indicates_missing_files(&self) -> bool {
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
|
||||
// Walk the chain rather than matching the top variant: by the time this
|
||||
// surfaces it has usually been wrapped two or three layers deep, and the
|
||||
// `io::Error` that actually carries the kind is at the bottom.
|
||||
while let Some(err) = source {
|
||||
if let Some(io) = err.downcast_ref::<std::io::Error>()
|
||||
&& io.kind() == std::io::ErrorKind::NotFound
|
||||
{
|
||||
return true;
|
||||
}
|
||||
source = err.source();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SentryExclusion {
|
||||
|
|
@ -156,3 +189,65 @@ impl SentryExclusion for polyio::IOError {
|
|||
}
|
||||
|
||||
pub type LauncherResult<T> = Result<T, LauncherError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The OS-level "no such file or directory" the filesystem actually
|
||||
/// returns, rather than a synthesized `ErrorKind`: they map to the same
|
||||
/// kind, but only this one renders the `(os error 2)` text users report.
|
||||
fn not_found(path: &str) -> LauncherError {
|
||||
LauncherError::IoError(polyio::IOError::PathIOError {
|
||||
source: std::io::Error::from_raw_os_error(2),
|
||||
path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_asset_directory_asks_for_a_repair() {
|
||||
// The exact shape users report: a path deep in the launcher's own
|
||||
// metadata that they never created and cannot fix by hand.
|
||||
let path =
|
||||
"/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets";
|
||||
let err = not_found(path);
|
||||
|
||||
assert!(err.indicates_missing_files(), "{err}");
|
||||
assert!(err.to_string().contains(path), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_not_found_nested_several_layers_deep_is_still_found() {
|
||||
// By the time this surfaces it has usually been wrapped on its way up,
|
||||
// so matching only the outermost variant would miss it.
|
||||
let err = LauncherError::McError(oneclient_mc::McError::Io(polyio::IOError::PathIOError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::NotFound),
|
||||
path: "metadata/libraries/foo.jar".to_string(),
|
||||
}));
|
||||
|
||||
assert!(err.indicates_missing_files(), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_a_repair_could_not_fix_are_left_alone() {
|
||||
// Re-downloading the entire game to rediscover that the disk is full,
|
||||
// or that the folder is not writable, would waste a lot of the user's
|
||||
// time and end at the same error.
|
||||
for kind in [
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
std::io::ErrorKind::TimedOut,
|
||||
] {
|
||||
let err = LauncherError::IoError(polyio::IOError::PathIOError {
|
||||
source: std::io::Error::from(kind),
|
||||
path: "metadata/assets".to_string(),
|
||||
});
|
||||
assert!(!err.indicates_missing_files(), "{kind:?} should not repair");
|
||||
}
|
||||
|
||||
assert!(!LauncherError::NotInitialized.indicates_missing_files());
|
||||
assert!(
|
||||
!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
222
packages/oneclient_core/src/game/diagnosis.rs
Normal file
222
packages/oneclient_core/src/game/diagnosis.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Reading a crash out of the game's own output.
|
||||
//!
|
||||
//! Minecraft's exit code says only that it died. The log says why, and some of
|
||||
//! the reasons are ones the launcher can fix — so it is worth reading rather
|
||||
//! than handing the user a stack trace and an exit status.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A crash cause the launcher recognises and can act on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CrashDiagnosis {
|
||||
/// The JVM could not read a jar: truncated, wrong bytes, or not a zip at
|
||||
/// all. On the classpath this is nearly always a damaged library or mod,
|
||||
/// which is exactly what verification repairs.
|
||||
CorruptArchive {
|
||||
/// The jar named in the message, when the JVM included one. Several of
|
||||
/// these messages carry no path at all, so this is a bonus rather than
|
||||
/// something to depend on.
|
||||
file: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CrashDiagnosis {
|
||||
/// What to tell the user, phrased as the problem rather than the exception.
|
||||
#[must_use]
|
||||
pub fn body(&self) -> String {
|
||||
match self {
|
||||
Self::CorruptArchive { file: Some(file) } => format!(
|
||||
"The game could not read {file} — the file is damaged. \
|
||||
Verifying will re-download anything that does not match."
|
||||
),
|
||||
Self::CorruptArchive { file: None } => "The game could not read one of its \
|
||||
library or mod files — it is damaged. Verifying will re-download \
|
||||
anything that does not match."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signatures that all mean "a jar could not be read".
|
||||
///
|
||||
/// `ZipException` is the JVM's own; the other two are what the launcher and the
|
||||
/// `java -jar` front-end print for the same underlying damage. They share a
|
||||
/// remedy, so they share a diagnosis.
|
||||
const CORRUPT_ARCHIVE_MARKERS: [&str; 4] = [
|
||||
"java.util.zip.ZipException",
|
||||
"java.util.zip.ZipError",
|
||||
"Invalid or corrupt jarfile",
|
||||
"zip END header not found",
|
||||
];
|
||||
|
||||
/// Reads one log line, returning a diagnosis when it recognises the failure.
|
||||
///
|
||||
/// Runs against every line the game prints, so it stays a handful of substring
|
||||
/// scans rather than anything that has to parse the line.
|
||||
#[must_use]
|
||||
pub fn diagnose(line: &str) -> Option<CrashDiagnosis> {
|
||||
if !CORRUPT_ARCHIVE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| line.contains(marker))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CrashDiagnosis::CorruptArchive {
|
||||
file: jar_in(line),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pulls a jar path out of a line, when the message happens to carry one.
|
||||
///
|
||||
/// `ZipFile` reports "error in opening zip file" with no path on some JVMs and
|
||||
/// with a full path on others, so this is opportunistic: naming the file is a
|
||||
/// better message, but not naming it still leads to the same repair.
|
||||
fn jar_in(line: &str) -> Option<String> {
|
||||
let token = line
|
||||
.split(|c: char| c.is_whitespace() || c == '(' || c == ')')
|
||||
.find(|token| token.trim_end_matches(['.', ',', ':']).ends_with(".jar"))?;
|
||||
|
||||
let token = token.trim_end_matches(['.', ',', ':']);
|
||||
|
||||
// The full path is noise in a notification; the file name is what the user
|
||||
// recognises and what they would go looking for.
|
||||
let name = token
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(token)
|
||||
.to_string();
|
||||
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
/// Remembers the first recognised crash cause seen in a session's output.
|
||||
///
|
||||
/// The first rather than the last: a corrupt jar tends to cascade into pages of
|
||||
/// unrelated failures, and the earliest recognised line is the closest thing to
|
||||
/// a root cause the log offers.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct CrashWatch {
|
||||
found: Arc<Mutex<Option<CrashDiagnosis>>>,
|
||||
}
|
||||
|
||||
impl CrashWatch {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) fn observe(&self, line: &str) {
|
||||
// Cheap rejection first: almost every line is ordinary game output, and
|
||||
// this runs on all of them.
|
||||
if self.found.lock().is_ok_and(|found| found.is_some()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(diagnosis) = diagnose(line)
|
||||
&& let Ok(mut found) = self.found.lock()
|
||||
&& found.is_none()
|
||||
{
|
||||
tracing::warn!(?diagnosis, "recognised a repairable crash cause in the game log");
|
||||
*found = Some(diagnosis);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take(&self) -> Option<CrashDiagnosis> {
|
||||
self.found.lock().ok().and_then(|mut found| found.take())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_zip_exception_is_recognised() {
|
||||
// What the JVM prints when a jar on the classpath is damaged.
|
||||
let diagnosis = diagnose("java.util.zip.ZipException: error in opening zip file");
|
||||
assert_eq!(
|
||||
diagnosis,
|
||||
Some(CrashDiagnosis::CorruptArchive { file: None })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_zip_exception_is_recognised() {
|
||||
let line = "Caused by: java.util.zip.ZipException: zip END header not found";
|
||||
assert!(diagnose(line).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_jar_is_named_when_the_message_carries_one() {
|
||||
let line = "java.util.zip.ZipException: error in opening zip file: \
|
||||
/Users/someone/metadata/libraries/net/fabricmc/fabric-loader-0.15.jar";
|
||||
|
||||
assert_eq!(
|
||||
diagnose(line),
|
||||
Some(CrashDiagnosis::CorruptArchive {
|
||||
file: Some("fabric-loader-0.15.jar".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_path_is_reduced_to_its_file_name() {
|
||||
let line = r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar";
|
||||
|
||||
assert_eq!(
|
||||
diagnose(line),
|
||||
Some(CrashDiagnosis::CorruptArchive {
|
||||
file: Some("asm-9.7.jar".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_game_output_is_not_a_crash() {
|
||||
// This runs against every line the game prints, so a false positive
|
||||
// would nag the user into re-downloading the game for nothing.
|
||||
for line in [
|
||||
"[Render thread/INFO]: Setting user: Dev",
|
||||
"[main/INFO]: Loading 42 mods",
|
||||
"Loaded jar file sodium-0.5.jar",
|
||||
"[Worker-Main-1/WARN]: Unable to play unknown soundEvent",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(diagnose(line), None, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_watch_keeps_the_first_cause_not_the_last() {
|
||||
// A corrupt jar cascades, so the earliest recognised line is the one
|
||||
// closest to the root cause.
|
||||
let watch = CrashWatch::new();
|
||||
|
||||
watch.observe("[main/INFO]: Loading mods");
|
||||
watch.observe("java.util.zip.ZipException: error in opening zip file: first.jar");
|
||||
watch.observe("java.util.zip.ZipException: error in opening zip file: second.jar");
|
||||
|
||||
assert_eq!(
|
||||
watch.take(),
|
||||
Some(CrashDiagnosis::CorruptArchive {
|
||||
file: Some("first.jar".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_session_diagnoses_nothing() {
|
||||
let watch = CrashWatch::new();
|
||||
watch.observe("[Render thread/INFO]: Stopping!");
|
||||
assert_eq!(watch.take(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_a_diagnosis_consumes_it() {
|
||||
let watch = CrashWatch::new();
|
||||
watch.observe("java.util.zip.ZipException: error in opening zip file");
|
||||
|
||||
assert!(watch.take().is_some());
|
||||
assert!(watch.take().is_none(), "a diagnosis must not be reported twice");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ use crate::game::tail::spawn_log_tail;
|
|||
use crate::game::GameError;
|
||||
use oneclient_mc::{
|
||||
self as arguments, download_minecraft, download_version_info, get_loader_version,
|
||||
libraries_missing, resolve_minecraft_version,
|
||||
game_files_missing, resolve_minecraft_version,
|
||||
};
|
||||
use oneclient_events::{GroupedProgressSession, LaunchStage};
|
||||
use crate::settings::GameSettingsProfile;
|
||||
|
|
@ -187,7 +187,7 @@ pub async fn launch_cluster(
|
|||
state.java.prepare(major, search_for_java, false, None).await?
|
||||
};
|
||||
|
||||
match libraries_missing(&version_info, &java.os_arch, updated) {
|
||||
match game_files_missing(&version_info, &java.os_arch, updated) {
|
||||
Ok(true) => {
|
||||
tracing::info!(cluster_id, "missing game files; repairing");
|
||||
let _ = state.clusters.set_stage(cluster_id, ClusterStage::Repairing).await;
|
||||
|
|
@ -368,7 +368,14 @@ pub async fn launch_cluster(
|
|||
.as_ref()
|
||||
.and_then(SessionRecorder::started_at)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let tail = spawn_log_tail(cluster_id, log_path, events.clone(), recorder.clone());
|
||||
let crash_watch = crate::game::diagnosis::CrashWatch::new();
|
||||
let tail = spawn_log_tail(
|
||||
cluster_id,
|
||||
log_path,
|
||||
events.clone(),
|
||||
recorder.clone(),
|
||||
crash_watch.clone(),
|
||||
);
|
||||
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
state.games.register_kill(cluster_id, kill_tx);
|
||||
|
|
@ -408,6 +415,7 @@ pub async fn launch_cluster(
|
|||
ended_at: Utc::now(),
|
||||
outcome,
|
||||
owns_slot: true,
|
||||
diagnosis: crash_watch.take(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -472,6 +480,10 @@ pub(crate) struct SessionEnd {
|
|||
/// playtime is right, but clearing that slot would report the live game as
|
||||
/// exited.
|
||||
pub owns_slot: bool,
|
||||
/// What the game's own output said about why it died, when the launcher
|
||||
/// recognised the cause. `None` for a clean exit, an unrecognised crash, or
|
||||
/// a session recovered after the fact with no log being watched.
|
||||
pub diagnosis: Option<crate::game::diagnosis::CrashDiagnosis>,
|
||||
}
|
||||
|
||||
/// Everything that has to happen once a game is gone: clear its running state,
|
||||
|
|
@ -530,6 +542,8 @@ pub(crate) async fn finalize_session(
|
|||
}
|
||||
|
||||
let name = &cluster.name;
|
||||
let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. });
|
||||
|
||||
match end.outcome {
|
||||
Exit::Observed { success: true, .. } => state
|
||||
.services
|
||||
|
|
@ -547,6 +561,78 @@ pub(crate) async fn finalize_session(
|
|||
// user wants a popup about, so the session is just booked and closed.
|
||||
Exit::Inferred => {}
|
||||
}
|
||||
|
||||
// Only after the crash notice, and only when the game actually died: a
|
||||
// `ZipException` the game recovered from is not worth interrupting a
|
||||
// finished session over.
|
||||
if crashed && let Some(diagnosis) = end.diagnosis {
|
||||
offer_repair(state, cluster_id, &diagnosis).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers to verify and repair after a crash the launcher recognised.
|
||||
///
|
||||
/// Asks rather than acting: verification reads every installed file and can
|
||||
/// re-download a good part of the game, which is not something to start behind
|
||||
/// the user's back the moment they close a crashed session. Declining is a real
|
||||
/// answer — the same offer comes back on the next crash.
|
||||
#[tracing::instrument(skip(state, diagnosis), level = "debug")]
|
||||
pub async fn offer_repair(
|
||||
state: &Arc<LauncherState>,
|
||||
cluster_id: i64,
|
||||
diagnosis: &crate::game::diagnosis::CrashDiagnosis,
|
||||
) {
|
||||
enum Answer {
|
||||
Verify,
|
||||
}
|
||||
|
||||
let answer = state
|
||||
.services
|
||||
.events
|
||||
.ask(
|
||||
oneclient_events::Prompt::new("A damaged file crashed the game", diagnosis.body())
|
||||
.option(
|
||||
oneclient_events::Choice::primary("verify", "Verify and repair"),
|
||||
Answer::Verify,
|
||||
)
|
||||
.dismiss("Not now"),
|
||||
)
|
||||
.await;
|
||||
|
||||
match answer {
|
||||
Ok(Some(_)) => {}
|
||||
// Dismissed, or nobody was there to ask. Neither is consent to spend
|
||||
// several minutes re-downloading the game.
|
||||
Ok(None) => {
|
||||
tracing::info!(cluster_id, "user declined the post-crash repair");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, "could not offer a post-crash repair: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match crate::verify::verify_cluster_files(state, cluster_id).await {
|
||||
Ok(report) => {
|
||||
state
|
||||
.services
|
||||
.events
|
||||
.notify("Repair complete")
|
||||
.body(report.summary())
|
||||
.send();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(cluster_id, "post-crash repair failed: {err:#}");
|
||||
state
|
||||
.services
|
||||
.events
|
||||
.notify("Repair failed")
|
||||
.body(err.to_string())
|
||||
.error()
|
||||
.send();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command {
|
||||
|
|
|
|||
|
|
@ -19,16 +19,21 @@ pub use oneclient_mc::{
|
|||
get_classpath_library, get_library, java_arguments, main_class, minecraft_arguments,
|
||||
processor_arguments,
|
||||
};
|
||||
pub mod diagnosis;
|
||||
|
||||
pub use diagnosis::{CrashDiagnosis, diagnose};
|
||||
pub use error::GameError;
|
||||
pub use launch::{LaunchedGame, is_running, launch_cluster};
|
||||
pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair};
|
||||
pub use process::{
|
||||
GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time,
|
||||
};
|
||||
pub use reattach::recover_sessions;
|
||||
pub use oneclient_mc::{
|
||||
DownloadPlan, download_minecraft, download_version_info, get_game_versions,
|
||||
DownloadPlan, confirm_incomplete_install, download_minecraft, download_version_info, get_game_versions,
|
||||
get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated,
|
||||
libraries_missing, plan_downloads, resolve_minecraft_version, validate_rules,
|
||||
game_files_missing, libraries_missing, plan_downloads, resolve_minecraft_version,
|
||||
validate_rules,
|
||||
verify_game_files,
|
||||
};
|
||||
pub use shared_dir::{
|
||||
dematerialize_content, import_manual_content, link_cluster_logs, materialize_content,
|
||||
|
|
|
|||
|
|
@ -163,11 +163,16 @@ async fn readopt(
|
|||
let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else {
|
||||
return;
|
||||
};
|
||||
// A re-adopted game is still writing to its log, so its crashes are still
|
||||
// worth reading even though the exit code went with the launcher that
|
||||
// spawned it.
|
||||
let crash_watch = crate::game::diagnosis::CrashWatch::new();
|
||||
let tail = spawn_log_tail(
|
||||
cluster_id,
|
||||
log_path,
|
||||
state.services.events.clone(),
|
||||
Some(recorder.clone()),
|
||||
crash_watch.clone(),
|
||||
);
|
||||
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
|
@ -213,6 +218,7 @@ async fn readopt(
|
|||
// fresh game on this cluster while the re-adopted one plays on,
|
||||
// and that newer game now owns the slot.
|
||||
owns_slot: state.games.pid(cluster_id) == Some(pid),
|
||||
diagnosis: crash_watch.take(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
@ -297,6 +303,9 @@ async fn reconcile(
|
|||
// This session is over, but the cluster may have a newer one playing right
|
||||
// now, so only claim the slot if nothing else holds it.
|
||||
owns_slot: !state.games.is_active(cluster_id),
|
||||
// Nothing was watching this session's log while it ran, so there is
|
||||
// no crash to have recognised.
|
||||
diagnosis: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::time::Duration;
|
|||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
|
||||
|
||||
use crate::game::diagnosis::CrashWatch;
|
||||
use crate::game::session::SessionRecorder;
|
||||
use oneclient_events::EventBus;
|
||||
|
||||
|
|
@ -30,12 +31,13 @@ pub(crate) fn spawn_log_tail(
|
|||
path: PathBuf,
|
||||
events: EventBus,
|
||||
recorder: Option<SessionRecorder>,
|
||||
crash_watch: CrashWatch,
|
||||
) -> LogTail {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&stop);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &flag).await {
|
||||
if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &crash_watch, &flag).await {
|
||||
tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped");
|
||||
}
|
||||
});
|
||||
|
|
@ -48,6 +50,7 @@ async fn tail(
|
|||
path: &PathBuf,
|
||||
events: &EventBus,
|
||||
recorder: Option<&SessionRecorder>,
|
||||
crash_watch: &CrashWatch,
|
||||
stop: &AtomicBool,
|
||||
) -> std::io::Result<()> {
|
||||
// The game may not have created the file yet on a re-adopt, and on launch it
|
||||
|
|
@ -96,7 +99,7 @@ async fn tail(
|
|||
// leaves a newline that will never arrive, and waiting for it would
|
||||
// hang the caller of `stop()` forever.
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
emit(cluster_id, &line, events, recorder).await;
|
||||
emit(cluster_id, &line, events, recorder, crash_watch).await;
|
||||
return Ok(());
|
||||
}
|
||||
reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?;
|
||||
|
|
@ -105,7 +108,7 @@ async fn tail(
|
|||
continue;
|
||||
}
|
||||
|
||||
emit(cluster_id, &line, events, recorder).await;
|
||||
emit(cluster_id, &line, events, recorder, crash_watch).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +117,7 @@ async fn emit(
|
|||
line: &str,
|
||||
events: &EventBus,
|
||||
recorder: Option<&SessionRecorder>,
|
||||
crash_watch: &CrashWatch,
|
||||
) {
|
||||
// Blank lines are kept: the game prints them, and the console should read
|
||||
// the way the log does.
|
||||
|
|
@ -121,6 +125,9 @@ async fn emit(
|
|||
if let Some(recorder) = recorder {
|
||||
recorder.observe(&text).await;
|
||||
}
|
||||
// Read on the way past rather than by re-opening the log afterwards: the
|
||||
// line is already here, and a crash can take the log with it.
|
||||
crash_watch.observe(&text);
|
||||
events.game_log(cluster_id, text);
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +182,7 @@ mod tests {
|
|||
let path = h.path();
|
||||
polyio::write(&path, "[12:00:00] first\n").await.unwrap();
|
||||
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
settle().await;
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
|
|
@ -196,7 +203,7 @@ mod tests {
|
|||
let mut h = Harness::new().await;
|
||||
let path = h.path();
|
||||
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
settle().await;
|
||||
polyio::write(&path, "[12:00:00] late\n").await.unwrap();
|
||||
settle().await;
|
||||
|
|
@ -211,7 +218,7 @@ mod tests {
|
|||
let path = h.path();
|
||||
polyio::write(&path, "[12:00:00] half").await.unwrap();
|
||||
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
settle().await;
|
||||
// A line mid-write must not be emitted, or it would arrive twice.
|
||||
assert!(h.drain().is_empty());
|
||||
|
|
@ -235,7 +242,7 @@ mod tests {
|
|||
let path = h.path();
|
||||
polyio::write(&path, "[12:00:00] old session line\n").await.unwrap();
|
||||
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
settle().await;
|
||||
assert_eq!(h.drain(), vec!["[12:00:00] old session line"]);
|
||||
|
||||
|
|
@ -255,7 +262,7 @@ mod tests {
|
|||
// A process killed mid-write leaves a newline that never arrives.
|
||||
polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap();
|
||||
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
settle().await;
|
||||
|
||||
// Waiting for that newline would hang the exit path forever.
|
||||
|
|
@ -272,7 +279,7 @@ mod tests {
|
|||
|
||||
// Stopping immediately, as the exit path does, must still flush the
|
||||
// reason the game exited rather than race past it.
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
|
||||
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
|
||||
tail.stop().await;
|
||||
|
||||
assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ pub mod settings;
|
|||
pub mod storage;
|
||||
pub mod tos;
|
||||
mod state;
|
||||
pub mod simulate;
|
||||
pub mod verify;
|
||||
pub mod versions;
|
||||
|
||||
pub use oneclient_content::bundles::{
|
||||
|
|
@ -60,6 +62,7 @@ pub use oneclient_content::packages::updates::{
|
|||
refresh_browser_package_updates, skip_browser_package_update,
|
||||
};
|
||||
pub use settings::ProfileUpdate;
|
||||
pub use verify::{ClusterVerifyReport, verify_cluster_files};
|
||||
pub use state::LauncherServices;
|
||||
pub use state::LauncherState;
|
||||
pub use state::run_startup_tasks;
|
||||
|
|
|
|||
398
packages/oneclient_core/src/simulate.rs
Normal file
398
packages/oneclient_core/src/simulate.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
//! Deliberately damaging an installation, so the repair paths can be exercised
|
||||
//! without waiting for a real bad connection.
|
||||
//!
|
||||
//! Every function here breaks files in a *specific* way, because the launcher
|
||||
//! catches different damage at different layers and the distinction is the
|
||||
//! whole point of testing it:
|
||||
//!
|
||||
//! - **Same length, wrong bytes** slips past the launch-time size check by
|
||||
//! design, and is only caught by the hashing pass behind "Verify Files".
|
||||
//! - **Truncated** is what an interrupted download used to leave behind, and is
|
||||
//! caught cheaply on the next launch.
|
||||
//! - **Absent** is the case that used to surface as a raw "No such file or
|
||||
//! directory" naming a path inside the launcher's own metadata folder.
|
||||
//!
|
||||
//! Destructive by nature. Everything is repairable by the launcher itself,
|
||||
//! which is what makes it safe to point at a real installation.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use oneclient_content::packages::PackageStore;
|
||||
use oneclient_content::packages::store::artifact_absolute_path;
|
||||
use oneclient_common::paths;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
|
||||
use crate::state::LauncherState;
|
||||
use crate::LauncherResult;
|
||||
|
||||
/// What a simulation actually did, so the UI can say so rather than claiming
|
||||
/// success over an installation that had nothing to break.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct SimulationReport {
|
||||
pub affected: usize,
|
||||
/// A few of the paths touched, for a notification body. Not the full list:
|
||||
/// corrupting 50 assets should not produce a 50-line toast.
|
||||
pub samples: Vec<String>,
|
||||
}
|
||||
|
||||
impl SimulationReport {
|
||||
#[must_use]
|
||||
pub fn summary(&self, verb: &str) -> String {
|
||||
if self.affected == 0 {
|
||||
return "Nothing to affect — is the game installed?".to_string();
|
||||
}
|
||||
|
||||
let mut text = format!("{verb} {} file(s)", self.affected);
|
||||
if let Some(first) = self.samples.first() {
|
||||
text.push_str(&format!(": {first}"));
|
||||
if self.affected > 1 {
|
||||
text.push_str(&format!(" and {} more", self.affected - 1));
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn push(&mut self, path: &Path) {
|
||||
self.affected += 1;
|
||||
if self.samples.len() < 3 {
|
||||
self.samples.push(
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file should be damaged.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Damage {
|
||||
/// Flip bytes in the middle, keeping the length. Passes a size check and
|
||||
/// fails a hash check — the case "Verify Files" exists for.
|
||||
Corrupt,
|
||||
/// Cut the file to half its length, as an interrupted write would.
|
||||
Truncate,
|
||||
/// Remove it outright.
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl Damage {
|
||||
#[must_use]
|
||||
pub fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Self::Corrupt => "Corrupted",
|
||||
Self::Truncate => "Truncated",
|
||||
Self::Delete => "Deleted",
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(self, path: &Path) -> std::io::Result<()> {
|
||||
match self {
|
||||
Self::Delete => std::fs::remove_file(path),
|
||||
Self::Truncate => {
|
||||
let len = std::fs::metadata(path)?.len();
|
||||
let file = std::fs::OpenOptions::new().write(true).open(path)?;
|
||||
file.set_len(len / 2)
|
||||
}
|
||||
Self::Corrupt => {
|
||||
let mut bytes = std::fs::read(path)?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Flipping every bit of a few bytes in the middle guarantees a
|
||||
// different hash without changing the length, and lands past
|
||||
// any header a reader might validate first — so the file stays
|
||||
// superficially plausible, exactly like real bit rot.
|
||||
let start = bytes.len() / 2;
|
||||
let end = (start + 8).min(bytes.len());
|
||||
for byte in &mut bytes[start..end] {
|
||||
*byte = !*byte;
|
||||
}
|
||||
std::fs::write(path, &bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Damages `count` asset objects, spread across the sharded subdirectories.
|
||||
///
|
||||
/// Spread rather than taken in bulk from one shard because that is how real
|
||||
/// damage arrives — a handful of failed requests scattered through a download —
|
||||
/// and because it proves the verify pass walks the whole tree.
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub async fn damage_assets(count: usize, damage: Damage) -> LauncherResult<SimulationReport> {
|
||||
let dir = paths::assets_object_dir()?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut report = SimulationReport::default();
|
||||
let Ok(shards) = std::fs::read_dir(&dir) else {
|
||||
return report;
|
||||
};
|
||||
|
||||
// One file per shard before taking a second from any, so a small count
|
||||
// never all lands in `00/`.
|
||||
let mut shard_dirs: Vec<PathBuf> = shards
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect();
|
||||
shard_dirs.sort();
|
||||
|
||||
let mut round = 0;
|
||||
while report.affected < count {
|
||||
let mut progressed = false;
|
||||
|
||||
for shard in &shard_dirs {
|
||||
if report.affected >= count {
|
||||
break;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(shard) else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file())
|
||||
.nth(round)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
progressed = true;
|
||||
match damage.apply(&path) {
|
||||
Ok(()) => report.push(&path),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), "could not damage: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every shard is exhausted; asking for more than exists is not an
|
||||
// error, there simply are not that many assets installed.
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
round += 1;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
affected = report.affected,
|
||||
?damage,
|
||||
"simulated asset damage"
|
||||
);
|
||||
report
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Damages the installed libraries, which are on the classpath and so tend to
|
||||
/// stop the game outright rather than degrade it.
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub async fn damage_libraries(count: usize, damage: Damage) -> LauncherResult<SimulationReport> {
|
||||
let dir = paths::libraries_dir()?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut report = SimulationReport::default();
|
||||
let mut stack = vec![dir];
|
||||
|
||||
while let Some(current) = stack.pop() {
|
||||
if report.affected >= count {
|
||||
break;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|ext| ext != "jar") {
|
||||
continue;
|
||||
}
|
||||
if report.affected >= count {
|
||||
break;
|
||||
}
|
||||
match damage.apply(&path) {
|
||||
Ok(()) => report.push(&path),
|
||||
Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
affected = report.affected,
|
||||
?damage,
|
||||
"simulated library damage"
|
||||
);
|
||||
report
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Removes the whole assets tree.
|
||||
///
|
||||
/// Reproduces the reported failure exactly: a cluster already marked `Ready`
|
||||
/// skips preparation on every later launch, so nothing notices until something
|
||||
/// tries to read a path that is no longer there.
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub async fn delete_assets_tree() -> LauncherResult<SimulationReport> {
|
||||
let dir = paths::assets_dir()?;
|
||||
|
||||
let existed = polyio::try_exists(&dir).await.unwrap_or(false);
|
||||
if existed {
|
||||
tokio::fs::remove_dir_all(&dir)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
|
||||
tracing::warn!(path = %dir.display(), existed, "simulated a missing assets tree");
|
||||
|
||||
Ok(SimulationReport {
|
||||
affected: usize::from(existed),
|
||||
samples: vec![dir.display().to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
/// Damages the installed content of one cluster.
|
||||
///
|
||||
/// Content is cached content-addressed, so a mod that no longer hashes to its
|
||||
/// own key cannot be found again — the repair has to go back to the provider
|
||||
/// for it, which is a different path from the game files.
|
||||
#[tracing::instrument(level = "debug", skip(state))]
|
||||
pub async fn damage_cluster_content(
|
||||
state: &std::sync::Arc<LauncherState>,
|
||||
cluster_id: i64,
|
||||
count: usize,
|
||||
damage: Damage,
|
||||
) -> LauncherResult<SimulationReport> {
|
||||
let content = state.services.content();
|
||||
let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?;
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for link in linked {
|
||||
let Some(artifact) =
|
||||
artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(path) = artifact_absolute_path(&artifact.path) {
|
||||
paths.push(path);
|
||||
}
|
||||
if paths.len() >= count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut report = SimulationReport::default();
|
||||
for path in paths {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
match damage.apply(&path) {
|
||||
Ok(()) => report.push(&path),
|
||||
Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
affected = report.affected,
|
||||
?damage,
|
||||
"simulated content damage"
|
||||
);
|
||||
report
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"oneclient-simulate-{tag}-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupting_keeps_the_length_and_changes_the_bytes() {
|
||||
// The whole point of this mode: it has to survive a size check so that
|
||||
// only the hashing pass can catch it.
|
||||
let dir = scratch("corrupt");
|
||||
let file = dir.join("object.bin");
|
||||
let original: Vec<u8> = (0..=255u8).collect();
|
||||
std::fs::write(&file, &original).unwrap();
|
||||
|
||||
Damage::Corrupt.apply(&file).unwrap();
|
||||
|
||||
let after = std::fs::read(&file).unwrap();
|
||||
assert_eq!(after.len(), original.len(), "length must not change");
|
||||
assert_ne!(after, original, "bytes must change");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupting_an_empty_file_is_a_no_op_rather_than_a_panic() {
|
||||
let dir = scratch("corrupt-empty");
|
||||
let file = dir.join("empty.bin");
|
||||
std::fs::write(&file, []).unwrap();
|
||||
|
||||
Damage::Corrupt.apply(&file).unwrap();
|
||||
|
||||
assert!(std::fs::read(&file).unwrap().is_empty());
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncating_halves_the_file() {
|
||||
let dir = scratch("truncate");
|
||||
let file = dir.join("object.bin");
|
||||
std::fs::write(&file, vec![7u8; 100]).unwrap();
|
||||
|
||||
Damage::Truncate.apply(&file).unwrap();
|
||||
|
||||
assert_eq!(std::fs::metadata(&file).unwrap().len(), 50);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_over_nothing_says_so() {
|
||||
let report = SimulationReport::default();
|
||||
assert_eq!(
|
||||
report.summary("Corrupted"),
|
||||
"Nothing to affect — is the game installed?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_names_a_sample_and_counts_the_rest() {
|
||||
let mut report = SimulationReport::default();
|
||||
report.push(Path::new("/tmp/assets/objects/00/aaaa"));
|
||||
report.push(Path::new("/tmp/assets/objects/01/bbbb"));
|
||||
report.push(Path::new("/tmp/assets/objects/02/cccc"));
|
||||
report.push(Path::new("/tmp/assets/objects/03/dddd"));
|
||||
|
||||
assert_eq!(report.affected, 4);
|
||||
// Only a few names are kept, so a large run cannot produce a toast the
|
||||
// size of the asset index.
|
||||
assert_eq!(report.samples.len(), 3);
|
||||
assert_eq!(report.summary("Corrupted"), "Corrupted 4 file(s): aaaa and 3 more");
|
||||
}
|
||||
}
|
||||
452
packages/oneclient_core/src/verify.rs
Normal file
452
packages/oneclient_core/src/verify.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use oneclient_cluster::ClusterError;
|
||||
use oneclient_content::packages::PackageStore;
|
||||
use oneclient_content::packages::store::artifact_absolute_path;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase};
|
||||
use oneclient_common::paths;
|
||||
use polyio::{normalize_hash, sha1_file};
|
||||
|
||||
use crate::clusters::prepare::prepare_cluster_locked;
|
||||
use crate::game::{
|
||||
download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files,
|
||||
};
|
||||
use crate::state::LauncherState;
|
||||
use crate::LauncherResult;
|
||||
|
||||
/// What a verify pass found and what it managed to do about it.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct ClusterVerifyReport {
|
||||
/// Files read and hashed, across game files and installed content.
|
||||
pub checked: usize,
|
||||
/// Files whose contents did not match and were removed.
|
||||
pub corrupt: usize,
|
||||
/// Files a manifest expected that were not on disk at all.
|
||||
pub missing: usize,
|
||||
/// Files that were successfully fetched again.
|
||||
pub repaired: usize,
|
||||
/// Content that was corrupt but has no source to re-download from —
|
||||
/// locally imported files, or a version the provider no longer serves.
|
||||
/// Named so the user can replace them by hand.
|
||||
pub unrepairable: Vec<String>,
|
||||
/// The game files were re-downloaded without being counted, because the
|
||||
/// assets index they would have been checked against was itself gone.
|
||||
/// Reported separately so a wholesale reinstall is never described as a
|
||||
/// clean sweep of the handful of files that could still be counted.
|
||||
pub reinstalled_game_files: bool,
|
||||
}
|
||||
|
||||
impl ClusterVerifyReport {
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.corrupt == 0
|
||||
&& self.missing == 0
|
||||
&& self.unrepairable.is_empty()
|
||||
&& !self.reinstalled_game_files
|
||||
}
|
||||
|
||||
/// A one-line summary for a notification.
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
if self.is_clean() {
|
||||
return format!("All {} files verified.", self.checked);
|
||||
}
|
||||
|
||||
if self.reinstalled_game_files && self.corrupt == 0 && self.missing == 0 {
|
||||
return "Game files were missing and have been re-downloaded.".to_string();
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if self.corrupt > 0 {
|
||||
parts.push(format!("{} corrupt", self.corrupt));
|
||||
}
|
||||
if self.missing > 0 {
|
||||
parts.push(format!("{} missing", self.missing));
|
||||
}
|
||||
if self.repaired > 0 {
|
||||
parts.push(format!("{} repaired", self.repaired));
|
||||
}
|
||||
if !self.unrepairable.is_empty() {
|
||||
parts.push(format!("{} could not be replaced", self.unrepairable.len()));
|
||||
}
|
||||
|
||||
format!("Checked {} files: {}.", self.checked, parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies every file a cluster installed, and re-downloads what is broken.
|
||||
///
|
||||
/// Runs in three passes. Game files are hashed against the Mojang manifest and
|
||||
/// the bad ones deleted; installed content is hashed against the hash it is
|
||||
/// indexed under and the bad ones deleted and re-fetched from the provider that
|
||||
/// served them; then the ordinary prepare path runs, which re-downloads exactly
|
||||
/// what is now missing using the same verified, retrying download every other
|
||||
/// install goes through.
|
||||
///
|
||||
/// Deleting and re-preparing rather than overwriting in place is deliberate: it
|
||||
/// reuses one well-tested download path instead of adding a second one that
|
||||
/// would have to be kept in step with it.
|
||||
#[tracing::instrument(skip(state), level = "debug")]
|
||||
pub async fn verify_cluster_files(
|
||||
state: &Arc<LauncherState>,
|
||||
cluster_id: i64,
|
||||
) -> LauncherResult<ClusterVerifyReport> {
|
||||
let cluster = state.clusters.get(cluster_id).await?;
|
||||
|
||||
let progress = GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
format!("Verifying files - {}", cluster.mc_version),
|
||||
);
|
||||
|
||||
let result = run_verify(state, cluster_id, &progress).await;
|
||||
progress.finish();
|
||||
|
||||
match &result {
|
||||
Ok(report) => tracing::info!(
|
||||
cluster_id,
|
||||
checked = report.checked,
|
||||
corrupt = report.corrupt,
|
||||
missing = report.missing,
|
||||
repaired = report.repaired,
|
||||
"cluster verification finished"
|
||||
),
|
||||
Err(err) => tracing::error!(cluster_id, "cluster verification failed: {err}"),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_verify(
|
||||
state: &Arc<LauncherState>,
|
||||
cluster_id: i64,
|
||||
progress: &GroupedProgressSession,
|
||||
) -> LauncherResult<ClusterVerifyReport> {
|
||||
let cluster = state.clusters.get(cluster_id).await?;
|
||||
let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version);
|
||||
|
||||
// Resolving the version needs the metadata lock, which prepare also takes.
|
||||
// Scope it so it is released before the repair pass below.
|
||||
let (version_info, minecraft_updated) = {
|
||||
let mut metadata = state.metadata.lock().await;
|
||||
let (version, _index, minecraft_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?;
|
||||
|
||||
let info = download_version_info(
|
||||
&state.services.mc(),
|
||||
Some(progress),
|
||||
&version,
|
||||
loader_version.as_ref(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
(info, minecraft_updated)
|
||||
};
|
||||
|
||||
let mut report = ClusterVerifyReport::default();
|
||||
|
||||
// The index is needed to know which assets this version expects. Reading
|
||||
// the cached copy avoids a network round trip; if it is absent or itself
|
||||
// corrupt there is nothing to verify assets against, so skip that part
|
||||
// rather than fail — prepare will fetch it and the assets below.
|
||||
let assets_index = cached_assets_index(&version_info).await;
|
||||
|
||||
// Library rules are evaluated per architecture, so checking against the
|
||||
// wrong one would verify a different set of libraries than the launch path
|
||||
// installs. Use the runtime this cluster actually launches with.
|
||||
let java_arch = {
|
||||
let global = state.settings.read().global_game_settings.clone();
|
||||
let profile = state.clusters.resolve_settings(&global, &cluster).await?;
|
||||
state
|
||||
.java
|
||||
.runtime_for_profile(profile.java_path.as_deref())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map_or_else(|| std::env::consts::ARCH.to_string(), |rt| rt.os_arch)
|
||||
};
|
||||
|
||||
// Counted apart from content: these are repaired by the prepare pass below,
|
||||
// whereas content repairs itself as it goes. Adding both into one bucket
|
||||
// would count the content ones twice.
|
||||
let mut game_broken = 0;
|
||||
|
||||
// Without the index there is nothing to check the assets against — and the
|
||||
// index lives inside the assets directory, so its absence usually means
|
||||
// that whole directory is gone. That is precisely the case this repair
|
||||
// exists for, so hand the work to prepare rather than reporting a clean
|
||||
// sweep of the nothing that could be checked.
|
||||
let reinstalled_game_files = assets_index.is_none();
|
||||
|
||||
if let Some(assets_index) = assets_index {
|
||||
let game = verify_game_files(
|
||||
progress,
|
||||
&version_info,
|
||||
assets_index,
|
||||
&java_arch,
|
||||
minecraft_updated,
|
||||
)
|
||||
.await?;
|
||||
|
||||
report.checked += game.checked;
|
||||
report.corrupt += game.corrupt;
|
||||
report.missing += game.missing;
|
||||
game_broken = game.corrupt + game.missing;
|
||||
} else {
|
||||
tracing::warn!("assets index is missing; re-downloading the game files wholesale");
|
||||
}
|
||||
|
||||
verify_cluster_content(state, cluster_id, progress, &mut report).await?;
|
||||
|
||||
// Everything corrupt is gone, so a normal prepare sees it as missing and
|
||||
// fetches it back through the verified, retrying download path. A file it
|
||||
// cannot get surfaces as an error rather than a silent gap, so reaching the
|
||||
// line after this means the game files are whole again.
|
||||
//
|
||||
// Note this runs `prepare` directly rather than going through the launch
|
||||
// path, which skips preparing a cluster already marked `Ready` — the very
|
||||
// reason a cluster with deleted files keeps failing to launch instead of
|
||||
// healing itself.
|
||||
if game_broken > 0 || reinstalled_game_files {
|
||||
prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?;
|
||||
report.repaired += game_broken;
|
||||
}
|
||||
|
||||
report.reinstalled_game_files = reinstalled_game_files;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Hashes the cluster's installed content against the hashes it is indexed
|
||||
/// under, and re-fetches whatever no longer matches.
|
||||
///
|
||||
/// Content is cached content-addressed, so a file that does not hash to its own
|
||||
/// key is unusable by definition: nothing can find it again, and materializing
|
||||
/// it into the game folder would put a corrupt mod in front of the game.
|
||||
async fn verify_cluster_content(
|
||||
state: &Arc<LauncherState>,
|
||||
cluster_id: i64,
|
||||
progress: &GroupedProgressSession,
|
||||
report: &mut ClusterVerifyReport,
|
||||
) -> LauncherResult<()> {
|
||||
let content = state.services.content();
|
||||
let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?;
|
||||
if linked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let child = progress.child(
|
||||
"Verifying content",
|
||||
linked.len() as u64,
|
||||
TaskCategory::Packages,
|
||||
);
|
||||
child.set_phase(TaskPhase::Verifying);
|
||||
|
||||
for (index, link) in linked.iter().enumerate() {
|
||||
child.set_progress(index as u64, Some(linked.len() as u64));
|
||||
|
||||
let Some(artifact) = artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(path) = artifact_absolute_path(&artifact.path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !path.is_file() {
|
||||
report.missing += 1;
|
||||
repair_content(state, link, report).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let actual = match sha1_file(&path).await {
|
||||
Ok(actual) => actual,
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), "could not hash content: {err}");
|
||||
report.corrupt += 1;
|
||||
let _ = polyio::remove_file(&path).await;
|
||||
repair_content(state, link, report).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
report.checked += 1;
|
||||
|
||||
if normalize_hash(&actual) == normalize_hash(&link.hash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
file = %link.file_name,
|
||||
expected = %link.hash,
|
||||
%actual,
|
||||
"corrupt content file; removing"
|
||||
);
|
||||
report.corrupt += 1;
|
||||
let _ = polyio::remove_file(&path).await;
|
||||
repair_content(state, link, report).await;
|
||||
}
|
||||
|
||||
child.finish();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-downloads one piece of content from the provider that served it.
|
||||
///
|
||||
/// Failures are recorded rather than propagated: one mod that a provider has
|
||||
/// since delisted should not abandon verification of everything else. A locally
|
||||
/// imported file has no provider at all, and the user is the only one who can
|
||||
/// replace it — so it is named in the report instead of silently vanishing.
|
||||
async fn repair_content(
|
||||
state: &Arc<LauncherState>,
|
||||
link: &oneclient_content::packages::LinkedArtifactInfo,
|
||||
report: &mut ClusterVerifyReport,
|
||||
) {
|
||||
let content = state.services.content();
|
||||
|
||||
let (Some(provider_id), Some(project_id), Some(version_id)) = (
|
||||
link.provider,
|
||||
link.project_id.as_deref(),
|
||||
link.version_id.as_deref(),
|
||||
) else {
|
||||
tracing::warn!(file = %link.file_name, "no provider to re-download from");
|
||||
report.unrepairable.push(link.file_name.clone());
|
||||
return;
|
||||
};
|
||||
|
||||
let outcome = async {
|
||||
let provider = content.providers.get(provider_id)?;
|
||||
let version = provider.get_version(project_id, version_id, &content).await?;
|
||||
|
||||
// The cluster is pinned to one exact file, identified by the hash it is
|
||||
// indexed under. Re-downloading a different file from the same version
|
||||
// would silently change what is installed.
|
||||
let file = version
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| normalize_hash(&file.sha1) == normalize_hash(&link.hash))
|
||||
.ok_or_else(|| oneclient_content::ContentError::InvalidData {
|
||||
reason: format!("{} is no longer served by {provider_id:?}", link.file_name),
|
||||
})?;
|
||||
|
||||
oneclient_content::packages::store::download_version_file(
|
||||
provider_id,
|
||||
project_id,
|
||||
&version,
|
||||
link.content_type,
|
||||
file,
|
||||
true,
|
||||
None,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
tracing::info!(file = %link.file_name, "re-downloaded corrupt content");
|
||||
report.repaired += 1;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(file = %link.file_name, "could not re-download: {err}");
|
||||
report.unrepairable.push(link.file_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_assets_index(
|
||||
info: &interfrost::api::minecraft::VersionInfo,
|
||||
) -> Option<interfrost::api::minecraft::AssetsIndex> {
|
||||
let path = paths::assets_index_dir()
|
||||
.ok()?
|
||||
.join(format!("{}.json", info.asset_index.id));
|
||||
polyio::read_json(&path).await.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_clean_sweep_says_so() {
|
||||
let report = ClusterVerifyReport {
|
||||
checked: 5123,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(report.is_clean());
|
||||
assert_eq!(report.summary(), "All 5123 files verified.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repaired_sweep_reports_what_it_did() {
|
||||
let report = ClusterVerifyReport {
|
||||
checked: 5123,
|
||||
corrupt: 3,
|
||||
missing: 1,
|
||||
repaired: 4,
|
||||
unrepairable: Vec::new(),
|
||||
reinstalled_game_files: false,
|
||||
};
|
||||
|
||||
assert!(!report.is_clean());
|
||||
assert_eq!(
|
||||
report.summary(),
|
||||
"Checked 5123 files: 3 corrupt, 1 missing, 4 repaired."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wholesale_reinstall_is_not_reported_as_a_clean_sweep() {
|
||||
// The `metadata/assets` case: the index the assets would have been
|
||||
// checked against was gone too, so almost nothing could be counted.
|
||||
// Saying "All 2 files verified" after re-downloading the game would be
|
||||
// actively misleading.
|
||||
let report = ClusterVerifyReport {
|
||||
checked: 2,
|
||||
reinstalled_game_files: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!report.is_clean());
|
||||
assert_eq!(
|
||||
report.summary(),
|
||||
"Game files were missing and have been re-downloaded."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_that_cannot_be_replaced_is_still_reported() {
|
||||
// A locally imported mod has no provider to fetch it from again, so the
|
||||
// user is the only one who can put it back. Saying nothing would leave
|
||||
// them with a cluster that is quietly missing a file.
|
||||
let report = ClusterVerifyReport {
|
||||
checked: 12,
|
||||
corrupt: 1,
|
||||
repaired: 0,
|
||||
unrepairable: vec!["my-local-mod.jar".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!report.is_clean());
|
||||
assert_eq!(
|
||||
report.summary(),
|
||||
"Checked 12 files: 1 corrupt, 1 could not be replaced."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use polyio::Checksum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::vendors::JavaVendor;
|
||||
|
|
@ -21,6 +22,41 @@ pub struct JavaPackage {
|
|||
pub java_version: Vec<u32>,
|
||||
pub vendor: JavaVendor,
|
||||
pub archive: PackageArchive,
|
||||
/// What the vendor promises the archive hashes to. Every vendor publishes
|
||||
/// one, in its own algorithm — SHA-256 for Adoptium, Corretto and Zulu,
|
||||
/// SHA-1 for Liberica — so this carries the algorithm with it.
|
||||
///
|
||||
/// `None` only when a vendor's metadata omitted or malformed it. A runtime
|
||||
/// that cannot be verified is still worth installing (the alternative is a
|
||||
/// launcher that cannot start the game at all), so this degrades to an
|
||||
/// unverified download rather than a hard failure.
|
||||
pub checksum: Option<Checksum>,
|
||||
/// Archive size in bytes when the vendor publishes it, for progress bars
|
||||
/// that would otherwise wait on `Content-Length`.
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
impl JavaPackage {
|
||||
/// Drops a checksum the vendor returned in an unusable shape.
|
||||
///
|
||||
/// A vendor that starts sending an empty string or a placeholder would
|
||||
/// otherwise make every runtime install fail with what looks to the user
|
||||
/// like a corrupt download, three retries deep.
|
||||
#[must_use]
|
||||
pub fn with_checksum(mut self, checksum: Option<Checksum>) -> Self {
|
||||
self.checksum = checksum.filter(|sum| {
|
||||
let ok = sum.is_well_formed();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
algorithm = sum.algorithm.name(),
|
||||
hex = %sum.hex,
|
||||
"vendor published a malformed checksum; installing unverified"
|
||||
);
|
||||
}
|
||||
ok
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use reqwest::Method;
|
||||
use url::Url;
|
||||
|
||||
use oneclient_net::{ResponseNotifyOptions, ResponseOptions};
|
||||
use oneclient_net::ResponseNotifyOptions;
|
||||
use crate::data::{
|
||||
java_executable_relative_path, JavaPackage, PackageArchive,
|
||||
};
|
||||
|
|
@ -32,8 +29,14 @@ pub async fn install_package(
|
|||
// settings, say) gets its own notification.
|
||||
// No size in the package metadata; the child picks up its real total from
|
||||
// Content-Length as soon as the response headers land.
|
||||
let child = progress
|
||||
.map(|session| session.child(format!("{} {}", package.vendor, major), 0, TaskCategory::Java));
|
||||
let expected_size = package.size.unwrap_or(0);
|
||||
let child = progress.map(|session| {
|
||||
session.child(
|
||||
format!("{} {}", package.vendor, major),
|
||||
expected_size,
|
||||
TaskCategory::Java,
|
||||
)
|
||||
});
|
||||
|
||||
let notify = match &child {
|
||||
Some(child) => ResponseNotifyOptions::grouped(child.clone()),
|
||||
|
|
@ -44,16 +47,30 @@ pub async fn install_package(
|
|||
.done_label(format!("Installed {} {}", package.vendor, major)),
|
||||
};
|
||||
|
||||
net
|
||||
.download_file(
|
||||
reqwest::Request::new(Method::GET, Url::parse(&package.download_url)?),
|
||||
&archive_path,
|
||||
ResponseOptions {
|
||||
notify: Some(notify),
|
||||
},
|
||||
events,
|
||||
)
|
||||
.await?;
|
||||
if package.checksum.is_none() {
|
||||
tracing::warn!(
|
||||
vendor = %package.vendor,
|
||||
major,
|
||||
"vendor published no usable checksum; installing runtime unverified"
|
||||
);
|
||||
}
|
||||
|
||||
// A runtime is the largest single file the launcher downloads, so it is the
|
||||
// most likely to be interrupted — and it used to be the one download with no
|
||||
// verification at all. Now it is hashed from the bytes in flight against the
|
||||
// vendor's own checksum, retried on a dropped connection, and only published
|
||||
// to `archive_path` once it matches, so extraction never sees a partial
|
||||
// archive.
|
||||
oneclient_net::download_verified(
|
||||
net,
|
||||
events,
|
||||
&package.download_url,
|
||||
&archive_path,
|
||||
package.checksum.as_ref(),
|
||||
expected_size,
|
||||
Some(notify),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let extract_root = java_dir.join(stem_without_archive(&package.name));
|
||||
|
||||
|
|
|
|||
28
packages/oneclient_java/src/vendors/adoptium.rs
vendored
28
packages/oneclient_java/src/vendors/adoptium.rs
vendored
|
|
@ -3,6 +3,7 @@ use serde::Deserialize;
|
|||
use url::Url;
|
||||
|
||||
use oneclient_net::RequestClient;
|
||||
use polyio::Checksum;
|
||||
|
||||
use crate::data::{JavaPackage, PackageArchive};
|
||||
use crate::error::JavaResult;
|
||||
|
|
@ -38,6 +39,12 @@ struct AdoptiumBinary {
|
|||
struct AdoptiumPackage {
|
||||
name: String,
|
||||
link: String,
|
||||
/// Adoptium's `checksum` is SHA-256; the API has no algorithm field because
|
||||
/// it has only ever published one.
|
||||
#[serde(default)]
|
||||
checksum: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -87,13 +94,20 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider {
|
|||
}
|
||||
|
||||
for binary in release.binaries {
|
||||
packages.push(JavaPackage {
|
||||
archive: PackageArchive::from_filename(&binary.package.name),
|
||||
download_url: binary.package.link,
|
||||
java_version: java_version.clone(),
|
||||
name: binary.package.name,
|
||||
vendor: JavaVendor::Adoptium,
|
||||
});
|
||||
let checksum = binary.package.checksum.as_deref().map(Checksum::sha256);
|
||||
|
||||
packages.push(
|
||||
JavaPackage {
|
||||
archive: PackageArchive::from_filename(&binary.package.name),
|
||||
download_url: binary.package.link,
|
||||
java_version: java_version.clone(),
|
||||
name: binary.package.name,
|
||||
vendor: JavaVendor::Adoptium,
|
||||
checksum: None,
|
||||
size: binary.package.size,
|
||||
}
|
||||
.with_checksum(checksum),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
48
packages/oneclient_java/src/vendors/corretto.rs
vendored
48
packages/oneclient_java/src/vendors/corretto.rs
vendored
|
|
@ -3,6 +3,7 @@ use serde_json::Value;
|
|||
use url::Url;
|
||||
|
||||
use oneclient_net::RequestClient;
|
||||
use polyio::Checksum;
|
||||
|
||||
use crate::data::{JavaPackage, PackageArchive};
|
||||
use crate::error::JavaResult;
|
||||
|
|
@ -52,26 +53,47 @@ impl JavaRuntimeProvider for CorrettoRuntimeProvider {
|
|||
continue;
|
||||
}
|
||||
|
||||
let has_ext = by_ext
|
||||
.as_object()
|
||||
.is_some_and(|exts| exts.contains_key(CORRETTO_EXT.0));
|
||||
if !has_ext {
|
||||
let Some(entry) = by_ext.get(CORRETTO_EXT.0) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer the exact versioned URL the index names over the `latest`
|
||||
// redirect. Both resolve to the same build today, but `latest` is
|
||||
// resolved at download time: if Corretto publishes a new build in
|
||||
// the window between listing and downloading, the redirect moves and
|
||||
// the checksum from this index no longer describes those bytes. The
|
||||
// download would then fail as corrupt, three retries deep, for a
|
||||
// file that is perfectly intact.
|
||||
let download_url = entry
|
||||
.get("resource")
|
||||
.and_then(Value::as_str)
|
||||
.map_or_else(
|
||||
|| latest_url(this_major, CORRETTO_EXT.0),
|
||||
|resource| format!("https://corretto.aws{resource}"),
|
||||
);
|
||||
|
||||
let checksum = entry
|
||||
.get("checksum_sha256")
|
||||
.and_then(Value::as_str)
|
||||
.map(Checksum::sha256);
|
||||
|
||||
let download_url = latest_url(this_major, CORRETTO_EXT.0);
|
||||
let name = format!(
|
||||
"amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}",
|
||||
CORRETTO_EXT.0
|
||||
);
|
||||
|
||||
packages.push(JavaPackage {
|
||||
archive: CORRETTO_EXT.1,
|
||||
download_url,
|
||||
java_version: vec![this_major],
|
||||
name,
|
||||
vendor: JavaVendor::Corretto,
|
||||
});
|
||||
packages.push(
|
||||
JavaPackage {
|
||||
archive: CORRETTO_EXT.1,
|
||||
download_url,
|
||||
java_version: vec![this_major],
|
||||
name,
|
||||
vendor: JavaVendor::Corretto,
|
||||
checksum: None,
|
||||
size: None,
|
||||
}
|
||||
.with_checksum(checksum),
|
||||
);
|
||||
}
|
||||
|
||||
packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0)));
|
||||
|
|
|
|||
14
packages/oneclient_java/src/vendors/liberica.rs
vendored
14
packages/oneclient_java/src/vendors/liberica.rs
vendored
|
|
@ -3,6 +3,7 @@ use serde::Deserialize;
|
|||
use url::Url;
|
||||
|
||||
use oneclient_net::RequestClient;
|
||||
use polyio::Checksum;
|
||||
|
||||
use crate::data::{JavaPackage, PackageArchive};
|
||||
use crate::error::JavaResult;
|
||||
|
|
@ -23,6 +24,11 @@ struct LibericaRelease {
|
|||
#[serde(rename = "buildVersion", default)]
|
||||
build_version: u32,
|
||||
version: String,
|
||||
/// Liberica is the one vendor that publishes SHA-1 rather than SHA-256.
|
||||
#[serde(default)]
|
||||
sha1: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -59,13 +65,18 @@ impl JavaRuntimeProvider for LibericaRuntimeProvider {
|
|||
java_version.insert(0, r.feature_version);
|
||||
}
|
||||
|
||||
let checksum = r.sha1.as_deref().map(Checksum::sha1);
|
||||
|
||||
JavaPackage {
|
||||
archive: PackageArchive::from_filename(&r.filename),
|
||||
download_url: r.download_url,
|
||||
java_version,
|
||||
name: r.filename,
|
||||
vendor: JavaVendor::Liberica,
|
||||
checksum: None,
|
||||
size: r.size,
|
||||
}
|
||||
.with_checksum(checksum)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -88,7 +99,8 @@ fn liberica_url(major: Option<u32>) -> JavaResult<Url> {
|
|||
.append_pair("installation-type", "archive")
|
||||
.append_pair(
|
||||
"fields",
|
||||
"downloadUrl,filename,featureVersion,updateVersion,buildVersion,version",
|
||||
// `sha1` and `size` are only returned when named here.
|
||||
"downloadUrl,filename,featureVersion,updateVersion,buildVersion,version,sha1,size",
|
||||
)
|
||||
.append_pair("output", "json");
|
||||
if let Some(major) = major {
|
||||
|
|
|
|||
15
packages/oneclient_java/src/vendors/zulu.rs
vendored
15
packages/oneclient_java/src/vendors/zulu.rs
vendored
|
|
@ -3,6 +3,7 @@ use serde::Deserialize;
|
|||
use url::Url;
|
||||
|
||||
use oneclient_net::RequestClient;
|
||||
use polyio::Checksum;
|
||||
|
||||
use crate::data::{JavaPackage, PackageArchive};
|
||||
use crate::error::JavaResult;
|
||||
|
|
@ -16,6 +17,12 @@ struct ZuluPackage {
|
|||
download_url: String,
|
||||
name: String,
|
||||
java_version: Vec<u32>,
|
||||
/// Only present because `include_fields` asks for it; Azul omits it from
|
||||
/// the default projection.
|
||||
#[serde(default)]
|
||||
sha256_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -37,13 +44,18 @@ impl JavaRuntimeProvider for ZuluRuntimeProvider {
|
|||
}
|
||||
|
||||
fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage {
|
||||
let checksum = pkg.sha256_hash.as_deref().map(Checksum::sha256);
|
||||
|
||||
JavaPackage {
|
||||
archive: PackageArchive::Zip,
|
||||
download_url: pkg.download_url,
|
||||
java_version: pkg.java_version,
|
||||
name: pkg.name,
|
||||
vendor: JavaVendor::Zulu,
|
||||
checksum: None,
|
||||
size: pkg.size,
|
||||
}
|
||||
.with_checksum(checksum)
|
||||
}
|
||||
|
||||
fn zulu_url(major: Option<u32>) -> JavaResult<Url> {
|
||||
|
|
@ -60,6 +72,9 @@ fn zulu_url(major: Option<u32>) -> JavaResult<Url> {
|
|||
.append_pair("availability_types", "CA")
|
||||
.append_pair("certifications", "tck")
|
||||
.append_pair("latest", "true")
|
||||
// Azul's default projection omits the hash, so a download could
|
||||
// only ever be verified by asking for it explicitly.
|
||||
.append_pair("include_fields", "sha256_hash,size")
|
||||
.append_pair("page", "1")
|
||||
.append_pair("page_size", "100");
|
||||
if let Some(major) = major {
|
||||
|
|
|
|||
98
packages/oneclient_java/tests/vendor_checksums.rs
Normal file
98
packages/oneclient_java/tests/vendor_checksums.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use oneclient_java::vendors::runtime_providers;
|
||||
use oneclient_net::{NetConfig, RequestClient};
|
||||
use polyio::ChecksumAlgorithm;
|
||||
|
||||
/// A major every vendor still publishes, so a listing coming back empty means
|
||||
/// the query is wrong rather than the release being retired.
|
||||
const MAJOR: u32 = 21;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires network"]
|
||||
async fn every_vendor_publishes_a_usable_checksum() {
|
||||
let net = RequestClient::new(NetConfig::default()).expect("a client");
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for provider in runtime_providers() {
|
||||
let vendor = provider.vendor();
|
||||
|
||||
let packages = match provider.list_packages(Some(MAJOR), &net).await {
|
||||
Ok(packages) => packages,
|
||||
Err(err) => {
|
||||
failures.push(format!("{vendor}: listing failed: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if packages.is_empty() {
|
||||
failures.push(format!("{vendor}: no packages listed for major {MAJOR}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let unverifiable = packages
|
||||
.iter()
|
||||
.filter(|package| package.checksum.is_none())
|
||||
.count();
|
||||
|
||||
if unverifiable > 0 {
|
||||
failures.push(format!(
|
||||
"{vendor}: {unverifiable}/{} packages carry no usable checksum",
|
||||
packages.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// A well-formed hash in the wrong algorithm would fail every download,
|
||||
// so assert the algorithm each vendor actually publishes.
|
||||
for package in &packages {
|
||||
let checksum = package.checksum.as_ref().expect("checked above");
|
||||
assert!(
|
||||
checksum.is_well_formed(),
|
||||
"{vendor}: malformed checksum on {}: {}",
|
||||
package.name,
|
||||
checksum.hex
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{vendor}: {} packages, all carrying {}",
|
||||
packages.len(),
|
||||
packages[0]
|
||||
.checksum
|
||||
.as_ref()
|
||||
.expect("checked above")
|
||||
.algorithm
|
||||
.name()
|
||||
);
|
||||
}
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires network"]
|
||||
async fn liberica_publishes_sha1_and_the_rest_publish_sha256() {
|
||||
let net = RequestClient::new(NetConfig::default()).expect("a client");
|
||||
|
||||
for provider in runtime_providers() {
|
||||
let vendor = provider.vendor();
|
||||
let packages = provider
|
||||
.list_packages(Some(MAJOR), &net)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{vendor}: listing failed: {err}"));
|
||||
|
||||
let Some(checksum) = packages.first().and_then(|p| p.checksum.as_ref()) else {
|
||||
panic!("{vendor}: no checksum to check the algorithm of");
|
||||
};
|
||||
|
||||
let expected = match vendor.to_string().as_str() {
|
||||
"Liberica" => ChecksumAlgorithm::Sha1,
|
||||
_ => ChecksumAlgorithm::Sha256,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
checksum.algorithm,
|
||||
expected,
|
||||
"{vendor} changed the algorithm it publishes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,14 +29,18 @@ pub async fn download_to_path(
|
|||
) -> McResult<()> {
|
||||
let child = progress.child(label, expected_size, category);
|
||||
|
||||
// Every hash in a Minecraft manifest is SHA-1, so callers here pass a bare
|
||||
// string and the algorithm is implied.
|
||||
let expected = expected_sha1.map(polyio::Checksum::sha1);
|
||||
|
||||
oneclient_net::download_verified(
|
||||
requester,
|
||||
events,
|
||||
url,
|
||||
dest,
|
||||
expected_sha1,
|
||||
expected.as_ref(),
|
||||
expected_size,
|
||||
Some(child),
|
||||
Some(oneclient_net::ResponseNotifyOptions::grouped(child)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ pub enum McError {
|
|||
#[error("no matching version found")]
|
||||
NoMatchingVersion,
|
||||
|
||||
/// Files could not be downloaded and the user chose not to launch anyway.
|
||||
#[error("cancelled: {failed} file(s) could not be downloaded")]
|
||||
IncompleteInstallCancelled { failed: usize },
|
||||
|
||||
/// A Mojang response was well-formed JSON but not what the API documents.
|
||||
#[error("minecraft: {0}")]
|
||||
Minecraft(String),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::rules::validate_rules;
|
|||
use crate::manifest::MetadataStore;
|
||||
use oneclient_common::os_ext::OsExt;
|
||||
use oneclient_common::domain::GameLoader;
|
||||
use oneclient_events::{GroupedProgressSession, TaskCategory};
|
||||
use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase};
|
||||
use oneclient_common::paths;
|
||||
use crate::McCtx;
|
||||
use crate::error::{McError, McResult};
|
||||
|
|
@ -55,6 +55,33 @@ fn asset_object_path(dir: &Path, name: &str, hash: &str, legacy: bool) -> PathBu
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is present and as long as the manifest promised.
|
||||
///
|
||||
/// `exists()` and `metadata()` are the same `stat` syscall, so checking the
|
||||
/// length costs nothing over the presence check it replaces — and it catches the
|
||||
/// truncated files an interrupted download leaves behind, which is what nearly
|
||||
/// all download corruption looks like in practice. Comparing content instead
|
||||
/// would mean reading and hashing every one of ~5000 assets on every launch;
|
||||
/// that belongs behind an explicit repair action, not in the launch path.
|
||||
///
|
||||
/// An `expected` of 0 means the manifest did not say, so presence is all there
|
||||
/// is to go on.
|
||||
fn matches_expected_size(path: &Path, expected: u64) -> bool {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) => expected == 0 || meta.len() == expected,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The artifact size a library's manifest entry promises, or 0 when it carries
|
||||
/// no artifact metadata (maven-coordinate libraries from loader manifests).
|
||||
fn library_artifact_size(lib: &Library) -> u64 {
|
||||
lib.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.map_or(0, |artifact| u64::from(artifact.size))
|
||||
}
|
||||
|
||||
fn native_download_size(lib: &Library, java_arch: &str) -> u64 {
|
||||
let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| {
|
||||
Some((
|
||||
|
|
@ -106,7 +133,7 @@ pub async fn plan_downloads(
|
|||
|
||||
for (name, asset) in assets_index.objects {
|
||||
let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy);
|
||||
if !force && path.exists() {
|
||||
if !force && matches_expected_size(&path, u64::from(asset.size)) {
|
||||
continue;
|
||||
}
|
||||
plan.asset_bytes += u64::from(asset.size);
|
||||
|
|
@ -130,21 +157,21 @@ pub async fn plan_downloads(
|
|||
continue;
|
||||
};
|
||||
|
||||
if !force && lib_dir.join(&artifact_path).exists() {
|
||||
if !force
|
||||
&& matches_expected_size(&lib_dir.join(&artifact_path), library_artifact_size(&lib))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
plan.library_bytes += lib
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.map_or(0, |artifact| u64::from(artifact.size));
|
||||
plan.library_bytes += library_artifact_size(&lib);
|
||||
plan.library_bytes += native_download_size(&lib, &java_arch);
|
||||
plan.libraries.push(lib);
|
||||
}
|
||||
|
||||
if let Some((path, size)) = client {
|
||||
let present = path.map(|path| path.exists()).unwrap_or(false);
|
||||
let present = path
|
||||
.map(|path| matches_expected_size(&path, size))
|
||||
.unwrap_or(false);
|
||||
if force || !present {
|
||||
plan.client = true;
|
||||
plan.client_bytes = size;
|
||||
|
|
@ -158,6 +185,165 @@ pub async fn plan_downloads(
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// What a hash-level sweep of the installed game files found.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VerifyReport {
|
||||
/// Files present on disk that were read and hashed.
|
||||
pub checked: usize,
|
||||
/// Files whose contents did not match the manifest and were removed.
|
||||
pub corrupt: usize,
|
||||
/// Files the manifest lists that were not on disk at all.
|
||||
pub missing: usize,
|
||||
}
|
||||
|
||||
impl VerifyReport {
|
||||
/// Whether anything needs re-downloading.
|
||||
#[must_use]
|
||||
pub fn needs_repair(&self) -> bool {
|
||||
self.corrupt > 0 || self.missing > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and hashes every installed game file, deleting the ones that do not
|
||||
/// match the manifest.
|
||||
///
|
||||
/// This is the expensive counterpart to the size check the launch path uses:
|
||||
/// it reads every asset and library off disk, which is why it belongs behind an
|
||||
/// explicit user action rather than in the pre-launch flow. Size catches the
|
||||
/// truncation an interrupted download leaves; only hashing catches a file whose
|
||||
/// bytes went bad some other way — a failing disk, a half-synced cloud folder,
|
||||
/// an antivirus rewrite, or a partial file written before the launcher started
|
||||
/// publishing downloads by rename.
|
||||
///
|
||||
/// Nothing is re-downloaded here. Corrupt files are removed so that the normal
|
||||
/// download path, which already knows how to fetch and verify exactly what is
|
||||
/// missing, repairs them on the next prepare.
|
||||
#[tracing::instrument(skip(progress, version, assets_index), fields(version_id = %version.id), level = "debug")]
|
||||
pub async fn verify_game_files(
|
||||
progress: &GroupedProgressSession,
|
||||
version: &VersionInfo,
|
||||
assets_index: AssetsIndex,
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> McResult<VerifyReport> {
|
||||
let legacy = version.assets == "legacy";
|
||||
let asset_dir = if legacy {
|
||||
paths::legacy_assets_dir()?
|
||||
} else {
|
||||
paths::assets_object_dir()?
|
||||
};
|
||||
let lib_dir = paths::libraries_dir()?;
|
||||
|
||||
// Assets are content-addressed by their own hash; libraries and the client
|
||||
// carry theirs in the manifest.
|
||||
let mut targets: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
for (name, asset) in assets_index.objects {
|
||||
let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy);
|
||||
targets.push((path, asset.hash));
|
||||
}
|
||||
|
||||
for lib in &version.libraries {
|
||||
if let Some(rules) = &lib.rules
|
||||
&& !validate_rules(rules, java_arch, minecraft_updated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !lib.downloadable {
|
||||
continue;
|
||||
}
|
||||
let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else {
|
||||
continue;
|
||||
};
|
||||
// Only libraries whose manifest entry carries a hash can be checked;
|
||||
// maven-coordinate entries from loader manifests publish none.
|
||||
if let Some(artifact) = lib
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
&& !artifact.sha1.is_empty()
|
||||
{
|
||||
targets.push((lib_dir.join(&artifact_path), artifact.sha1.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(client) = version.downloads.get(&DownloadType::Client) {
|
||||
let path = paths::versions_dir()?
|
||||
.join(&version.id)
|
||||
.join(format!("{}.jar", version.id));
|
||||
targets.push((path, client.sha1.clone()));
|
||||
}
|
||||
|
||||
let total = targets.len() as u64;
|
||||
let child = progress.child("Verifying game files", total.max(1), TaskCategory::Assets);
|
||||
child.set_phase(TaskPhase::Verifying);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let child_for_progress = child.clone();
|
||||
|
||||
// One blocking task for the whole sweep rather than one per file: hashing
|
||||
// thousands of small assets is dominated by per-file overhead, and a
|
||||
// spawn_blocking dispatch each would cost more than the hashing itself.
|
||||
let report = tokio::task::spawn_blocking(move || {
|
||||
let mut report = VerifyReport::default();
|
||||
|
||||
for (index, (path, expected)) in targets.iter().enumerate() {
|
||||
if !path.is_file() {
|
||||
report.missing += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match polyio::sha1_file_sync(path) {
|
||||
Ok(actual) if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => {
|
||||
report.checked += 1;
|
||||
}
|
||||
Ok(actual) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
%actual,
|
||||
%expected,
|
||||
"corrupt game file; removing so it is re-downloaded"
|
||||
);
|
||||
report.checked += 1;
|
||||
report.corrupt += 1;
|
||||
if let Err(err) = std::fs::remove_file(path) {
|
||||
tracing::error!(path = %path.display(), "could not remove: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Unreadable is as good as corrupt: the game cannot use it
|
||||
// either. Removing it lets the download path replace it.
|
||||
tracing::warn!(path = %path.display(), "could not hash: {err}");
|
||||
report.corrupt += 1;
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Reporting every file would emit thousands of events; the child's
|
||||
// own sampling is per-call, so throttle here instead.
|
||||
if index % 64 == 0 {
|
||||
child_for_progress.set_progress(index as u64, Some(total.max(1)));
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
child.finish();
|
||||
|
||||
tracing::info!(
|
||||
checked = report.checked,
|
||||
corrupt = report.corrupt,
|
||||
missing = report.missing,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"verified game files"
|
||||
);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn download_minecraft(
|
||||
ctx: &McCtx,
|
||||
|
|
@ -197,7 +383,7 @@ pub async fn download_minecraft(
|
|||
assets, libraries, ..
|
||||
} = plan;
|
||||
|
||||
tokio::try_join!(
|
||||
let (failed_assets, _client, failed_libraries) = tokio::try_join!(
|
||||
download_assets(
|
||||
ctx,
|
||||
progress,
|
||||
|
|
@ -214,6 +400,8 @@ pub async fn download_minecraft(
|
|||
),
|
||||
)?;
|
||||
|
||||
confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?;
|
||||
|
||||
tracing::info!(
|
||||
version = %version.id,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
|
|
@ -223,6 +411,93 @@ pub async fn download_minecraft(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// What the user chose to do about files that could not be downloaded.
|
||||
enum IncompleteInstall {
|
||||
LaunchAnyway,
|
||||
}
|
||||
|
||||
/// Asks whether to launch with files missing, when some could not be fetched.
|
||||
///
|
||||
/// Individual assets and libraries are downloaded concurrently and their
|
||||
/// failures counted rather than propagated, so that one unreachable file does
|
||||
/// not abandon the other four thousand. That count used to be logged and
|
||||
/// dropped, which meant an install that had visibly failed still went on to
|
||||
/// launch a game with missing textures, sounds, or a library the loader needs —
|
||||
/// and the user's only clue was a crash later.
|
||||
///
|
||||
/// Each of these already survived every retry `oneclient_net` makes, so this is
|
||||
/// not a transient blip. It is still the user's call: a
|
||||
/// handful of missing assets is often survivable, and refusing to launch would
|
||||
/// strand someone offline who just wants to play.
|
||||
#[tracing::instrument(skip(ctx), level = "debug")]
|
||||
pub async fn confirm_incomplete_install(
|
||||
ctx: &McCtx,
|
||||
failed_assets: usize,
|
||||
failed_libraries: usize,
|
||||
) -> McResult<()> {
|
||||
let failed = failed_assets + failed_libraries;
|
||||
if failed == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
failed_assets,
|
||||
failed_libraries,
|
||||
"some files could not be downloaded; asking the user how to proceed"
|
||||
);
|
||||
|
||||
let body = match (failed_assets, failed_libraries) {
|
||||
// Libraries are on the classpath, so a missing one usually means the
|
||||
// game will not start at all. Say so rather than implying it is as
|
||||
// survivable as a missing sound file.
|
||||
(0, libraries) => format!(
|
||||
"{libraries} librar{} could not be downloaded after several attempts. \
|
||||
The game will most likely fail to start without {}.",
|
||||
if libraries == 1 { "y" } else { "ies" },
|
||||
if libraries == 1 { "it" } else { "them" },
|
||||
),
|
||||
(assets, 0) => format!(
|
||||
"{assets} game asset{} could not be downloaded after several attempts. \
|
||||
Sounds or textures may be missing.",
|
||||
if assets == 1 { "" } else { "s" },
|
||||
),
|
||||
(assets, libraries) => format!(
|
||||
"{assets} asset(s) and {libraries} librar{} could not be downloaded after \
|
||||
several attempts. The game may fail to start or be missing content.",
|
||||
if libraries == 1 { "y" } else { "ies" },
|
||||
),
|
||||
};
|
||||
|
||||
let answer = ctx
|
||||
.events
|
||||
.ask(
|
||||
Prompt::new("Some files could not be downloaded", body)
|
||||
.option(
|
||||
Choice::new("launch", "Launch anyway"),
|
||||
IncompleteInstall::LaunchAnyway,
|
||||
)
|
||||
.dismiss("Cancel"),
|
||||
)
|
||||
.await;
|
||||
|
||||
match answer {
|
||||
Ok(Some(chosen)) => match chosen.value {
|
||||
IncompleteInstall::LaunchAnyway => {
|
||||
tracing::warn!(failed, "user chose to launch with files missing");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
// Dismissed, or the prompt could not be delivered. Both mean nobody
|
||||
// agreed to launch a broken install, so the safe reading is to stop —
|
||||
// a headless caller with no UI attached should not silently opt in.
|
||||
Ok(None) => Err(McError::IncompleteInstallCancelled { failed }),
|
||||
Err(err) => {
|
||||
tracing::warn!("could not ask about the incomplete install: {err}");
|
||||
Err(McError::IncompleteInstallCancelled { failed })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(ctx, progress), level = "debug")]
|
||||
pub async fn download_version_info(
|
||||
ctx: &McCtx,
|
||||
|
|
@ -462,14 +737,21 @@ pub async fn download_client(
|
|||
.join(&version.id)
|
||||
.join(format!("{}.jar", version.id));
|
||||
|
||||
if path.exists()
|
||||
&& !force
|
||||
&& let Ok(actual) = polyio::sha1_file(&path).await
|
||||
// A size check, not a hash: this runs on every launch, and re-reading the
|
||||
// whole ~25 MiB jar to hash it costs more than everything else in the
|
||||
// pre-launch check combined. The jar is only ever published by a rename
|
||||
// after its hash matched, so a wrong-but-right-length jar is not a state the
|
||||
// launcher can produce.
|
||||
let expected_size = u64::from(client.size);
|
||||
if !force
|
||||
&& let Ok(meta) = polyio::stat(&path).await
|
||||
&& (expected_size == 0 || meta.len() == expected_size)
|
||||
{
|
||||
if polyio::normalize_hash(&actual) == polyio::normalize_hash(&client.sha1) {
|
||||
return Ok(path);
|
||||
}
|
||||
tracing::warn!("client hash mismatch, redownloading");
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
tracing::warn!("client jar is the wrong size, redownloading");
|
||||
}
|
||||
|
||||
download_to_path(
|
||||
|
|
@ -506,14 +788,79 @@ pub fn libraries_missing(
|
|||
let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else {
|
||||
continue;
|
||||
};
|
||||
if !lib_dir.join(&rel).exists() {
|
||||
tracing::warn!(library = %lib.name, "missing classpath library; will repair");
|
||||
if !matches_expected_size(&lib_dir.join(&rel), library_artifact_size(lib)) {
|
||||
tracing::warn!(library = %lib.name, "missing or truncated classpath library; will repair");
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Whether the assets tree this version needs is absent.
|
||||
///
|
||||
/// The index names every asset the version expects and lives inside the assets
|
||||
/// directory, so checking both catches a deleted index and a deleted assets
|
||||
/// tree — the second being the case users actually hit.
|
||||
///
|
||||
/// Takes its paths rather than resolving them, so it can be tested without the
|
||||
/// process-global launcher directory.
|
||||
fn assets_tree_missing(index: &Path, objects: &Path) -> bool {
|
||||
if !index.is_file() {
|
||||
tracing::warn!(path = %index.display(), "assets index is missing; will repair");
|
||||
return true;
|
||||
}
|
||||
|
||||
if !objects.is_dir() {
|
||||
tracing::warn!(path = %objects.display(), "assets directory is missing; will repair");
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether anything the game needs to start is absent, so the launch path can
|
||||
/// repair before it tries rather than fail part-way through.
|
||||
///
|
||||
/// A cluster marked `Ready` skips preparation entirely on every later launch,
|
||||
/// so nothing re-checks its files. Delete the assets directory out from under a
|
||||
/// ready cluster and the launcher walks straight into an error naming a path
|
||||
/// inside its own metadata folder — which tells the user nothing they can act
|
||||
/// on. Checking here turns that into an automatic repair.
|
||||
///
|
||||
/// Only the coarse checks belong here: the assets tree as a whole, the client
|
||||
/// jar, and the classpath libraries. Verifying the ~5000 individual asset
|
||||
/// objects is the job of `download_minecraft`'s plan once a repair starts, and
|
||||
/// doing it on every launch would cost far more than it saves.
|
||||
#[tracing::instrument(skip(version_info), level = "debug")]
|
||||
pub fn game_files_missing(
|
||||
version_info: &VersionInfo,
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> McResult<bool> {
|
||||
let index = paths::assets_index_dir()?.join(format!("{}.json", version_info.asset_index.id));
|
||||
let objects = if version_info.assets == "legacy" {
|
||||
paths::legacy_assets_dir()?
|
||||
} else {
|
||||
paths::assets_object_dir()?
|
||||
};
|
||||
|
||||
if assets_tree_missing(&index, &objects) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(client) = version_info.downloads.get(&DownloadType::Client) {
|
||||
let jar = paths::versions_dir()?
|
||||
.join(&version_info.id)
|
||||
.join(format!("{}.jar", version_info.id));
|
||||
if !matches_expected_size(&jar, u64::from(client.size)) {
|
||||
tracing::warn!(path = %jar.display(), "client jar is missing or truncated; will repair");
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
libraries_missing(version_info, java_arch, minecraft_updated)
|
||||
}
|
||||
|
||||
fn lib_short(name: &str) -> String {
|
||||
let parts: Vec<&str> = name.split(':').collect();
|
||||
match parts.as_slice() {
|
||||
|
|
@ -820,3 +1167,217 @@ pub async fn get_loaders_for_version(
|
|||
pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool {
|
||||
version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"oneclient-install-{tag}-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Lays out the assets tree a ready cluster is expected to have, returning
|
||||
/// the index file and objects directory that `game_files_missing` checks.
|
||||
fn install_assets(dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let indexes = dir.join("assets").join("indexes");
|
||||
let objects = dir.join("assets").join("objects");
|
||||
std::fs::create_dir_all(&indexes).unwrap();
|
||||
std::fs::create_dir_all(&objects).unwrap();
|
||||
|
||||
let index = indexes.join("17.json");
|
||||
std::fs::write(&index, b"{}").unwrap();
|
||||
|
||||
(index, objects)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_assets_tree_needs_no_repair() {
|
||||
let dir = scratch("assets-ok");
|
||||
let (index, objects) = install_assets(&dir);
|
||||
|
||||
assert!(!assets_tree_missing(&index, &objects));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_assets_directory_asks_for_a_repair() {
|
||||
// The reported failure: a cluster marked Ready whose assets tree was
|
||||
// deleted underneath it. Nothing re-checked it, so the launch walked
|
||||
// into "No such file or directory" on the launcher's own metadata path.
|
||||
let dir = scratch("assets-deleted");
|
||||
let (index, objects) = install_assets(&dir);
|
||||
std::fs::remove_dir_all(dir.join("assets")).unwrap();
|
||||
|
||||
assert!(assets_tree_missing(&index, &objects));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_assets_index_asks_for_a_repair() {
|
||||
let dir = scratch("assets-no-index");
|
||||
let (index, objects) = install_assets(&dir);
|
||||
std::fs::remove_file(&index).unwrap();
|
||||
|
||||
assert!(assets_tree_missing(&index, &objects));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_that_is_a_directory_is_not_mistaken_for_a_file() {
|
||||
// `create_dir_all` on a path that should hold a file is a shape the
|
||||
// launcher has produced before; `is_file` has to be the check, not
|
||||
// `exists`.
|
||||
let dir = scratch("assets-index-dir");
|
||||
let (index, objects) = install_assets(&dir);
|
||||
std::fs::remove_file(&index).unwrap();
|
||||
std::fs::create_dir(&index).unwrap();
|
||||
|
||||
assert!(assets_tree_missing(&index, &objects));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_check_accepts_a_complete_file() {
|
||||
let dir = scratch("size-ok");
|
||||
let file = dir.join("object.bin");
|
||||
std::fs::write(&file, b"0123456789").unwrap();
|
||||
|
||||
assert!(matches_expected_size(&file, 10));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_check_rejects_a_truncated_file() {
|
||||
let dir = scratch("size-short");
|
||||
let file = dir.join("object.bin");
|
||||
// What a dropped connection used to leave behind, and what every later
|
||||
// launch used to skip over because the path merely existed.
|
||||
std::fs::write(&file, b"0123").unwrap();
|
||||
|
||||
assert!(!matches_expected_size(&file, 10));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_check_rejects_a_missing_file() {
|
||||
let dir = scratch("size-missing");
|
||||
|
||||
assert!(!matches_expected_size(&dir.join("nope.bin"), 10));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A context whose bus is drained by the test, so `ask` has somebody to
|
||||
/// talk to. No request is ever sent through the client.
|
||||
fn test_ctx() -> (McCtx, tokio::sync::mpsc::UnboundedReceiver<oneclient_events::Event>) {
|
||||
let (events, rx) = oneclient_events::EventBus::channel();
|
||||
let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default())
|
||||
.expect("a client");
|
||||
(McCtx::new(net, events), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_complete_install_asks_nothing() {
|
||||
let (ctx, mut rx) = test_ctx();
|
||||
|
||||
confirm_incomplete_install(&ctx, 0, 0)
|
||||
.await
|
||||
.expect("nothing failed, so nothing to confirm");
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"a clean install must not interrupt the user"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launching_anyway_is_allowed() {
|
||||
let (ctx, mut rx) = test_ctx();
|
||||
|
||||
let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await });
|
||||
|
||||
let Some(oneclient_events::Event::Notification(
|
||||
oneclient_events::Notification::Prompt(request),
|
||||
)) = rx.recv().await
|
||||
else {
|
||||
panic!("expected a prompt");
|
||||
};
|
||||
// Assets alone should not claim the game will fail to start.
|
||||
assert!(request.body.contains("3 game assets"), "{}", request.body);
|
||||
request
|
||||
.reply
|
||||
.send(Some(oneclient_events::Answer::new("launch")))
|
||||
.unwrap();
|
||||
|
||||
asking.await.unwrap().expect("the user opted in");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismissing_the_prompt_cancels_the_launch() {
|
||||
let (ctx, mut rx) = test_ctx();
|
||||
|
||||
let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await });
|
||||
|
||||
let Some(oneclient_events::Event::Notification(
|
||||
oneclient_events::Notification::Prompt(request),
|
||||
)) = rx.recv().await
|
||||
else {
|
||||
panic!("expected a prompt");
|
||||
};
|
||||
// Missing libraries are a classpath problem, so the wording should be
|
||||
// blunter than it is for assets.
|
||||
assert!(
|
||||
request.body.contains("fail to start"),
|
||||
"{}",
|
||||
request.body
|
||||
);
|
||||
request.reply.send(None).unwrap();
|
||||
|
||||
let err = asking.await.unwrap().expect_err("dismissal must cancel");
|
||||
assert!(
|
||||
matches!(err, McError::IncompleteInstallCancelled { failed: 2 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bus_with_nobody_listening_cancels_rather_than_launching() {
|
||||
let (ctx, rx) = test_ctx();
|
||||
// A headless run with no UI attached: nobody can agree to a broken
|
||||
// install, so it must not proceed by default.
|
||||
drop(rx);
|
||||
|
||||
let err = confirm_incomplete_install(&ctx, 1, 1)
|
||||
.await
|
||||
.expect_err("an unanswerable prompt must not launch the game");
|
||||
assert!(
|
||||
matches!(err, McError::IncompleteInstallCancelled { failed: 2 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_check_falls_back_to_presence_without_a_manifest_size() {
|
||||
let dir = scratch("size-unknown");
|
||||
let file = dir.join("object.bin");
|
||||
std::fs::write(&file, b"anything").unwrap();
|
||||
|
||||
// Maven-coordinate libraries carry no artifact metadata, so presence is
|
||||
// all there is to check.
|
||||
assert!(matches_expected_size(&file, 0));
|
||||
assert!(!matches_expected_size(&dir.join("nope.bin"), 0));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,40 @@
|
|||
use std::path::Path;
|
||||
|
||||
use oneclient_events::{EventBus, GroupedProgressChild, TaskPhase};
|
||||
use polyio::{Sha1Stream, normalize_hash};
|
||||
use polyio::{Checksum, ChecksumStream, normalize_hash};
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::error::RequestError;
|
||||
use crate::response::{ResponseExt, ResponseNotifyOptions, ResponseOptions};
|
||||
use crate::service::RequestClient;
|
||||
|
||||
/// How many times one file is fetched before it counts as failed.
|
||||
///
|
||||
/// [`RequestClient::send`] already retries what fails before the response
|
||||
/// headers land; this covers what fails after, which on an unstable connection
|
||||
/// is most of it. Without a retry here a single dropped chunk permanently fails
|
||||
/// that one file and the install is quietly left incomplete.
|
||||
const MAX_DOWNLOAD_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Whether fetching the same URL again has a realistic chance of working.
|
||||
///
|
||||
/// A transport error or a hash mismatch means the bytes arrived wrong, which is
|
||||
/// exactly what a retry fixes. Everything else — a 404, a bad URL, a full disk —
|
||||
/// will fail the same way every time, and retrying only multiplies the delay
|
||||
/// before the user sees the real error.
|
||||
fn worth_retrying(err: &RequestError) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
RequestError::ReqwestError(_) | RequestError::HashMismatch { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Grows with each attempt, so a link that is briefly saturated gets a chance to
|
||||
/// recover instead of being hit again immediately.
|
||||
fn retry_delay(attempt: u32) -> std::time::Duration {
|
||||
std::time::Duration::from_millis(300 * u64::from(attempt))
|
||||
}
|
||||
|
||||
/// Whether a file already on disk matches `expected_sha1`.
|
||||
///
|
||||
/// Hashing an existing file is cheaper than re-downloading it, so every caller
|
||||
|
|
@ -32,20 +59,26 @@ pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Downloads `url` to `dest`, verifying SHA-1 from the bytes as they stream past.
|
||||
/// Downloads `url` to `dest`, verifying it from the bytes as they stream past.
|
||||
///
|
||||
/// On a mismatch the partial file is removed, so a retry cannot mistake it for a
|
||||
/// complete download. `progress` is optional: pass a child when the caller is
|
||||
/// running a grouped session, `None` for a one-off.
|
||||
#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))]
|
||||
/// Hashing happens on the bytes in flight, so verification costs no extra disk
|
||||
/// read and a good file is never hashed twice. `dest` is only created once the
|
||||
/// hash matches — a failed or corrupt attempt leaves nothing behind for a later
|
||||
/// `exists()` check to trip over. Transport failures and mismatches are retried
|
||||
/// up to [`MAX_DOWNLOAD_ATTEMPTS`] times before the error surfaces.
|
||||
///
|
||||
/// `notify` is optional: pass [`ResponseNotifyOptions::grouped`] when the caller
|
||||
/// is running a grouped session, [`ResponseNotifyOptions::standalone`] for a
|
||||
/// one-off with its own notification, or `None` to download silently.
|
||||
#[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))]
|
||||
pub async fn download_verified(
|
||||
client: &RequestClient,
|
||||
events: &EventBus,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
expected_sha1: Option<&str>,
|
||||
expected: Option<&Checksum>,
|
||||
expected_size: u64,
|
||||
progress: Option<GroupedProgressChild>,
|
||||
notify: Option<ResponseNotifyOptions>,
|
||||
) -> Result<(), RequestError> {
|
||||
// Callers that fan out over thousands of files (assets) pre-create the tree,
|
||||
// so this is usually a stat that avoids a blocking-pool round trip per file.
|
||||
|
|
@ -55,6 +88,64 @@ pub async fn download_verified(
|
|||
polyio::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// A standalone notification needs a fixed id up front, or each retry opens a
|
||||
// new entry in the UI instead of updating the one already there.
|
||||
let notify = notify.map(ResponseNotifyOptions::pinned);
|
||||
|
||||
let mut attempt = 1;
|
||||
loop {
|
||||
match download_attempt(
|
||||
client,
|
||||
events,
|
||||
url,
|
||||
dest,
|
||||
expected,
|
||||
expected_size,
|
||||
notify.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => break,
|
||||
Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
%url,
|
||||
"download failed, retrying: {err}"
|
||||
);
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
attempt += 1;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) {
|
||||
child.finish();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One pass of [`download_verified`]: fetch, hash in flight, publish on match.
|
||||
///
|
||||
/// Split out so a retry re-enters with a fresh request and a fresh hasher. The
|
||||
/// progress child is shared across attempts and reports absolute byte counts, so
|
||||
/// an attempt that restarts simply rewinds its bar rather than double-counting.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_attempt(
|
||||
client: &RequestClient,
|
||||
events: &EventBus,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
expected: Option<&Checksum>,
|
||||
expected_size: u64,
|
||||
notify: Option<&ResponseNotifyOptions>,
|
||||
) -> Result<(), RequestError> {
|
||||
let child = notify.and_then(ResponseNotifyOptions::child);
|
||||
if let Some(child) = child {
|
||||
child.set_phase(TaskPhase::Downloading);
|
||||
}
|
||||
|
||||
let request = reqwest::Request::new(Method::GET, url.parse()?);
|
||||
let response = client.send(request).await?;
|
||||
|
||||
|
|
@ -66,11 +157,11 @@ pub async fn download_verified(
|
|||
};
|
||||
|
||||
let options = ResponseOptions {
|
||||
notify: progress.clone().map(ResponseNotifyOptions::grouped),
|
||||
notify: notify.cloned(),
|
||||
};
|
||||
let stream = response.stream(options, events).await?;
|
||||
|
||||
let mut hasher = expected_sha1.map(|_| Sha1Stream::new());
|
||||
let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm));
|
||||
{
|
||||
let stream = futures_lite::StreamExt::map(stream, |item| {
|
||||
if let (Ok(chunk), Some(hasher)) = (&item, hasher.as_mut()) {
|
||||
|
|
@ -79,29 +170,29 @@ pub async fn download_verified(
|
|||
item
|
||||
});
|
||||
let stream = std::pin::pin!(stream);
|
||||
// Writes to a scratch sibling and renames on success, so `dest` is either
|
||||
// absent or complete — never a truncated file a later run would skip.
|
||||
polyio::write_stream(dest, stream, Some(total)).await?;
|
||||
}
|
||||
|
||||
if let (Some(expected), Some(hasher)) = (expected_sha1, hasher) {
|
||||
if let Some(child) = &progress {
|
||||
if let (Some(expected), Some(hasher)) = (expected, hasher) {
|
||||
if let Some(child) = child {
|
||||
child.set_phase(TaskPhase::Verifying);
|
||||
}
|
||||
|
||||
let actual = hasher.finish();
|
||||
if actual != normalize_hash(expected) {
|
||||
if !expected.matches(&actual) {
|
||||
// The bytes were wrong, so the file that was just published is wrong
|
||||
// too. Remove it rather than leave it for the next `exists()` check.
|
||||
let _ = polyio::remove_file(dest).await;
|
||||
return Err(RequestError::HashMismatch {
|
||||
source_desc: dest.display().to_string(),
|
||||
expected: expected.to_string(),
|
||||
expected: format!("{} {}", expected.algorithm.name(), expected.hex),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(child) = progress {
|
||||
child.finish();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,28 @@ impl ResponseNotifyOptions {
|
|||
self.done_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The grouped child these options report against, if any. Lets a caller
|
||||
/// driving the download set phases and finish the child without having to
|
||||
/// keep a second copy of it alongside the options.
|
||||
#[must_use]
|
||||
pub fn child(&self) -> Option<&GroupedProgressChild> {
|
||||
self.child.as_ref()
|
||||
}
|
||||
|
||||
/// Fixes the standalone notification's id, so a caller that sends the same
|
||||
/// request more than once keeps updating one notification.
|
||||
///
|
||||
/// Without this a retried download opens a fresh entry in the UI on every
|
||||
/// attempt, and a flaky connection reads as several stalled installs rather
|
||||
/// than one that is recovering.
|
||||
#[must_use]
|
||||
pub fn pinned(mut self) -> Self {
|
||||
if self.standalone_label.is_some() && self.standalone_id.is_none() {
|
||||
self.standalone_id = Some(Uuid::new_v4());
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ bytes.workspace = true
|
|||
# content hashing
|
||||
digest.workspace = true
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
junction.workspace = true
|
||||
|
|
|
|||
|
|
@ -249,6 +249,19 @@ where
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Streams `stream` into `path`, which only ever appears complete.
|
||||
///
|
||||
/// The bytes go to a sibling scratch file that is renamed over `path` once the
|
||||
/// stream ends cleanly, so a dropped connection cannot leave a truncated file
|
||||
/// sitting at the real path where every later `exists()` check would mistake it
|
||||
/// for a finished download. On any error the scratch file is removed and
|
||||
/// whatever was already at `path` is left untouched — a failed re-download of a
|
||||
/// good file is a no-op rather than a deletion.
|
||||
///
|
||||
/// Unlike [`write_atomic`] this deliberately does not fsync: that would be the
|
||||
/// single most expensive operation in a five-thousand-file asset download, and
|
||||
/// nothing here needs to survive power loss. A crash can still leave a short
|
||||
/// file behind, which the caller's size check catches on the next run.
|
||||
#[tracing::instrument(
|
||||
level = "debug",
|
||||
skip(path, stream),
|
||||
|
|
@ -264,24 +277,36 @@ where
|
|||
E: From<IOError>,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
let file = tokio::fs::File::create(path).await.map_err(IOError::from)?;
|
||||
let tmp = temp_sibling(path);
|
||||
|
||||
let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file);
|
||||
let write = async {
|
||||
let file = tokio::fs::File::create(&tmp).await.map_err(IOError::from)?;
|
||||
let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file);
|
||||
|
||||
let mut write_result = Ok(());
|
||||
while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await {
|
||||
let chunk = chunk_result?;
|
||||
while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await {
|
||||
let chunk = chunk_result?;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk)
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
write_result = tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk).await.map_err(IOError::from);
|
||||
if write_result.is_err() {
|
||||
break;
|
||||
};
|
||||
};
|
||||
tokio::io::AsyncWriteExt::flush(&mut writer)
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
|
||||
let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer).await.map_err(IOError::from);
|
||||
Ok::<_, E>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
write_result?;
|
||||
flush_result?;
|
||||
if let Err(err) = write {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Err(err) = rename(&tmp, path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(E::from(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -765,6 +790,89 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Builds a stream that yields `chunks` and then fails, standing in for a
|
||||
/// connection dropped part-way through a download.
|
||||
fn failing_stream(
|
||||
chunks: Vec<&'static [u8]>,
|
||||
) -> impl futures_lite::Stream<Item = Result<bytes::Bytes, IOError>> + Unpin + Send {
|
||||
let items = chunks
|
||||
.into_iter()
|
||||
.map(|c| Ok(bytes::Bytes::from_static(c)))
|
||||
.chain(std::iter::once(Err(IOError::IOError(
|
||||
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
|
||||
))));
|
||||
|
||||
Box::pin(futures_lite::stream::iter(items))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_stream_publishes_only_a_complete_file() {
|
||||
let dir = scratch("stream-ok");
|
||||
let target = dir.join("object.bin");
|
||||
|
||||
let chunks = vec![
|
||||
Ok(bytes::Bytes::from_static(b"hello ")),
|
||||
Ok(bytes::Bytes::from_static(b"world")),
|
||||
];
|
||||
let stream = Box::pin(futures_lite::stream::iter(chunks));
|
||||
|
||||
write_stream::<_, IOError>(&target, stream, Some(11))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"hello world");
|
||||
|
||||
// A successful write must not leave its scratch sibling behind either.
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n != "object.bin")
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_stream_leaves_nothing_when_the_stream_dies() {
|
||||
let dir = scratch("stream-drop");
|
||||
let target = dir.join("object.bin");
|
||||
|
||||
write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64))
|
||||
.await
|
||||
.expect_err("a dropped stream must fail");
|
||||
|
||||
// The whole point: no truncated file at the real path for a later
|
||||
// `exists()` check to mistake for a finished download.
|
||||
assert!(!target.exists(), "left a truncated file at the destination");
|
||||
|
||||
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() {
|
||||
let dir = scratch("stream-keep");
|
||||
let target = dir.join("object.bin");
|
||||
std::fs::write(&target, b"known-good").unwrap();
|
||||
|
||||
write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64))
|
||||
.await
|
||||
.expect_err("a dropped stream must fail");
|
||||
|
||||
// A failed repair must not be worse than no repair.
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"known-good");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_under_accepts_a_path_inside_a_root() {
|
||||
let dir = scratch("under-inside");
|
||||
|
|
|
|||
|
|
@ -82,6 +82,111 @@ pub fn sha1_bytes(data: &[u8]) -> String {
|
|||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Which digest a manifest published its hash in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChecksumAlgorithm {
|
||||
Sha1,
|
||||
Sha256,
|
||||
}
|
||||
|
||||
impl ChecksumAlgorithm {
|
||||
/// Length of this digest written as hex, used to tell one apart from
|
||||
/// another when a manifest gives a bare string with no algorithm field.
|
||||
#[must_use]
|
||||
pub const fn hex_len(self) -> usize {
|
||||
match self {
|
||||
Self::Sha1 => 40,
|
||||
Self::Sha256 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sha1 => "SHA-1",
|
||||
Self::Sha256 => "SHA-256",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash a manifest promised, together with the algorithm it is written in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Checksum {
|
||||
pub algorithm: ChecksumAlgorithm,
|
||||
/// Always normalized, so comparing two `Checksum`s never trips on case.
|
||||
pub hex: String,
|
||||
}
|
||||
|
||||
impl Checksum {
|
||||
#[must_use]
|
||||
pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
algorithm,
|
||||
hex: normalize_hash(hex.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sha1(hex: impl AsRef<str>) -> Self {
|
||||
Self::new(ChecksumAlgorithm::Sha1, hex)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sha256(hex: impl AsRef<str>) -> Self {
|
||||
Self::new(ChecksumAlgorithm::Sha256, hex)
|
||||
}
|
||||
|
||||
/// Whether the hash is the right shape for its algorithm.
|
||||
///
|
||||
/// A vendor that starts returning an empty string, a placeholder, or a
|
||||
/// digest in another algorithm would otherwise fail every download with a
|
||||
/// mismatch that looks like corruption. Callers drop malformed checksums and
|
||||
/// download unverified rather than making the runtime uninstallable.
|
||||
#[must_use]
|
||||
pub fn is_well_formed(&self) -> bool {
|
||||
self.hex.len() == self.algorithm.hex_len()
|
||||
&& self.hex.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matches(&self, actual: &str) -> bool {
|
||||
self.hex == normalize_hash(actual)
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental hashing in whichever algorithm a [`Checksum`] calls for, so a
|
||||
/// download is verified from the bytes as they stream past rather than by
|
||||
/// re-reading the finished file.
|
||||
pub enum ChecksumStream {
|
||||
Sha1(sha1::Sha1),
|
||||
Sha256(sha2::Sha256),
|
||||
}
|
||||
|
||||
impl ChecksumStream {
|
||||
#[must_use]
|
||||
pub fn new(algorithm: ChecksumAlgorithm) -> Self {
|
||||
match algorithm {
|
||||
ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()),
|
||||
ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, data: &[u8]) {
|
||||
match self {
|
||||
Self::Sha1(hasher) => Digest::update(hasher, data),
|
||||
Self::Sha256(hasher) => Digest::update(hasher, data),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn finish(self) -> String {
|
||||
match self {
|
||||
Self::Sha1(hasher) => to_hex(&hasher.finalize()),
|
||||
Self::Sha256(hasher) => to_hex(&hasher.finalize()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical form for a hash read from an external manifest, so a comparison
|
||||
/// against a locally computed one doesn't fail on case or stray whitespace.
|
||||
#[must_use]
|
||||
|
|
@ -125,4 +230,47 @@ mod tests {
|
|||
fn normalize_hash_trims_and_lowercases() {
|
||||
assert_eq!(normalize_hash(" A9B2 "), "a9b2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_streams_match_their_one_shot_digests() {
|
||||
let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1);
|
||||
sha1.update(b"ab");
|
||||
sha1.update(b"c");
|
||||
assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
|
||||
let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256);
|
||||
sha256.update(b"ab");
|
||||
sha256.update(b"c");
|
||||
assert_eq!(
|
||||
sha256.finish(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_comparison_ignores_case_and_padding() {
|
||||
let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D ");
|
||||
assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d"));
|
||||
assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D"));
|
||||
assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_of_the_wrong_shape_is_not_well_formed() {
|
||||
// What a vendor sending a placeholder, an empty field, or a digest in
|
||||
// another algorithm would look like. Verifying against these would fail
|
||||
// every download with what reads to the user as corruption.
|
||||
assert!(!Checksum::sha256("").is_well_formed());
|
||||
assert!(!Checksum::sha256("n/a").is_well_formed());
|
||||
assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
|
||||
assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed());
|
||||
|
||||
assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
|
||||
assert!(
|
||||
Checksum::sha256(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
)
|
||||
.is_well_formed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue