934 lines
28 KiB
Rust
934 lines
28 KiB
Rust
use std::fmt::Write as _;
|
|
use std::path::Path;
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use interfrost::api::minecraft::ArgumentType;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::process::Command;
|
|
|
|
use crate::ClusterStage;
|
|
use crate::LauncherResult;
|
|
use crate::clusters::Cluster;
|
|
use crate::game::GameError;
|
|
use crate::game::session::SessionRecorder;
|
|
use crate::game::tail::spawn_log_tail;
|
|
use crate::settings::GameSettingsProfile;
|
|
use crate::state::LauncherState;
|
|
use oneclient_auth::MinecraftAccount;
|
|
use oneclient_common::paths;
|
|
use oneclient_discord::Presence;
|
|
use oneclient_events::{GroupedProgressSession, LaunchStage};
|
|
use oneclient_mc::{
|
|
self as arguments, download_minecraft, download_version_info, game_files_missing,
|
|
get_loader_version, resolve_minecraft_version,
|
|
};
|
|
|
|
pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool {
|
|
state.games.is_running(cluster_id)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LaunchedGame {
|
|
pub cluster_id: i64,
|
|
pub pid: Option<u32>,
|
|
}
|
|
|
|
#[tracing::instrument(skip(state, account))]
|
|
pub async fn launch_cluster(
|
|
state: &Arc<LauncherState>,
|
|
cluster_id: i64,
|
|
account: &MinecraftAccount,
|
|
search_for_java: bool,
|
|
) -> LauncherResult<LaunchedGame> {
|
|
tracing::info!(cluster_id, search_for_java, "launching cluster");
|
|
|
|
let parallel = state.settings.read().allow_parallel_running_clusters;
|
|
if !parallel && state.games.is_active(cluster_id) {
|
|
tracing::warn!(
|
|
cluster_id,
|
|
"cluster already launching or running; refusing launch"
|
|
);
|
|
return Err(GameError::AlreadyRunning(cluster_id).into());
|
|
}
|
|
|
|
// A parallel attempt shares its entry with the session already under way, so
|
|
// clearing it here would drop that game's kill sender and pid
|
|
let adopted = state.games.is_active(cluster_id);
|
|
|
|
let result = start(state, cluster_id, account, search_for_java).await;
|
|
|
|
if result.is_err() && !adopted {
|
|
state.games.remove(cluster_id);
|
|
state
|
|
.services
|
|
.events
|
|
.game_stage(cluster_id, LaunchStage::Exited);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
async fn start(
|
|
state: &Arc<LauncherState>,
|
|
cluster_id: i64,
|
|
account: &MinecraftAccount,
|
|
search_for_java: bool,
|
|
) -> LauncherResult<LaunchedGame> {
|
|
let events = state.services.events.clone();
|
|
let stage = |s: LaunchStage| {
|
|
state.games.set_stage(cluster_id, s);
|
|
events.game_stage(cluster_id, s);
|
|
};
|
|
|
|
stage(LaunchStage::Checking);
|
|
|
|
let existing = state.clusters.get(cluster_id).await?;
|
|
|
|
let game_dir = existing.game_dir()?;
|
|
let dedicated = existing.uses_dedicated_dir();
|
|
|
|
// Non-dedicated clusters share one directory so a second game there would
|
|
// materialize over the running one's
|
|
// `dir_in_use_by` waves a cluster past
|
|
// its own session which parallel launches make reachable
|
|
if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) {
|
|
tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch");
|
|
let name = running_cluster_name(state, other).await;
|
|
return Err(GameError::SharedDirectoryBusy(name).into());
|
|
}
|
|
|
|
if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) {
|
|
return Err(GameError::DirectoryInUse(other).into());
|
|
}
|
|
|
|
state.games.set_dir(cluster_id, game_dir.clone());
|
|
|
|
match state.clusters.mark_played(cluster_id).await {
|
|
Ok(()) => events.signal(oneclient_events::Signal::ClustersChanged),
|
|
Err(err) => tracing::debug!(cluster_id, error = %err, "could not record the launch time"),
|
|
}
|
|
|
|
let progress = GroupedProgressSession::start(
|
|
&state.services.events,
|
|
format!("Launching {}", existing.name),
|
|
);
|
|
|
|
if existing.uses_bundles()
|
|
&& let Err(err) = crate::clusters::apply_bundle_java_override(
|
|
state,
|
|
cluster_id,
|
|
search_for_java,
|
|
false,
|
|
Some(&progress),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(cluster_id, error = %err, "failed to apply bundle java override");
|
|
}
|
|
|
|
let cluster = if existing.stage == ClusterStage::Ready {
|
|
existing
|
|
} else {
|
|
stage(LaunchStage::Downloading);
|
|
match crate::clusters::prepare_cluster_locked(
|
|
state,
|
|
cluster_id,
|
|
false,
|
|
search_for_java,
|
|
false,
|
|
Some(&progress),
|
|
)
|
|
.await
|
|
{
|
|
Ok(cluster) => cluster,
|
|
Err(err) => {
|
|
progress.finish();
|
|
stage(LaunchStage::Exited);
|
|
return Err(err);
|
|
}
|
|
}
|
|
};
|
|
if cluster.uses_bundles()
|
|
&& let Err(err) = oneclient_content::bundles::install_cluster_bundles(
|
|
cluster_id,
|
|
state.bundles.as_ref(),
|
|
Some(&progress),
|
|
&state.services.content(),
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(cluster_id, error = %err, "failed to install bundle content");
|
|
}
|
|
|
|
let global = state.settings.read().global_game_settings.clone();
|
|
let profile = state.clusters.resolve_settings(&global, &cluster).await?;
|
|
|
|
let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version);
|
|
|
|
let (version, updated, loader_version, version_info) = {
|
|
let mut metadata = state.metadata.lock().await;
|
|
|
|
let (version, _index, updated) =
|
|
resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version).await?;
|
|
|
|
let loader_version = get_loader_version(
|
|
&mut metadata,
|
|
&state.services.mc(),
|
|
&mc_version,
|
|
cluster.mc_loader,
|
|
cluster.mc_loader_version.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
let version_info = download_version_info(
|
|
&state.services.mc(),
|
|
Some(&progress),
|
|
&version,
|
|
cluster.mc_loader,
|
|
loader_version.as_ref(),
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
(version, updated, loader_version, version_info)
|
|
};
|
|
|
|
let version_info = match version_info {
|
|
Ok(info) => info,
|
|
Err(err) => {
|
|
progress.finish();
|
|
stage(LaunchStage::Exited);
|
|
return Err(err.into());
|
|
}
|
|
};
|
|
|
|
let version_name = loader_version.as_ref().map_or_else(
|
|
|| version.id.clone(),
|
|
|lv| format!("{}-{}", version.id, lv.id),
|
|
);
|
|
|
|
tracing::info!(
|
|
cluster_id,
|
|
mc_version = %version.id,
|
|
loader = %cluster.mc_loader,
|
|
loader_version = loader_version.as_ref().map(|lv| lv.id.as_str()).unwrap_or("none"),
|
|
version_name = %version_name,
|
|
libraries = version_info.libraries.len(),
|
|
main_class = %version_info.main_class,
|
|
"resolved launch metadata"
|
|
);
|
|
|
|
let java = if let Some(runtime) = state
|
|
.java
|
|
.runtime_for_profile(profile.java_path.as_deref())
|
|
.await?
|
|
{
|
|
runtime
|
|
} else {
|
|
let major = version_info
|
|
.java_version
|
|
.as_ref()
|
|
.map(|v| v.major_version)
|
|
.ok_or(GameError::MissingJavaVersion)?;
|
|
|
|
state
|
|
.java
|
|
.prepare(major, search_for_java, false, None)
|
|
.await?
|
|
};
|
|
|
|
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;
|
|
stage(LaunchStage::Downloading);
|
|
if let Err(err) = download_minecraft(
|
|
&state.services.mc(),
|
|
&progress,
|
|
&version_info,
|
|
&java.os_arch,
|
|
updated,
|
|
false,
|
|
)
|
|
.await
|
|
{
|
|
progress.finish();
|
|
stage(LaunchStage::Exited);
|
|
return Err(err.into());
|
|
}
|
|
let _ = state
|
|
.clusters
|
|
.set_stage(cluster_id, ClusterStage::Ready)
|
|
.await;
|
|
}
|
|
Ok(false) => {}
|
|
Err(err @ oneclient_mc::McError::NoNativesForPlatform { .. }) => {
|
|
progress.finish();
|
|
stage(LaunchStage::Exited);
|
|
return Err(err.into());
|
|
}
|
|
Err(err) => tracing::warn!(cluster_id, error = %err, "repair check failed"),
|
|
}
|
|
|
|
progress.finish();
|
|
|
|
stage(LaunchStage::Launching);
|
|
|
|
let cwd = game_dir;
|
|
polyio::create_dir_all(&cwd).await.ok();
|
|
|
|
if let Err(err) = crate::game::write_allowed_symlinks(&cwd).await {
|
|
tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt");
|
|
}
|
|
|
|
let custom_args = profile.launch_args.clone().unwrap_or_default();
|
|
let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str());
|
|
|
|
let mods_in_cluster =
|
|
crate::game::uses_cluster_mods_folder(cluster.mc_loader, loader_version_id, &custom_args);
|
|
|
|
if let Err(err) =
|
|
crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await
|
|
{
|
|
tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content");
|
|
}
|
|
|
|
if !dedicated {
|
|
crate::game::link_cluster_logs(&cluster, &cwd).await;
|
|
}
|
|
|
|
let client_jar = paths::versions_dir()?
|
|
.join(&version_name)
|
|
.join(format!("{version_name}.jar"));
|
|
let natives = paths::natives_dir()?.join(&version_name);
|
|
polyio::create_dir_all(&natives).await?;
|
|
let libraries = paths::libraries_dir()?;
|
|
let assets = paths::assets_dir()?;
|
|
|
|
let arg_map = version_info.arguments.clone().unwrap_or_default();
|
|
|
|
let classpaths = arguments::classpaths(
|
|
&libraries,
|
|
&version_info.libraries,
|
|
&client_jar,
|
|
&java.os_arch,
|
|
updated,
|
|
)?;
|
|
|
|
let mut jvm_args = arguments::java_arguments(
|
|
arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice),
|
|
&natives,
|
|
&libraries,
|
|
&classpaths,
|
|
&version_name,
|
|
profile
|
|
.mem_max
|
|
.unwrap_or_else(oneclient_common::default_mem_max),
|
|
profile.launch_args.clone().unwrap_or_default(),
|
|
&java.os_arch,
|
|
java.major,
|
|
)?;
|
|
|
|
let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?;
|
|
if let Some(arg) = crate::game::mods_folder_argument(
|
|
cluster.mc_loader,
|
|
loader_version_id,
|
|
&custom_args,
|
|
&mods_dir,
|
|
) {
|
|
tracing::debug!(cluster_id, mods_dir = %mods_dir.display(), "redirecting fabric mods folder");
|
|
jvm_args.push(arg);
|
|
}
|
|
|
|
let mut mc_args = arguments::minecraft_arguments(
|
|
arg_map.get(&ArgumentType::Game).map(Vec::as_slice),
|
|
version_info.minecraft_arguments.as_deref(),
|
|
&account.access_token,
|
|
&account.username,
|
|
account.id,
|
|
&version.id,
|
|
&version_info.asset_index.id,
|
|
&cwd,
|
|
&assets,
|
|
version.type_,
|
|
profile.resolution.unwrap_or_default(),
|
|
&java.os_arch,
|
|
)?;
|
|
arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None);
|
|
|
|
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,
|
|
java = %java.absolute_path,
|
|
jvm_args = jvm_args.len(),
|
|
mc_args = mc_args.len(),
|
|
cwd = %cwd.display(),
|
|
"spawning minecraft process"
|
|
);
|
|
tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments");
|
|
|
|
let (mut command, wrapper) = base_command(&profile, &java.absolute_path);
|
|
|
|
if let Some(gpu) = profile.gpu() {
|
|
crate::game::gpu::select(&mut command, &java.absolute_path, gpu).await;
|
|
}
|
|
|
|
apply_env(&mut command, &profile);
|
|
|
|
command
|
|
.args(jvm_args)
|
|
.arg(&version_info.main_class)
|
|
.args(mc_args)
|
|
.current_dir(&cwd);
|
|
|
|
let header = format!(
|
|
"OneClient {} · {} · {version_name} · Java {}\n\n{}\n\n",
|
|
env!("CARGO_PKG_VERSION"),
|
|
cluster.name,
|
|
java.major,
|
|
command_line(command.as_std(), &version_info.main_class),
|
|
);
|
|
|
|
let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?;
|
|
if let Some(parent) = log_path.parent() {
|
|
polyio::create_dir_all(parent).await.ok();
|
|
}
|
|
|
|
// A file not a pipe a pipe would die with the launcher and take the game's
|
|
// next stdout write down with it
|
|
// The cloned handle shares the file offset
|
|
// so stdout and stderr interleave instead of overwriting
|
|
let handles = match tokio::fs::File::create(&log_path).await {
|
|
Ok(mut out) => {
|
|
if let Err(err) = out.write_all(header.as_bytes()).await {
|
|
tracing::warn!(cluster_id, error = %err, "failed to write the launch header");
|
|
}
|
|
match out.try_clone().await {
|
|
Ok(err) => Ok((out.into_std().await, err.into_std().await)),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
Err(err) => Err(err),
|
|
};
|
|
|
|
match handles {
|
|
Ok((out, err)) => {
|
|
command.stdout(Stdio::from(out)).stderr(Stdio::from(err));
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(cluster_id, error = %err, "failed to open game log; discarding output");
|
|
command.stdout(Stdio::null()).stderr(Stdio::null());
|
|
}
|
|
}
|
|
command.stdin(Stdio::null());
|
|
detach(&mut command);
|
|
|
|
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);
|
|
state.games.set_pid(cluster_id, pid);
|
|
state.discord.set_presence(Presence::Playing {
|
|
cluster: cluster.name.clone(),
|
|
mc_version: cluster.mc_version.clone(),
|
|
});
|
|
|
|
let recorder = SessionRecorder::start(
|
|
state,
|
|
cluster_id,
|
|
profile
|
|
.mem_max
|
|
.unwrap_or_else(oneclient_common::default_mem_max),
|
|
&java,
|
|
)
|
|
.await;
|
|
|
|
// Pinned to the session row so that if the launcher exits first the next
|
|
// start can tell whether the game is still playing
|
|
if let (Some(recorder), Some(pid)) = (recorder.as_ref(), pid) {
|
|
recorder
|
|
.record_process(pid, crate::game::process_start_time(pid))
|
|
.await;
|
|
}
|
|
|
|
let started_at = recorder
|
|
.as_ref()
|
|
.and_then(SessionRecorder::started_at)
|
|
.unwrap_or_else(Utc::now);
|
|
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);
|
|
|
|
let state = Arc::clone(state);
|
|
let post_hook = profile.hook_post.clone();
|
|
tokio::spawn(async move {
|
|
let cluster = cluster;
|
|
let (status, stopped) = tokio::select! {
|
|
status = child.wait() => (status, false),
|
|
_ = kill_rx => {
|
|
let _ = child.start_kill();
|
|
(child.wait().await, true)
|
|
}
|
|
};
|
|
|
|
tail.stop().await;
|
|
|
|
let outcome = match status {
|
|
Ok(status) => Exit::Observed {
|
|
code: status.code().map(i64::from),
|
|
success: status.success() || stopped || killed(&status),
|
|
display: status.to_string(),
|
|
},
|
|
Err(err) => Exit::Failed(err.to_string()),
|
|
};
|
|
|
|
finalize_session(
|
|
&state,
|
|
&cluster,
|
|
&cwd,
|
|
dedicated,
|
|
post_hook.as_deref(),
|
|
recorder,
|
|
SessionEnd {
|
|
started_at,
|
|
ended_at: Utc::now(),
|
|
outcome,
|
|
owns_slot: true,
|
|
diagnosis: crash_watch.take(),
|
|
},
|
|
)
|
|
.await;
|
|
});
|
|
|
|
Ok(LaunchedGame { cluster_id, pid })
|
|
}
|
|
|
|
async fn running_cluster_name(state: &Arc<LauncherState>, cluster_id: i64) -> String {
|
|
state
|
|
.clusters
|
|
.get(cluster_id)
|
|
.await
|
|
.map_or_else(|_| format!("Cluster {cluster_id}"), |cluster| cluster.name)
|
|
}
|
|
|
|
/// Cuts the game loose from the launcher's process group/console so signals
|
|
/// aimed at the launcher (Ctrl-C console close) don't reach the game
|
|
fn detach(command: &mut Command) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
command.as_std_mut().process_group(0);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
|
command
|
|
.as_std_mut()
|
|
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn killed(status: &std::process::ExitStatus) -> bool {
|
|
use std::os::unix::process::ExitStatusExt;
|
|
const SIGKILL: i32 = 9;
|
|
const JVM_SIGTERM_EXIT: i32 = 143;
|
|
status.signal() == Some(SIGKILL) || status.code() == Some(JVM_SIGTERM_EXIT)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn killed(_status: &std::process::ExitStatus) -> bool {
|
|
false
|
|
}
|
|
|
|
pub(crate) enum Exit {
|
|
Observed {
|
|
code: Option<i64>,
|
|
success: bool,
|
|
display: String,
|
|
},
|
|
Failed(String),
|
|
/// Game exited while the launcher was closed time recovered from the log
|
|
/// exit code unrecoverable
|
|
Inferred,
|
|
}
|
|
|
|
pub(crate) struct SessionEnd {
|
|
pub started_at: DateTime<Utc>,
|
|
pub ended_at: DateTime<Utc>,
|
|
pub outcome: Exit,
|
|
/// False for a stale session recovered from the database whose cluster has a
|
|
/// live game book its playtime but clearing the slot would report the live
|
|
/// game as exited
|
|
pub owns_slot: bool,
|
|
/// `None` for a clean exit an unrecognised crash or a session recovered
|
|
/// after the fact with no log watched
|
|
pub diagnosis: Option<crate::game::diagnosis::CrashDiagnosis>,
|
|
}
|
|
|
|
/// Shared by the live exit path and by recovery of sessions that outlived the
|
|
/// launcher
|
|
pub(crate) async fn finalize_session(
|
|
state: &Arc<LauncherState>,
|
|
cluster: &Cluster,
|
|
cwd: &Path,
|
|
dedicated: bool,
|
|
post_hook: Option<&str>,
|
|
recorder: Option<SessionRecorder>,
|
|
end: SessionEnd,
|
|
) {
|
|
let cluster_id = cluster.id;
|
|
let played = (end.ended_at - end.started_at).to_std().unwrap_or_default();
|
|
|
|
if end.owns_slot {
|
|
state.games.remove(cluster_id);
|
|
state
|
|
.services
|
|
.events
|
|
.game_stage(cluster_id, LaunchStage::Exited);
|
|
|
|
if state.games.running_ids().is_empty() {
|
|
state.discord.set_presence(Presence::Idle);
|
|
}
|
|
}
|
|
|
|
if played > Duration::from_secs(1) {
|
|
let _ = state.clusters.add_playtime(cluster_id, played).await;
|
|
}
|
|
|
|
if let Some(recorder) = recorder {
|
|
let code = match &end.outcome {
|
|
Exit::Observed { code, .. } => *code,
|
|
Exit::Failed(_) | Exit::Inferred => None,
|
|
};
|
|
recorder.finish_at(&end.ended_at.to_rfc3339(), code).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
|
|
// for external tools adopting drop-ins now keeps the UI right on close
|
|
crate::game::import_manual_content(&state.services, cluster, cwd).await;
|
|
} else {
|
|
if let Err(err) = crate::game::dematerialize_content(&state.services, cluster, cwd).await {
|
|
tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit");
|
|
}
|
|
crate::game::unlink_cluster_logs(cwd).await;
|
|
}
|
|
|
|
let name = &cluster.name;
|
|
let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. });
|
|
|
|
match end.outcome {
|
|
Exit::Observed { success: true, .. } => {}
|
|
Exit::Observed { display, .. } => state
|
|
.services
|
|
.events
|
|
.notify("Game crashed")
|
|
.body(format!("{name} exited with {display}"))
|
|
.error()
|
|
.send(),
|
|
Exit::Failed(err) => state
|
|
.services
|
|
.events
|
|
.notify("Game error")
|
|
.body(format!("{name}: {err}"))
|
|
.error()
|
|
.send(),
|
|
// Nothing was watching so there is no crash to report
|
|
Exit::Inferred => {}
|
|
}
|
|
|
|
// Only when the game actually died a `ZipException` it recovered from is not
|
|
// worth interrupting a finished session over
|
|
if crashed && let Some(diagnosis) = end.diagnosis {
|
|
offer_repair(state, cluster_id, &diagnosis).await;
|
|
}
|
|
}
|
|
|
|
/// Asks rather than acting verification can re-download much of the game too
|
|
/// much to start unprompted
|
|
/// The offer returns 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 there to ask neither is consent to re-download
|
|
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, Option<String>) {
|
|
let Some(wrapper) = profile
|
|
.hook_wrapper
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|hook| !hook.is_empty())
|
|
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 command_line(command: &std::process::Command, main_class: &str) -> String {
|
|
#[cfg(not(windows))]
|
|
const CONTINUATION: &str = " \\\n ";
|
|
#[cfg(windows)]
|
|
const CONTINUATION: &str = " `\n ";
|
|
|
|
let mut out = String::new();
|
|
if let Some(dir) = command.get_current_dir() {
|
|
let _ = writeln!(out, "cd {}", shell_quote(&dir.to_string_lossy()));
|
|
}
|
|
|
|
for (key, value) in command.get_envs() {
|
|
let (key, Some(value)) = (key.to_string_lossy(), value) else {
|
|
continue;
|
|
};
|
|
let value = shell_quote(&value.to_string_lossy());
|
|
#[cfg(not(windows))]
|
|
let _ = write!(out, "{key}={value} ");
|
|
#[cfg(windows)]
|
|
let _ = writeln!(out, "$env:{key} = {value}");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
out += "& ";
|
|
}
|
|
out += &shell_quote(&command.get_program().to_string_lossy());
|
|
|
|
for arg in command.get_args() {
|
|
let arg = arg.to_string_lossy();
|
|
let arg = oneclient_cluster::logs::censor(&arg);
|
|
out += if arg.starts_with('-') || arg == main_class {
|
|
CONTINUATION
|
|
} else {
|
|
" "
|
|
};
|
|
out += &shell_quote(&arg);
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn shell_quote(arg: &str) -> String {
|
|
let plain = !arg.is_empty()
|
|
&& arg
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b"_-./=:,+@%".contains(&b));
|
|
if plain {
|
|
arg.to_string()
|
|
} else {
|
|
format!("'{}'", arg.replace('\'', r"'\''"))
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn shell_quote(arg: &str) -> String {
|
|
format!("'{}'", arg.replace('\'', "''"))
|
|
}
|
|
|
|
fn apply_env(command: &mut Command, profile: &GameSettingsProfile) {
|
|
command.env_remove("_JAVA_OPTIONS");
|
|
|
|
if let Some(env) = &profile.launch_env {
|
|
for pair in env.split_whitespace() {
|
|
if let Some((key, value)) = pair.split_once('=') {
|
|
command.env(key, value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip(cwd), fields(hook), level = "debug")]
|
|
async fn run_hook(hook: Option<&str>, cwd: &Path) -> Option<String> {
|
|
let hook = hook.map(str::trim).filter(|h| !h.is_empty())?;
|
|
|
|
#[cfg(windows)]
|
|
let mut command = {
|
|
let mut c = Command::new("cmd");
|
|
c.args(["/C", hook]);
|
|
c
|
|
};
|
|
#[cfg(not(windows))]
|
|
let mut command = {
|
|
let mut c = Command::new("sh");
|
|
c.args(["-c", hook]);
|
|
c
|
|
};
|
|
|
|
command.current_dir(cwd);
|
|
command.stderr(Stdio::piped());
|
|
command.stdin(Stdio::null());
|
|
oneclient_common::process::no_window(command.as_std_mut());
|
|
|
|
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<String> {
|
|
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)
|
|
}
|