diff --git a/.github/media/RepoBanner.png b/.github/media/RepoBanner.png index db9ec1e4..fee91d91 100644 Binary files a/.github/media/RepoBanner.png and b/.github/media/RepoBanner.png differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9402ef70..d21b5f79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release Tag (e.g. v2.5.0)' + description: 'Release Tag (e.g. v2.5.1)' required: false - default: 'v2.5.0-c1' + default: 'v2.5.1-c1' type: string permissions: @@ -138,7 +138,7 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT else - echo "tag=v2.5.0-c1" >> $GITHUB_OUTPUT + echo "tag=v2.5.1-c1" >> $GITHUB_OUTPUT fi - name: Clean up previous release if exists @@ -155,7 +155,7 @@ jobs: name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}" body: | ### What's New in ${{ steps.release_tag.outputs.tag }} - - **Rebased onto Official Upstream 2.5.0**: Includes all upstream 2.5.0 updates (New Control Center quick settings & system info, refined navbar styling, bundle version-tag matching and compatibility filtering, version metadata self-healing). + - **Rebased onto Official Upstream 2.5.1**: Includes all upstream 2.5.1 updates (Process hook error handling and execution reporting, optimized bundled packages sync after launch, auto-refresh of stale package metadata, asset optimizations). - **Guaranteed Offline Game Launch**: Completely eliminated the upstream check in `account_for_launch` that blocked offline accounts from launching without Microsoft accounts. Offline accounts can launch directly with 0 Microsoft accounts required. - **Custom Offline Skins & Targeted Multiplayer Injection**: In-launcher 3D skin changer, slim/classic model toggle, per-account Java UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation. - **Offline / Cracked Accounts**: Full offline account support in onboarding and settings with inline username editing. diff --git a/Cargo.lock b/Cargo.lock index 16d1e700..1c4fc909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4480,7 +4480,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "anyhow", "arboard", @@ -4530,7 +4530,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4554,7 +4554,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "bytes 1.11.1", "chrono", @@ -4577,7 +4577,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "directories", "interfrost", @@ -4590,7 +4590,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "async-trait", "async_zip", @@ -4618,7 +4618,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "async-compression", "async-stream", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "async-tempfile", "chrono", @@ -4698,7 +4698,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "chrono", "discord-rich-presence", @@ -4710,7 +4710,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4743,7 +4743,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "proc-macro2", "quote", @@ -4752,7 +4752,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "arc-swap", "async-trait", @@ -4796,7 +4796,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -5094,7 +5094,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.5.0-c1" +version = "2.5.1-c1" dependencies = [ "astral-tokio-tar", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index 29cfc669..6f00f40b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ license = "GPL-3.0-only" readme = "README.md" repository = "https://github.com/oiupoyt/OneLauncher-Cracked" rust-version = "1.97" -version = "2.5.0-c1" +version = "2.5.1-c1" [workspace.dependencies] # core diff --git a/distribution/aur/oneclient/PKGBUILD b/distribution/aur/oneclient/PKGBUILD index 2dbf52df..7d93ea25 100644 --- a/distribution/aur/oneclient/PKGBUILD +++ b/distribution/aur/oneclient/PKGBUILD @@ -28,7 +28,10 @@ makedepends=( ) provides=('oneclient') conflicts=('oneclient-bin') -options=('!lto', '!debug') +options=( + '!lto' + '!debug' +) source=("$pkgname-$pkgver.tar.gz::${url}/archive/refs/tags/oneclient-${pkgver}.tar.gz") sha256sums=('SKIP') diff --git a/packages/oneclient_app/assets/alex.png b/packages/oneclient_app/assets/alex.png index 99af1933..e61ea5ba 100644 Binary files a/packages/oneclient_app/assets/alex.png and b/packages/oneclient_app/assets/alex.png differ diff --git a/packages/oneclient_app/assets/steve.png b/packages/oneclient_app/assets/steve.png index 16077860..e8423406 100644 Binary files a/packages/oneclient_app/assets/steve.png and b/packages/oneclient_app/assets/steve.png differ diff --git a/packages/oneclient_app/distribution/dmg-background.png b/packages/oneclient_app/distribution/dmg-background.png index ef311ae2..75850ded 100644 Binary files a/packages/oneclient_app/distribution/dmg-background.png and b/packages/oneclient_app/distribution/dmg-background.png differ diff --git a/packages/oneclient_app/distribution/dmg-background@2x.png b/packages/oneclient_app/distribution/dmg-background@2x.png index 4b868d82..29ad6093 100644 Binary files a/packages/oneclient_app/distribution/dmg-background@2x.png and b/packages/oneclient_app/distribution/dmg-background@2x.png differ diff --git a/packages/oneclient_app/icons/128x128.png b/packages/oneclient_app/icons/128x128.png index 21b90752..54ffddc1 100644 Binary files a/packages/oneclient_app/icons/128x128.png and b/packages/oneclient_app/icons/128x128.png differ diff --git a/packages/oneclient_app/icons/128x128@2x.png b/packages/oneclient_app/icons/128x128@2x.png index 0786a43c..025891e6 100644 Binary files a/packages/oneclient_app/icons/128x128@2x.png and b/packages/oneclient_app/icons/128x128@2x.png differ diff --git a/packages/oneclient_app/icons/32x32.png b/packages/oneclient_app/icons/32x32.png index d190be46..719bc02c 100644 Binary files a/packages/oneclient_app/icons/32x32.png and b/packages/oneclient_app/icons/32x32.png differ diff --git a/packages/oneclient_app/icons/512x512.png b/packages/oneclient_app/icons/512x512.png index fbc4ca47..613fc8e9 100644 Binary files a/packages/oneclient_app/icons/512x512.png and b/packages/oneclient_app/icons/512x512.png differ diff --git a/packages/oneclient_app/icons/64x64.png b/packages/oneclient_app/icons/64x64.png index 6c92d512..24aa2ba5 100644 Binary files a/packages/oneclient_app/icons/64x64.png and b/packages/oneclient_app/icons/64x64.png differ diff --git a/packages/oneclient_app/icons/StoreLogo.png b/packages/oneclient_app/icons/StoreLogo.png index 431f57e5..4b6173d6 100644 Binary files a/packages/oneclient_app/icons/StoreLogo.png and b/packages/oneclient_app/icons/StoreLogo.png differ diff --git a/packages/oneclient_app/icons/icon.png b/packages/oneclient_app/icons/icon.png index fbc4ca47..613fc8e9 100644 Binary files a/packages/oneclient_app/icons/icon.png and b/packages/oneclient_app/icons/icon.png differ diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 33443417..674a3f17 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -1373,13 +1373,9 @@ impl Actions { spawn_forever(async move { - let synced = match state.bundles.sync(&state.services.content()).await { - Ok(_) => true, - Err(err) => { - tracing::error!("bundle catalog sync failed: {err:#}"); - false - } - }; + if let Err(err) = state.bundles.sync(&state.services.content()).await { + tracing::error!("bundle catalog sync failed: {err:#}"); + } if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { tracing::error!("cluster migrations failed: {err:#}"); } @@ -1387,37 +1383,6 @@ impl Actions { tracing::error!("bundle cluster provisioning failed: {err:#}"); } - // One grouped session for the batch so all downloads surface as a - // single notification `detach` lets it be converted in place - let session = oneclient_events::GroupedProgressSession::start( - &state.services.events, - "Updating mods", - ); - let changed = oneclient_content::bundles::sync_all_cluster_bundles( - state.bundles.as_ref(), - &state.services.content(), - Some(&session), - ) - .await; - let session_id = session.detach(); - - if synced - && let Ok(clusters) = state.clusters.list().await - { - let unsettled: std::collections::HashSet = changed - .iter() - .filter(|(_, result)| !result.settled()) - .map(|(cluster_id, _)| *cluster_id) - .collect(); - - actions.record_bundle_checks( - clusters - .iter() - .map(|cluster| cluster.id) - .filter(|cluster_id| !unsettled.contains(cluster_id)), - ); - } - actions .station .clone() @@ -1425,12 +1390,6 @@ impl Actions { .launcher .syncing_bundles = false; super::invalidate_cluster_queries().await; - - let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await; - actions.with_engine(|app| { - app.notifications - .finish_grouped_as_actions(&mut app.inbox, session_id, spec); - }); }); } @@ -1451,8 +1410,6 @@ impl Actions { return; } - let mode = self.cluster_update_mode(state, cluster_id).await; - self.station .clone() .write_channel(AppChannel::Launcher) @@ -1486,15 +1443,8 @@ impl Actions { .launcher .syncing_bundles = false; - // No prompt for bundles the package manager's markers carry them instead - if !matches!(mode, PackageUpdateMode::Automatic) { - tracing::debug!(cluster_id, "bundle updates left for the user to apply"); - if synced { - self.record_bundle_checks([cluster_id]); - } - return; - } - + // Bundle content is forced, so the update mode never gates it and the + // modal never lists it let result = match oneclient_core::apply_bundle_updates( cluster_id, state.bundles.as_ref(), @@ -1533,12 +1483,6 @@ impl Actions { } super::invalidate_cluster_queries().await; - - if let Some(spec) = - crate::install::cluster_update_notification(cluster_id, &result, &state.services).await - { - self.push_notification(spec); - } } /// The check runs and the cache is written whatever the mode says keeping diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index acc7f4b1..dbf4f783 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -168,60 +168,6 @@ pub async fn cluster_update_notification( }) } -/// All clusters share one "View changes" action so the notification keeps exactly two buttons -pub async fn combined_cluster_update_spec( - changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], - services: &oneclient_core::LauncherServices, -) -> Option { - let mut summaries = Vec::new(); - let mut total_changes = 0usize; - - for (cluster_id, result) in changed { - if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { - total_changes += summary.total(); - summaries.push(summary); - } - } - - if summaries.is_empty() { - return None; - } - - let cluster_count = summaries.len(); - let (title, body) = if total_changes == 0 { - let offers: usize = summaries.iter().map(|s| s.optional.len()).sum(); - ( - "Optional mods available", - format!( - "{offers} optional mod{} across {cluster_count} cluster{}", - if offers == 1 { "" } else { "s" }, - if cluster_count == 1 { "" } else { "s" } - ), - ) - } else { - ( - "Mods updated", - format!( - "{total_changes} package{} updated across {cluster_count} cluster{}", - if total_changes == 1 { "" } else { "s" }, - if cluster_count == 1 { "" } else { "s" } - ), - ) - }; - - Some(NotificationSpec { - title: title.to_string(), - body, - level: Level::Info, - icon: Some(IconType::DownloadCloud02), - progress: None, - actions: vec![NotificationAction { - label: "View changes".to_string(), - kind: NotificationActionKind::OpenClusterUpdate(summaries), - }], - }) -} - pub async fn pending_optional_group( cluster_id: i64, pending: &[oneclient_core::PendingOptionalMod], diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 965411f1..9fdfc4de 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -5,7 +5,6 @@ mod manifest; mod optional; mod overrides; mod polymrpack; -mod runtime; mod types; mod updates; @@ -25,7 +24,6 @@ pub use optional::{ PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods, }; pub use manifest::BundleManifest as RemoteBundleManifest; -pub use runtime::{is_bundle_syncing, sync_all_cluster_bundles}; pub use types::{ ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleOptionalPackage, BundleManifest, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 6806b610..9144b5d7 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -902,34 +902,6 @@ pub async fn get_bundles_with_update_status( Ok(results) } -#[tracing::instrument(level = "debug", skip_all)] -pub async fn apply_bundle_updates_for_all_clusters( - bundles: &BundlesManager, - ctx: &ContentCtx, - session: Option<&oneclient_events::GroupedProgressSession>, -) -> ContentResult> { - let mut changed = Vec::new(); - for cluster in cluster_dao::list_all(&ctx.db).await? { - match apply_bundle_updates_with(cluster.id, bundles, ctx, session, None).await { - Ok(result) => { - if !result.updates_applied.is_empty() - || !result.additions_applied.is_empty() - || !result.removals_applied.is_empty() - || !result.optional_available.is_empty() - { - changed.push((cluster.id, result)); - } - } - Err(err) => tracing::warn!( - cluster_id = cluster.id, - error = %err, - "bundle update apply failed for cluster" - ), - } - } - Ok(changed) -} - fn bundle_package_key( bundle_pkg: &BundleTrackedArtifactRow, linked: &HashMap, diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs index 0497af22..24bcbbc4 100644 --- a/packages/oneclient_content/src/packages/activity.rs +++ b/packages/oneclient_content/src/packages/activity.rs @@ -13,7 +13,8 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use crate::ctx::ContentCtx; use crate::error::ContentResult; use crate::packages::dependencies::base_game_version; -use crate::packages::store::PackageStore; +use crate::packages::FileIdentity; +use crate::packages::store::{PackageStore, record_release}; use crate::packages::types::LinkedArtifactInfo; struct Copy { @@ -126,11 +127,18 @@ pub async fn disable_foreign_game_versions( .map(|row| row.hash) .collect(); + let candidates: Vec<&LinkedArtifactInfo> = linked + .iter() + .filter(|info| { + info.enabled && info.content_type == ContentType::Mod && !from_bundle.contains(&info.hash) + }) + .collect(); + + refresh_stated_game_versions(cluster_id, &candidates, mc_version, ctx).await; + let mut switched_off = Vec::new(); - for info in linked.iter().filter(|info| { - info.enabled && info.content_type == ContentType::Mod && !from_bundle.contains(&info.hash) - }) { + for info in candidates { match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await { Ok(true) => switched_off.push( info.display_name @@ -152,13 +160,64 @@ pub async fn disable_foreign_game_versions( const FOREIGN_VERSION_REPAIR: &str = "disable-foreign-game-version"; +fn foreign_repair_id(cluster_id: i64, mc_version: &str, hash: &str) -> String { + format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{hash}") +} + +async fn refresh_stated_game_versions( + cluster_id: i64, + candidates: &[&LinkedArtifactInfo], + mc_version: &str, + ctx: &ContentCtx, +) { + let mut stale = Vec::new(); + + for info in candidates { + match migration_dao::is_applied(&ctx.db, &foreign_repair_id(cluster_id, mc_version, &info.hash)).await { + Ok(true) => continue, + Ok(false) => {} + Err(err) => { + tracing::debug!(hash = %info.hash, %err, "could not read the repair marker"); + continue; + } + } + + match artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await { + Ok(stated) if built_for_another_game_version(&stated, mc_version) => { + stale.push(FileIdentity::from_sha1(&info.hash)); + } + Ok(_) => {} + Err(err) => tracing::debug!(hash = %info.hash, %err, "could not read the stated game versions"), + } + } + + if stale.is_empty() { + return; + } + + let found = match ctx.providers.lookup_versions(&stale, ctx).await { + Ok(found) => found, + // Offline or the provider is down, so the stored rows are all there is + Err(err) => { + tracing::debug!(%err, "could not refresh the stated game versions"); + return; + } + }; + + for (sha1, (provider, version)) in found { + if let Err(err) = record_release(provider, &version, &sha1, ctx).await { + tracing::debug!(hash = %sha1, %err, "could not store the refreshed game versions"); + } + } +} + async fn switch_off_if_foreign( cluster_id: i64, info: &LinkedArtifactInfo, mc_version: &str, ctx: &ContentCtx, ) -> ContentResult { - let repair_id = format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{}", info.hash); + let repair_id = foreign_repair_id(cluster_id, mc_version, &info.hash); if migration_dao::is_applied(&ctx.db, &repair_id).await? { return Ok(false); } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index dc377edf..35f58a1f 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -571,7 +571,7 @@ async fn already_described(row: &ArtifactRow, ctx: &ContentCtx) -> bool { /// Written exactly as a download would have so the row joins the update flow /// rather than sitting outside it -async fn record_release( +pub(crate) async fn record_release( provider: ProviderId, version: &VersionDetail, hash: &str, diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs index 95d5d3f5..5057273a 100644 --- a/packages/oneclient_core/src/game/error.rs +++ b/packages/oneclient_core/src/game/error.rs @@ -37,4 +37,28 @@ pub enum GameError { #[error("failed to spawn the game process: {0}")] Spawn(String), + + #[error( + "the wrapper command '{program}' could not be started: {reason}. Check the Wrapper Command setting." + )] + WrapperSpawn { program: String, reason: String }, +} + +impl GameError { + pub(crate) fn wrapper_spawn(program: &str, err: &std::io::Error) -> Self { + let reason = match err.kind() { + std::io::ErrorKind::NotFound => { + "command not found".to_string() + } + std::io::ErrorKind::PermissionDenied => { + "insufficient permissions or file is not an executable".to_string() + } + _ => err.to_string(), + }; + + Self::WrapperSpawn { + program: program.to_string(), + reason, + } + } } diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index ed7cc13d..96059bfa 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -5,6 +5,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use interfrost::api::minecraft::ArgumentType; +use tokio::io::AsyncReadExt; use tokio::process::Command; use crate::ClusterStage; @@ -332,7 +333,13 @@ async fn start( )?; arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); - run_hook(profile.hook_pre.as_deref(), &cwd).await; + if let Some(reason) = run_hook(profile.hook_pre.as_deref(), &cwd).await { + events + .notify("Pre-launch command failed") + .body(reason) + .error() + .send(); + } tracing::info!( cluster_id, @@ -344,7 +351,7 @@ async fn start( ); tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments"); - let mut command = base_command(&profile, &java.absolute_path); + let (mut command, wrapper) = base_command(&profile, &java.absolute_path); if profile.use_discrete_gpu() { crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; @@ -387,9 +394,10 @@ async fn start( command.stdin(Stdio::null()); detach(&mut command); - let mut child = command - .spawn() - .map_err(|err| GameError::Spawn(err.to_string()))?; + let mut child = command.spawn().map_err(|err| match &wrapper { + Some(program) => GameError::wrapper_spawn(program, &err), + None => GameError::Spawn(err.to_string()), + })?; let pid = child.id(); stage(LaunchStage::Running); @@ -566,7 +574,15 @@ pub(crate) async fn finalize_session( recorder.finish_at(&end.ended_at.to_rfc3339(), code).await; } - run_hook(post_hook, cwd).await; + if let Some(reason) = run_hook(post_hook, cwd).await { + state + .services + .events + .notify("Post-exit command failed") + .body(reason) + .error() + .send(); + } if dedicated { // The folder stays materialized so it remains a real Minecraft directory @@ -667,21 +683,25 @@ pub async fn offer_repair( } } -fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { - if let Some(wrapper) = profile +fn base_command(profile: &GameSettingsProfile, java_path: &str) -> (Command, Option) { + let Some(wrapper) = profile .hook_wrapper .as_deref() .map(str::trim) .filter(|hook| !hook.is_empty()) - { - let mut split = wrapper.split_whitespace(); - let mut command = Command::new(split.next().unwrap_or("sh")); - command.args(split); - command.arg(java_path); - command - } else { - Command::new(java_path) - } + else { + return (Command::new(java_path), None); + }; + + let mut split = wrapper.split_whitespace(); + let Some(program) = split.next() else { + return (Command::new(java_path), None); + }; + + let mut command = Command::new(program); + command.args(split); + command.arg(java_path); + (command, Some(program.to_string())) } fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { @@ -697,10 +717,8 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { } #[tracing::instrument(skip(cwd), fields(hook), level = "debug")] -async fn run_hook(hook: Option<&str>, cwd: &Path) { - let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { - return; - }; +async fn run_hook(hook: Option<&str>, cwd: &Path) -> Option { + let hook = hook.map(str::trim).filter(|h| !h.is_empty())?; #[cfg(windows)] let mut command = { @@ -716,12 +734,84 @@ async fn run_hook(hook: Option<&str>, cwd: &Path) { }; command.current_dir(cwd); + command.stderr(Stdio::piped()); + command.stdin(Stdio::null()); oneclient_common::process::no_window(command.as_std_mut()); - if let Err(err) = command.status().await { - tracing::warn!("hook '{hook}' failed: {err}"); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => { + tracing::warn!("hook '{hook}' could not start: {err}"); + return Some(format!("The command shell could not be started: {err}")); + } + }; + + let collector = child.stderr.take().map(|mut pipe| { + tokio::spawn(async move { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf).await; + buf + }) + }); + + let status = match child.wait().await { + Ok(status) => status, + Err(err) => { + if let Some(collector) = collector { + collector.abort(); + } + tracing::warn!("hook '{hook}' could not be waited on: {err}"); + return Some(format!("The command could not be waited on: {err}")); + } + }; + + if status.success() { + if let Some(collector) = collector { + collector.abort(); + } + return None; } + + let detail = match collector { + Some(mut collector) => { + match tokio::time::timeout(Duration::from_millis(200), &mut collector).await { + Ok(Ok(buf)) => stderr_detail(&buf), + _ => { + collector.abort(); + None + } + } + } + None => None, + }; + + let message = match (detail, status.code()) { + (Some(detail), _) => detail, + (None, Some(code)) => format!("The command exited with code {code}"), + (None, None) => "The command was stopped before it finished".to_string(), + }; + + tracing::warn!("hook '{hook}' failed: {message}"); + Some(message) } +fn stderr_detail(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + let line = text + .lines() + .rev() + .map(str::trim) + .find(|line| !line.is_empty())?; + + const MAX: usize = 300; + if line.chars().count() <= MAX { + return Some(line.to_string()); + } + + let mut clipped: String = line.chars().take(MAX).collect(); + clipped.push('…'); + Some(clipped) +} /// Computes Java's exact 32-bit signed integer `uuid.hashCode()`. fn java_uuid_hash(uuid: &uuid::Uuid) -> i32 { let (most_sig, least_sig) = uuid.as_u64_pair(); diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 55cb511c..a364cbc7 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -27,7 +27,7 @@ pub use oneclient_content::bundles::{ cluster_has_bundle_content, effective_enabled, install_bundle, install_cluster_bundles, - install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, + install_package_from_bundle, list_cluster_bundle_overrides, pending_optional_mods, resolve_optional_mods, skip_optional_mods, PendingOptionalMod, set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides,