feat: Launching

This commit is contained in:
LynithDev 2025-04-18 16:28:38 +02:00
parent 71d584e5a3
commit 06215367c2
28 changed files with 632 additions and 224 deletions

1
Cargo.lock generated
View file

@ -3070,6 +3070,7 @@ dependencies = [
"thiserror 1.0.64",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-error",

View file

@ -106,6 +106,7 @@ webbrowser = { version = "1.0" }
# io
tokio = { version = "1.40", features = [ "full" ] }
tokio-util = { version = "0.7", features = [ "compat" ] }
tokio-stream = { version = "0.1.16", features = [ "fs" ] }
notify = { version = "6.1", default-features = false }
notify-debouncer-mini = { version = "0.4", default-features = false }

View file

@ -14,7 +14,7 @@ authors = { workspace = true }
path = "src/lib.rs"
[features]
default = []
default = [ "cli" ]
tauri = [
"dep:tauri",
"dep:tauri-specta",
@ -51,6 +51,7 @@ serde_with = { workspace = true }
# utils
sea-orm = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tokio-stream = { workspace = true }
futures = { workspace = true }
futures-lite = { workspace = true }

View file

@ -23,7 +23,7 @@ async fn main() -> LauncherResult<()> {
println!("cluster created: {cluster:#?}");
println!("preparing cluster for launch");
api::cluster::prepare_cluster(cluster).await?;
api::cluster::prepare_cluster(cluster, None).await?;
Ok(())
}

View file

@ -0,0 +1,80 @@
use chrono::{Days, Utc};
use onelauncher_core::api::cluster::dao::update_cluster;
use onelauncher_core::api::proxy::ProxyDynamic;
use onelauncher_core::api::{self, setting_profiles};
use onelauncher_core::error::LauncherResult;
use onelauncher_core::initialize_core;
use onelauncher_core::store::credentials::MinecraftCredentials;
use onelauncher_core::store::{CoreOptions, Dirs, State};
use onelauncher_entity::loader::GameLoader;
use sea_orm::ActiveValue::Set;
#[tokio::main]
pub async fn main() -> LauncherResult<()> {
initialize_core(CoreOptions::default(), ProxyDynamic::default()).await?;
let state = State::get().await?;
let dirs = Dirs::get().await?;
// We get the default credentials (or for the sake of this example, return an empty player profile)
println!("Getting credentials...");
let mut cred_store = state.credentials.write().await;
let creds = cred_store
.get_default()
.await?
.unwrap_or_else(|| MinecraftCredentials {
access_token: String::new(),
refresh_token: String::new(),
username: "Player1".to_string(),
id: uuid::Uuid::nil(),
expires: Utc::now().checked_add_days(Days::new(7)).unwrap(),
});
println!("Credentials: {creds:?}");
drop(cred_store);
// We create a settings profile so that we can run hooks
let profile_name = "profile with hooks example";
if setting_profiles::dao::get_profile_by_name(profile_name).await?.is_none() {
setting_profiles::create_profile(profile_name, async |mut profile| {
profile.hook_pre = Set(Some("echo this is a pre-hook!".to_string()));
profile.hook_post = Set(Some("echo this is my post-hook!".to_string()));
#[cfg(target_os = "linux")]
{
profile.hook_wrapper = Set(Some("mangohud".to_string()));
}
Ok(profile)
}).await?;
}
// Here we create the cluster
let cluster_name = "Launchable";
let path = dirs.clusters_dir().join(cluster_name);
let mut cluster = if let Some(cluster) = api::cluster::dao::get_cluster_by_path(&path).await? {
cluster
} else {
api::cluster::create_cluster(cluster_name, "1.20.4", GameLoader::Fabric, None, None).await?
};
// We set the clusters
update_cluster(&mut cluster, async |mut cluster| {
cluster.setting_profile_name = Set(Some(profile_name.to_string()));
Ok(cluster)
}).await?;
println!("Using cluster: {} at '{}'", cluster.name, cluster.path);
// Launch the game
let process = api::game::launch::launch_minecraft(&mut cluster, creds, None).await?;
println!("Process: {:?}", process.read().await);
// We wait for the process to finish so that we can run code after
process.read().await.child_type.write().await.wait().await?;
println!("Successfully exited !");
Ok(())
}

View file

@ -5,6 +5,8 @@ use sea_orm::{ActiveValue::Set, IntoActiveModel, prelude::*};
use crate::{error::{DaoError, LauncherResult}, store::State};
pub type ClusterId = i64;
/// Inserts a cluster in the database.
pub async fn insert_cluster(
name: &str,
@ -34,7 +36,7 @@ pub async fn insert_cluster(
/// Updates an existing cluster in the database.
pub async fn update_cluster_by_id<B>(
id: u64,
id: ClusterId,
block: B,
) -> LauncherResult<clusters::Model>
where B: AsyncFnOnce(clusters::ActiveModel) -> LauncherResult<clusters::ActiveModel> {
@ -66,7 +68,7 @@ where B: AsyncFnOnce(clusters::ActiveModel) -> LauncherResult<clusters::ActiveMo
}
/// Deletes a cluster by its ID from the **database**.
pub async fn delete_cluster_by_id(id: u64) -> LauncherResult<()> {
pub async fn delete_cluster_by_id(id: ClusterId) -> LauncherResult<()> {
let state = State::get().await?;
let db = &state.db;
@ -82,7 +84,7 @@ pub async fn delete_cluster_by_id(id: u64) -> LauncherResult<()> {
}
/// Gets a cluster by its ID from the database.
pub async fn get_cluster_by_id(id: u64) -> LauncherResult<Option<clusters::Model>> {
pub async fn get_cluster_by_id(id: ClusterId) -> LauncherResult<Option<clusters::Model>> {
let state = State::get().await?;
let db = &state.db;

View file

@ -1,4 +1,5 @@
use chrono::Utc;
use dao::ClusterId;
use interpulse::api::minecraft::VersionInfo;
use onelauncher_entity::cluster_stage::ClusterStage;
use onelauncher_entity::{clusters, java_versions};
@ -31,6 +32,8 @@ pub enum ClusterError {
MissingJavaVersion,
#[error("cluster is in downloading stage")]
ClusterDownloading,
#[error("cluster is already running")]
ClusterAlreadyRunning,
}
#[must_use]
@ -102,17 +105,17 @@ pub async fn create_cluster(
}
/// Make a cluster playable (installs all necessary game files and dependencies required to play)
pub async fn prepare_cluster(cluster: &mut clusters::Model) -> LauncherResult<&clusters::Model> {
pub async fn prepare_cluster(cluster: &mut clusters::Model, force: Option<bool>) -> LauncherResult<&clusters::Model> {
const INGRESS_TOTAL: f64 = 100.0;
const INGRESS_TASKS: f64 = 4.0;
const INGRESS_STEP: f64 = INGRESS_TOTAL / INGRESS_TASKS;
tracing::debug!("preparing cluster {}", cluster.name);
if cluster.stage == ClusterStage::Ready {
tracing::info!("cluster is already ready");
return Ok(cluster);
}
// if cluster.stage == ClusterStage::Ready {
// tracing::info!("cluster is already ready");
// return Ok(cluster);
// }
let ingress_id = init_ingress(
IngressType::PrepareCluster {
@ -144,7 +147,10 @@ pub async fn prepare_cluster(cluster: &mut clusters::Model) -> LauncherResult<&c
.versions
.iter()
.find(|v| v.id == cluster.mc_version)
.ok_or_else(|| ClusterError::InvalidVersion(cluster.mc_version.clone()))?;
.ok_or_else(|| ClusterError::InvalidVersion(cluster.mc_version.clone()))?
.clone();
drop(metadata);
let loader_version = metadata::get_loader_version(
&cluster.mc_version,
@ -155,10 +161,10 @@ pub async fn prepare_cluster(cluster: &mut clusters::Model) -> LauncherResult<&c
// TASK 2
let mut version_info = metadata::download_version_info(
version,
&version,
loader_version.as_ref(),
Some(&SubIngress::new(&ingress_id, INGRESS_STEP)),
None,
force,
)
.await?;
@ -173,7 +179,7 @@ pub async fn prepare_cluster(cluster: &mut clusters::Model) -> LauncherResult<&c
};
// TASK 3
download_minecraft(&version_info, &java_version.arch, Some(&SubIngress::new(&ingress_id, INGRESS_STEP)), None).await?;
download_minecraft(&version_info, &java_version.arch, Some(&SubIngress::new(&ingress_id, INGRESS_STEP)), force).await?;
// TASK 4
run_forge_processors(cluster, &mut version_info, java_version, &SubIngress::new(&ingress_id, INGRESS_STEP)).await?;
@ -303,7 +309,7 @@ async fn run_forge_processors(
Ok(())
}
pub async fn update_playtime(id: u64, duration: i64) -> LauncherResult<clusters::Model> {
pub async fn update_playtime(id: ClusterId, duration: i64) -> LauncherResult<clusters::Model> {
dao::update_cluster_by_id(id, async |mut cluster| {
let overall_played = cluster.overall_played.take().unwrap_or_default().unwrap_or_default();

View file

@ -5,7 +5,7 @@ use serde::Serialize;
use crate::{error::{DaoError, LauncherResult}, send_error, store::Dirs, utils::io};
use super::dao;
use super::dao::{self, ClusterId};
#[onelauncher_macro::specta]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@ -26,7 +26,7 @@ impl SyncAction {
/// Syncs all clusters in the database. Returns a list of cluster IDs that are missing.
#[tracing::instrument]
pub async fn sync_clusters() -> LauncherResult<Vec<u64>> {
pub async fn sync_clusters() -> LauncherResult<Vec<ClusterId>> {
let clusters = dao::get_all_clusters().await?;
let mut missing_ids = Vec::new();
@ -92,7 +92,7 @@ pub async fn sync_cluster(cluster: &clusters::Model) -> LauncherResult<SyncActio
/// Syncs the cluster with the database.
/// Checks if the cluster in the database exists and if the directory exists.
#[tracing::instrument]
pub async fn sync_cluster_by_id(id: u64) -> LauncherResult<SyncAction> {
pub async fn sync_cluster_by_id(id: ClusterId) -> LauncherResult<SyncAction> {
let cluster = dao::get_cluster_by_id(id)
.await?
.ok_or(DaoError::NotFound)?;

View file

@ -7,9 +7,11 @@ use futures::AsyncReadExt;
use interpulse::api::minecraft::{Argument, ArgumentValue, Library, VersionType};
use interpulse::api::modded::SidedDataEntry;
use interpulse::utils::get_path_from_artifact;
use onelauncher_entity::resolution::Resolution;
use crate::constants::{self, DUMMY_REPLACE_NEWLINE};
use crate::error::LauncherResult;
use crate::store::credentials::MinecraftCredentials;
use crate::store::Core;
use crate::utils::io;
@ -20,9 +22,8 @@ pub fn java_arguments(
libraries_path: &Path,
classpaths: &str,
version: &str,
mem_min: usize,
mem_max: usize,
custom_args: Vec<String>,
mem_max: u32,
custom_args: String,
java_arch: &str,
) -> LauncherResult<Vec<String>> {
let mut parsed = Vec::new();
@ -56,75 +57,72 @@ pub fn java_arguments(
}
parsed.push(format!("-Xmx{mem_max}M"));
parsed.push(format!("-Xms{mem_min}M"));
for arg in custom_args {
if !arg.is_empty() {
parsed.push(arg);
}
if !custom_args.is_empty() {
parsed.push(custom_args);
}
Ok(parsed)
}
// #[allow(clippy::too_many_arguments)]
// pub fn minecraft_arguments(
// args: Option<&[Argument]>,
// legacy_args: Option<&str>,
// creds: &MinecraftCredentials,
// version: &str,
// asset_index: &str,
// game_directory: &Path,
// assets_directory: &Path,
// version_type: VersionType,
// resolution: Resolution,
// java_arch: &str,
// ) -> LauncherResult<Vec<String>> {
// if let Some(args) = args {
// let mut parsed = Vec::new();
// parse_arguments(
// args,
// &mut parsed,
// |arg| {
// parse_minecraft_argument(
// arg,
// &creds.access_token,
// &creds.username,
// creds.id,
// version,
// asset_index,
// game_directory,
// assets_directory,
// version_type,
// resolution,
// )
// },
// java_arch,
// )?;
#[allow(clippy::too_many_arguments)]
pub fn minecraft_arguments(
args: Option<&[Argument]>,
legacy_args: Option<&str>,
creds: &MinecraftCredentials,
version: &str,
asset_index: &str,
game_directory: &Path,
assets_directory: &Path,
version_type: VersionType,
resolution: Resolution,
java_arch: &str,
) -> LauncherResult<Vec<String>> {
if let Some(args) = args {
let mut parsed = Vec::new();
parse_arguments(
args,
&mut parsed,
|arg| {
parse_minecraft_argument(
arg,
&creds.access_token,
&creds.username,
creds.id,
version,
asset_index,
game_directory,
assets_directory,
version_type,
resolution,
)
},
java_arch,
)?;
// Ok(parsed)
// } else if let Some(legacy_args) = legacy_args {
// let mut parsed = Vec::new();
// for arg in legacy_args.split(' ') {
// parsed.push(parse_minecraft_argument(
// &arg.replace(' ', DUMMY_REPLACE_NEWLINE),
// &creds.access_token,
// &creds.username,
// creds.id,
// version,
// asset_index,
// game_directory,
// assets_directory,
// version_type,
// resolution,
// )?);
// }
Ok(parsed)
} else if let Some(legacy_args) = legacy_args {
let mut parsed = Vec::new();
for arg in legacy_args.split(' ') {
parsed.push(parse_minecraft_argument(
&arg.replace(' ', DUMMY_REPLACE_NEWLINE),
&creds.access_token,
&creds.username,
creds.id,
version,
asset_index,
game_directory,
assets_directory,
version_type,
resolution,
)?);
}
// Ok(parsed)
// } else {
// Ok(Vec::new())
// }
// }
Ok(parsed)
} else {
Ok(Vec::new())
}
}
pub fn processor_arguments<T: AsRef<str>, S: ::std::hash::BuildHasher>(
libraries_path: &Path,
@ -206,7 +204,7 @@ pub fn parse_minecraft_argument(
game_directory: &Path,
assets_directory: &Path,
version_type: VersionType,
resolution: (u32, u32),
resolution: Resolution,
) -> LauncherResult<String> {
#[allow(clippy::literal_string_with_formatting_args)]
Ok(argument
@ -235,8 +233,8 @@ pub fn parse_minecraft_argument(
&io::canonicalize(assets_directory)?.to_string_lossy(),
)
.replace("${version_type}", version_type.as_str())
.replace("${resolution_width}", &resolution.0.to_string())
.replace("${resolution_height}", &resolution.1.to_string()))
.replace("${resolution_width}", &resolution.width.to_string())
.replace("${resolution_height}", &resolution.height.to_string()))
}
#[allow(clippy::too_many_arguments)]

View file

@ -1,22 +1,38 @@
use std::path::PathBuf;
use std::sync::Arc;
use onelauncher_entity::{cluster_stage::ClusterStage, prelude::model::*};
use tokio::sync::RwLock;
use chrono::Utc;
use discord_rich_presence::activity::Timestamps;
use merge::Merge;
use onelauncher_entity::prelude::model::*;
use sea_orm::ActiveValue::Set;
use tokio::process::Command;
use tokio::sync::RwLock;
use crate::{api::{cluster::{prepare_cluster, ClusterError}, game::metadata, java::{self, JavaError}, setting_profiles::{dao::get_profile_by_name, get_global_profile}}, error::LauncherResult, store::{credentials::MinecraftCredentials, processes::Process, State}, utils::io};
use crate::api::cluster::dao::update_cluster;
use crate::api::cluster::{ClusterError, prepare_cluster};
use crate::api::game::{arguments, log, metadata};
use crate::api::java::{self, JavaError};
use crate::api::setting_profiles::dao::get_profile_by_name;
use crate::api::setting_profiles::get_global_profile;
use crate::error::LauncherResult;
use crate::store::credentials::MinecraftCredentials;
use crate::store::processes::Process;
use crate::store::{Dirs, State};
use crate::utils::io;
#[tracing::instrument]
#[tracing::instrument(skip_all)]
pub async fn launch_minecraft(
cluster: &mut Cluster,
creds: &MinecraftCredentials,
creds: MinecraftCredentials,
force: Option<bool>,
) -> LauncherResult<Arc<RwLock<Process>>> {
if cluster.stage.is_downloading() {
return Err(ClusterError::ClusterDownloading.into());
} else if cluster.stage == ClusterStage::NotReady {
prepare_cluster(cluster).await?;
}
prepare_cluster(cluster, force).await?;
let mut settings = get_global_profile().await;
if let Some(name) = &cluster.setting_profile_name {
if let Some(profile) = get_profile_by_name(name).await? {
@ -25,45 +41,167 @@ pub async fn launch_minecraft(
}
let state = State::get().await?;
let instance_path = &io::canonicalize(&cluster.path)?;
let mut metadata = state.metadata.write().await;
let versions = &metadata.get_vanilla_or_fetch().await?.versions;
if !state.settings.read().await.allow_parallel_running_clusters {
let running = state.processes.has_running(cluster.id).await;
if running {
return Err(ClusterError::ClusterAlreadyRunning.into());
}
}
let cwd = &io::canonicalize(&cluster.path)?;
let metadata = state.metadata.read().await;
let versions = &metadata.get_vanilla()?.versions;
let version_index = versions
.iter()
.position(|it| it.id == cluster.mc_version)
.ok_or_else(|| anyhow::anyhow!("invalid game version {}", cluster.mc_version))?;
let version = &versions[version_index];
let version = versions[version_index].clone();
let updated = version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0);
let version_jar_name = cluster
.mc_loader_version
.as_ref()
.map_or(version.id.clone(), |it| {
format!("{}-{}", version.id.clone(), it.clone())
});
let loader_version = metadata::get_loader_version(&cluster.mc_version, cluster.mc_loader, cluster.mc_loader_version.as_deref()).await?;
let version_info = metadata::download_version_info(
version,
loader_version.as_ref(),
None,
None,
let loader_version = metadata::get_loader_version(
&cluster.mc_version,
cluster.mc_loader,
cluster.mc_loader_version.as_deref(),
)
.await?;
drop(metadata);
let version_name = loader_version
.as_ref()
.map_or(version.id.clone(), |it| {
format!("{}-{}", version.id, it.id)
});
let java = java::get_recommended_java(&version_info, Some(&settings)).await?;
if java.is_none() {
let version_info =
metadata::download_version_info(&version, loader_version.as_ref(), None, None).await?;
let Some(java) = java::get_recommended_java(&version_info, Some(&settings)).await? else {
return Err(JavaError::MissingJava.into());
};
let java_path = PathBuf::from(&java.absolute_path);
java::check_java_runtime(&java_path, false).await?;
let dirs = Dirs::get().await?;
let client_jar_path = dirs
.versions_dir()
.join(&version_name)
.join(format!("{version_name}.jar"));
let mut command = if let Some(wrapper) = &settings.hook_wrapper {
let mut cmd = Command::new(wrapper);
cmd.arg(&java.absolute_path);
cmd
} else {
Command::new(&java.absolute_path)
};
let env_vars = settings.launch_env.iter().flat_map(|it| {
it.split(' ').map(|it| {
let mut split = it.split('=');
let key = split.next().unwrap_or_default();
let value = split.next().unwrap_or_default();
(key.to_string(), value.to_string())
})
});
#[cfg(target_os = "macos")]
if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
}
command.env_remove("_JAVA_OPTIONS");
command.envs(env_vars);
let args = version_info.arguments.clone().unwrap_or_default();
todo!()
}
command
.args(
arguments::java_arguments(
args.get(&interpulse::api::minecraft::ArgumentType::Jvm)
.map(Vec::as_slice),
&dirs.natives_dir().join(&version_name),
&dirs.libraries_dir(),
&arguments::classpaths(
&dirs.libraries_dir(),
version_info.libraries.as_slice(),
&client_jar_path,
&java.arch,
updated,
)?,
&version_name,
settings.mem_max.unwrap_or(2048),
settings.launch_args.clone().unwrap_or_default(),
&java.arch,
)?
.into_iter(),
)
.arg(version_info.main_class.clone())
.args(
arguments::minecraft_arguments(
args.get(&interpulse::api::minecraft::ArgumentType::Game)
.map(Vec::as_slice),
version_info.minecraft_arguments.as_deref(),
&creds,
&version.id,
&version_info.asset_index.id,
cwd,
&dirs.assets_dir(),
version.type_,
settings.res.unwrap_or_default(),
&java.arch,
)?
.into_iter(),
)
.current_dir(cwd);
// TODO: Make all options configurable?
let mc_options = &[(
String::from("fullscreen"),
settings.force_fullscreen.unwrap_or(false).to_string(),
)];
if !mc_options.is_empty() {
let options_path = cwd.join("options.txt");
let mut options_string = if options_path.exists() {
io::read_to_string(&options_path).await?
} else {
String::new()
};
for (key, value) in mc_options {
let re = regex::Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
if re.is_match(&options_string) {
let replaced_string = re
.replace_all(&options_string, &format!("{key}:{value}"))
.to_string();
options_string = replaced_string;
} else {
use std::fmt::Write;
let _ = write!(options_string, "\n{key}:{value}");
}
}
io::write(&options_path, options_string).await?;
}
let start_time = Utc::now().timestamp();
update_cluster(cluster, async |mut cluster| {
cluster.last_played = Set(Some(start_time));
Ok(cluster)
})
.await?;
let censors = log::create_censors(&creds);
if let Some(discord) = &state.rpc {
discord.set_message(
&format!("Playing {}", cluster.name),
Some(Timestamps::new().start(start_time)),
).await;
}
state.processes.spawn(cluster.id, cwd.clone(), creds, censors, &settings, command).await
}

View file

@ -1,27 +1,36 @@
use std::sync::LazyLock;
use std::collections::HashMap;
use crate::store::credentials::MinecraftCredentials;
static WHOAMI: LazyLock<(String, String)> = LazyLock::new(|| {
(whoami::username(), whoami::realname())
});
pub type CensorMap = HashMap<String, String>;
#[must_use]
pub fn censor_line(
pub fn create_censors(
creds: &MinecraftCredentials,
mut output: String
) -> String {
let username = &WHOAMI.0;
let realname = &WHOAMI.1;
) -> CensorMap {
let mut map = HashMap::new();
output = output.replace(&format!("/{username}/"), "/{ENV_USERNAME}/");
output = output.replace(&format!("\\{username}\\"), "\\{ENV_USERNAME}\\");
output = output.replace(&format!("/{realname}/"), "/{ENV_REALNAME}/");
output = output.replace(&format!("\\{realname}\\"), "\\{ENV_REALNAME}\\");
output = output.replace(&creds.access_token, "{MC_ACCESS_TOKEN}");
output = output.replace(&creds.username, "{MC_USERNAME}");
output = output.replace(&creds.id.as_simple().to_string(), "{MC_UUID}");
output = output.replace(&creds.id.as_hyphenated().to_string(), "{MC_UUID}");
let username = whoami::username();
let realname = whoami::realname();
output
map.insert(format!("/{username}/"), "/{ENV_USERNAME}/".to_string());
map.insert(format!("\\{username}\\"), "\\{ENV_USERNAME}\\".to_string());
map.insert(format!("/{realname}/"), "/{ENV_REALNAME}/".to_string());
map.insert(format!("\\{realname}\\"), "\\{ENV_REALNAME}\\".to_string());
if !creds.access_token.is_empty() {
map.insert(creds.access_token.clone(), "{MC_ACCESS_TOKEN}".to_string());
}
map.insert(creds.username.clone(), "{MC_USERNAME}".to_string());
map.insert(creds.id.as_simple().to_string(), "{MC_UUID}".to_string());
map.insert(creds.id.as_hyphenated().to_string(), "{MC_UUID}".to_string());
map
}
pub fn censor_line(censors: &CensorMap, line: &mut String) {
for (key, value) in censors {
*line = line.replace(key, value);
}
}

View file

@ -50,7 +50,7 @@ pub async fn download_minecraft(
}
// MARK: Version Info
#[tracing::instrument(skip_all)]
#[tracing::instrument(skip(ingress, force))]
pub async fn download_version_info(
version: &Version,
loader: Option<&LoaderVersion>,
@ -120,7 +120,7 @@ pub async fn download_assets_index(
tracing::debug!("loading assets index for version {}", version.id);
let path = Dirs::get_assets_index_dir()
.await?
.join(version.asset_index.id.clone());
.join(format!("{}.json", version.asset_index.id));
// let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 2.0));
// let ingress_ref = ingress_ref.as_ref();
@ -274,10 +274,10 @@ pub async fn download_libraries(
tracing::debug!("loading libraries for version {}", version);
let lib_dir = Dirs::get_libraries_dir().await?;
let natives_dir = Dirs::get_natives_dir().await?;
let natives_dest = Dirs::get_natives_dir().await?.join(version);
io::create_dir_all(&lib_dir).await?;
io::create_dir_all(&natives_dir).await?;
io::create_dir_all(&natives_dest).await?;
let num_files = libraries.len();
let ingress = ingress.ingress_sub(|total| total / num_files as f64);
@ -297,15 +297,16 @@ pub async fn download_libraries(
return Ok(());
}
let artifact_path = interpulse::utils::get_path_from_artifact(&lib.name)?;
let path = lib_dir.join(&artifact_path);
if path.exists() && !force.unwrap_or(false) {
tracing::debug!("library {} is installed, skipping", &lib.name);
return Ok(());
}
tokio::try_join! {
async {
let artifact_path = interpulse::utils::get_path_from_artifact(&lib.name)?;
let path = lib_dir.join(&artifact_path);
if path.exists() && !force.unwrap_or(false) {
return Ok(());
}
if let Some(interpulse::api::minecraft::LibraryDownloads {
artifact: Some(ref artifact), ..
}) = lib.downloads {
@ -343,6 +344,7 @@ pub async fn download_libraries(
let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH);
if let Some(native) = classifiers.get(&parsed) {
tracing::trace!("found native library {}", &lib.name);
let data = http::fetch_advanced(
Method::GET,
&native.url,
@ -352,9 +354,12 @@ pub async fn download_libraries(
ingress,
).await?;
let dest = natives_dir.join(version);
io::unzip_bytes(data.to_vec(), &dest).await?;
tracing::trace!("extracted native {} to path {:?}", &lib.name, &dest);
io::unzip_bytes_filtered(
data.to_vec(),
Some(|name: &str| !name.starts_with("META-INF")),
&natives_dest
).await?;
tracing::trace!("extracted native {} to path {:?}", &lib.name, &natives_dest);
}
}
@ -380,6 +385,7 @@ pub async fn download_libraries(
/// Gets the loader version for a given Minecraft version and loader
/// If `loader_version` is `None`, it will return the latest stable version else
/// it will return the specified version if found
#[tracing::instrument]
pub async fn get_loader_version(
mc_version: &str,
loader: GameLoader,
@ -405,6 +411,7 @@ pub async fn get_loader_version(
.loaders
.iter()
.find(|it| loader_version.map_or(it.stable, |version| it.id == version))
.or_else(|| loaders.loaders.first())
.cloned()
.ok_or(MetadataError::NoMatchingLoader)?;

View file

@ -7,6 +7,8 @@ use crate::{error::LauncherResult, store::State};
use super::{JavaError, JavaInfo};
pub type JavaVersionId = i64;
/// Returns the latest Java version
pub async fn get_latest_java() -> LauncherResult<Option<java_versions::Model>> {
let db = &State::get().await?.db;
@ -50,7 +52,7 @@ pub async fn get_java_all() -> LauncherResult<Vec<java_versions::Model>> {
}
/// Returns the specific Java version by ID
pub async fn get_java_by_id(id: u64) -> LauncherResult<Option<java_versions::Model>> {
pub async fn get_java_by_id(id: JavaVersionId) -> LauncherResult<Option<java_versions::Model>> {
let db = &State::get().await?.db;
Ok(java_versions::Entity::find()

View file

@ -4,6 +4,9 @@ use crate::store::processes::Process;
#[derive(Debug, Serialize)]
pub enum ProcessPayload {
Starting {
command: String,
},
Started {
process: Process,
},

View file

@ -4,7 +4,7 @@ use indicatif::ProgressBar;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{constants::CLI_TOTAL_INGRESS, api::proxy::{event::LauncherEvent, LauncherProxy}, LauncherResult};
use crate::{api::{processes::ProcessPayload, proxy::{event::LauncherEvent, LauncherProxy}}, constants::CLI_TOTAL_INGRESS, LauncherResult};
#[derive(Default, Debug)]
pub struct ProxyCli {
@ -49,9 +49,22 @@ impl LauncherProxy for ProxyCli {
LauncherEvent::Message(message) => {
println!("[{:?}] {}", message.level, message.message);
},
_ => {
println!("{:#?}", event);
},
LauncherEvent::Process(process) => {
match process {
ProcessPayload::Starting { command } => {
println!("Starting process: {command}");
},
ProcessPayload::Started { process } => {
println!("Process started: {process:#?}");
},
ProcessPayload::Stopped { pid, exit_code } => {
println!("Process {pid} exited with code {exit_code}");
},
ProcessPayload::Output { pid, output } => {
println!("Process {pid}: {output}");
},
}
}
}
Ok(())

View file

@ -1,6 +1,7 @@
use onelauncher_entity::setting_profiles;
use sea_orm::ActiveValue::Set;
use crate::store::State;
use crate::{error::LauncherResult, store::State};
pub mod dao {
use onelauncher_entity::setting_profiles;
@ -65,6 +66,7 @@ pub mod dao {
}
}
/// Returns the global settings profile
pub async fn get_global_profile() -> setting_profiles::Model {
let Ok(state) = State::get().await else {
return setting_profiles::Model::default_global_profile();
@ -73,3 +75,16 @@ pub async fn get_global_profile() -> setting_profiles::Model {
let settings = state.settings.read().await;
settings.global_game_settings.clone()
}
/// Creates a new setting profile and inserts it into the database. Returns the inserted entry
pub async fn create_profile<F>(name: &str, block: F) -> LauncherResult<setting_profiles::Model>
where F: AsyncFnOnce(setting_profiles::ActiveModel) -> LauncherResult<setting_profiles::ActiveModel> {
let model = setting_profiles::ActiveModel {
name: Set(name.to_string()),
..Default::default()
};
let model = block(model).await?;
dao::insert_profile(model).await
}

View file

@ -33,6 +33,8 @@ pub enum LauncherError {
ReqwestError(#[from] reqwest::Error),
#[error(transparent)]
InterpulseError(#[from] interpulse::Error),
#[error(transparent)]
RegexError(#[from] regex::Error),
#[error("couldn't acquire semaphore: {0}")]
SemaphoreError(#[from] tokio::sync::AcquireError),

View file

@ -59,6 +59,7 @@ impl Metadata {
}
#[tracing::instrument(skip(self))]
pub async fn get_vanilla_or_fetch(&mut self) -> LauncherResult<&VanillaManifest> {
if !self.initialized() {
self.initialize().await?;
@ -67,6 +68,7 @@ impl Metadata {
self.get_vanilla()
}
#[tracing::instrument(skip(self))]
pub async fn get_modded_or_fetch(&mut self, loader: GameLoader) -> LauncherResult<&ModdedManifest> {
if !loader.is_modded() {
return Err(MetadataError::NotModdedManifest(loader).into());

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
@ -12,10 +13,11 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::api::cluster;
use crate::api::game::log::censor_line;
use crate::api::cluster::dao::ClusterId;
use crate::api::game::log::{censor_line, CensorMap};
use crate::api::processes::ProcessPayload;
use crate::api::proxy::event::{send_event, LauncherEvent};
use crate::error::{DaoError, LauncherResult};
use crate::error::LauncherResult;
use crate::send_error;
use crate::utils::io::IOError;
@ -39,7 +41,7 @@ pub struct ProcessStore {
pub struct Process {
pub pid: u32,
pub started_at: DateTime<Utc>,
pub cluster_id: u64,
pub cluster_id: ClusterId,
pub post_hook: Option<String>,
pub account_id: Uuid,
#[serde(skip)]
@ -79,7 +81,7 @@ impl ChildType {
Self::Owned(child) => child
.try_wait()
.map_err(IOError::from)?
.map(|s| s.code().unwrap_or_default()),
.and_then(|s| s.code()),
})
}
@ -109,13 +111,83 @@ impl ProcessStore {
Self::default()
}
/// Returns a list of all running processes for a given cluster.
pub async fn get_running_by_cluster(
&self,
cluster_id: ClusterId,
) -> Vec<Arc<RwLock<Process>>> {
let mut list = Vec::new();
let read_guard = self.processes.read().await;
for process in read_guard.values() {
if process.read().await.cluster_id == cluster_id {
list.push(process.clone());
}
}
list
}
/// Returns a list of all running processes for a given account.
pub async fn get_running_by_account(
&self,
account_id: Uuid,
) -> Vec<Arc<RwLock<Process>>> {
let mut list = Vec::new();
let read_guard = self.processes.read().await;
for process in read_guard.values() {
if process.read().await.account_id == account_id {
list.push(process.clone());
}
}
list
}
/// Returns a list of all running processes.
pub async fn get_running(&self) -> Vec<Arc<RwLock<Process>>> {
let mut list = Vec::new();
let read_guard = self.processes.read().await;
for process in read_guard.values() {
list.push(process.clone());
}
list
}
/// Returns whether a cluster has a running process.
pub async fn has_running(&self, cluster_id: ClusterId) -> bool {
let read_guard = self.processes.read().await;
for process in read_guard.values() {
if process.read().await.cluster_id == cluster_id {
return true;
}
}
false
}
/// Spawns a new process (Minecraft Instance)
pub async fn spawn(
&self,
cluster_id: u64,
cluster_id: ClusterId,
cwd: PathBuf,
creds: MinecraftCredentials,
censors: CensorMap,
settings: &setting_profiles::Model,
mut command: Command,
) -> LauncherResult<Arc<RwLock<Process>>> {
send_event(LauncherEvent::Process(ProcessPayload::Starting { command: format!("{command:?}") })).await;
if let Some(pre_hook) = &settings.hook_pre {
if let Err(err) = run_hook(pre_hook, cwd.clone()).await {
send_error!("{err:?}");
}
}
command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -140,20 +212,19 @@ impl ProcessStore {
}
}
let account_id = creds.id;
tokio::spawn(async move {
let mut stdout = BufReader::new(stdout).lines();
let mut stderr = BufReader::new(stderr).lines();
while let Ok(Some(line)) = tokio::select! {
while let Ok(Some(mut line)) = tokio::select! {
line = stdout.next_line() => line,
line = stderr.next_line() => line,
} {
let censored = censor_line(&creds, line);
censor_line(&censors, &mut line);
send_event(LauncherEvent::Process(ProcessPayload::Output {
pid,
output: censored,
output: line,
})).await;
}
});
@ -164,10 +235,10 @@ impl ProcessStore {
started_at,
cluster_id,
post_hook: settings.hook_post.clone(),
account_id,
account_id: creds.id,
child_type: child_type.clone(),
process_loop: Some(tokio::task::spawn(Self::process_loop(
cluster_id, settings.hook_post.clone(), child_type,
cluster_id, cwd, settings.hook_post.clone(), child_type,
))),
};
@ -185,7 +256,8 @@ impl ProcessStore {
}
async fn process_loop(
cluster_id: u64,
cluster_id: ClusterId,
cwd: PathBuf,
post_hook: Option<String>,
child: Arc<RwLock<ChildType>>,
) -> LauncherResult<i32> {
@ -221,22 +293,8 @@ impl ProcessStore {
tracing::debug!("removed process with pid {}", pid);
if let Some(post_hook) = post_hook {
if !post_hook.is_empty() {
let result: LauncherResult<()> = {
let cluster = cluster::dao::get_cluster_by_id(cluster_id)
.await?
.ok_or(DaoError::NotFound)?;
if let Err(err) = run_hook(&post_hook, &cluster.path).await {
send_error!("{err:?}");
}
Ok(())
};
if let Err(err) = result {
send_error!("failed to run post hook: {}", err);
}
if let Err(err) = run_hook(&post_hook, cwd).await {
send_error!("{err:?}");
}
}
@ -246,13 +304,14 @@ impl ProcessStore {
}
}
pub async fn run_hook(hook: &str, cwd: &str) -> LauncherResult<Option<i32>> {
async fn run_hook(hook: &str, cwd: PathBuf) -> LauncherResult<Option<i32>> {
let mut split = hook.split(' ');
let Some(command) = split.next() else {
return Ok(None);
};
tracing::debug!("running hook");
let result = Command::new(command)
.args(split)
.current_dir(cwd)

View file

@ -8,6 +8,8 @@ use serde::de::DeserializeOwned;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::compat::TokioAsyncWriteCompatExt;
/// A wrapper around generic and unhelpful [`std::io::Error`] messages.
#[derive(Debug, thiserror::Error)]
pub enum IOError {
@ -215,6 +217,17 @@ pub async fn copy(
})
}
// /// Copies an async reader into an async writer. Returns the bytes copied
// pub async fn copy_buf<'a, R, W>(reader: &'a mut R, writer: &'a mut W) -> Result<u64, IOError>
// where
// R: TokioAsyncReadCompatExt,
// W: TokioAsyncWriteCompatExt,
// {
// tokio::io::copy_buf(reader, writer)
// .await
// .map_err(IOError::from)
// }
/// Removes a file from the filesystem.
pub async fn remove_file(path: impl AsRef<std::path::Path>) -> Result<(), IOError> {
let path = path.as_ref();
@ -237,10 +250,7 @@ pub fn tempfile() -> Result<std::fs::File, IOError> {
}
/// Reads a zip archive from a byte array
pub async fn read_zip_entries_bytes<F>(
data: Vec<u8>,
mut f: F,
) -> Result<(), IOError>
pub async fn read_zip_entries_bytes<F>(data: Vec<u8>, mut f: F) -> Result<(), IOError>
where
F: AsyncFnMut(
usize,
@ -272,11 +282,26 @@ where
pub async fn unzip_bytes(
data: Vec<u8>,
dest_path: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
unzip_bytes_filtered(data, None::<fn(&str) -> bool>, dest_path).await
}
/// Unzips a zip archive from a byte array
pub async fn unzip_bytes_filtered(
data: Vec<u8>,
filter_entries: Option<impl Fn(&str) -> bool>,
dest_path: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
read_zip_entries_bytes(data, async |_, entry, entry_reader| {
let path = dest_path
.as_ref()
.join(sanitize_path(entry.filename().as_str()?));
let file_name = entry.filename().as_str()?;
if let Some(filter) = &filter_entries {
if !filter(file_name) {
return Ok(());
}
}
let path = dest_path.as_ref().join(sanitize_path(file_name));
let is_dir = entry.dir()?;
@ -288,20 +313,14 @@ pub async fn unzip_bytes(
}
let file = tokio::fs::File::create(&path).await?;
let mut writer = tokio::io::BufWriter::new(file);
let writer = tokio::io::BufWriter::new(file);
let mut buf = vec![0; 8192];
loop {
let n = futures::AsyncReadExt::read(entry_reader, &mut buf).await?;
if n == 0 {
break;
}
writer.write_all(&buf[..n]).await?;
}
futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?;
}
Ok(())
}).await?;
})
.await?;
Ok(())
}
@ -331,17 +350,10 @@ pub async fn unzip_file(
}
let file = tokio::fs::File::create(&path).await?;
let mut writer = tokio::io::BufWriter::new(file);
let mut entry_reader = reader.reader_without_entry(index).await?;
let writer = tokio::io::BufWriter::new(file);
let entry_reader = reader.reader_without_entry(index).await?;
let mut buf = vec![0; 8192];
loop {
let n = futures::AsyncReadExt::read(&mut entry_reader, &mut buf).await?;
if n == 0 {
break;
}
writer.write_all(&buf[..n]).await?;
}
futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?;
}
}

View file

@ -1,4 +1,5 @@
pub mod cluster_stage;
pub mod icon;
pub mod loader;
pub mod resolution;
pub mod settings;

View file

@ -0,0 +1,58 @@
use sea_orm::{sea_query::{Nullable, ValueType}, TryGetableFromJson};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resolution {
pub width: u32,
pub height: u32,
}
impl Default for Resolution {
fn default() -> Self {
Self::new(854, 480)
}
}
impl Resolution {
pub fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
}
impl From<Resolution> for sea_orm::Value {
fn from(value: Resolution) -> Self {
sea_orm::Value::Json(serde_json::to_value(value).ok().map(Box::new))
}
}
impl TryGetableFromJson for Resolution {}
impl ValueType for Resolution {
fn try_from(v: sea_orm::Value) -> Result<Self, sea_orm::sea_query::ValueTypeErr> {
match v {
sea_orm::Value::Json(Some(json)) => Ok(
serde_json::from_value(*json).map_err(|_| sea_orm::sea_query::ValueTypeErr)?,
),
_ => Err(sea_orm::sea_query::ValueTypeErr),
}
}
fn type_name() -> String {
"Resolution".to_string()
}
fn array_type() -> sea_orm::sea_query::ArrayType {
sea_orm::sea_query::ArrayType::Json
}
fn column_type() -> sea_orm::ColumnType {
sea_orm::ColumnType::Json
}
}
impl Nullable for Resolution {
fn null() -> sea_orm::Value {
sea_orm::Value::Json(None)
}
}

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
#[onelauncher_macro::specta]
pub struct Model {
#[sea_orm(primary_key)]
pub id: u64,
pub id: i64,
#[sea_orm(column_type = "Text")]
pub name: String,
}

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
#[onelauncher_macro::specta]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub cluster_id: u64,
pub cluster_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub package_hash: String,
}

View file

@ -8,12 +8,12 @@ use crate::{cluster_stage::ClusterStage, icon::Icon, loader::GameLoader};
#[onelauncher_macro::specta]
pub struct Model {
#[sea_orm(primary_key)]
pub id: u64,
pub id: i64,
#[sea_orm(column_type = "Text")]
pub path: String,
pub stage: ClusterStage,
pub created_at: i64,
pub group_id: Option<u64>,
pub group_id: Option<i64>,
#[sea_orm(column_type = "Text")]
pub name: String,
#[sea_orm(column_type = "Text")]

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
#[onelauncher_macro::specta]
pub struct Model {
#[sea_orm(primary_key)]
pub id: u64,
pub id: i64,
pub major_version: u32,
#[sea_orm(column_type = "Text")]
pub vendor_name: String,

View file

@ -4,7 +4,7 @@ use merge::Merge;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::settings::SettingsOsExtra;
use crate::{resolution::Resolution, settings::SettingsOsExtra};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, Merge)]
#[sea_orm(table_name = "setting_profiles")]
@ -14,9 +14,9 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
#[merge(skip)]
pub name: String,
pub java_id: Option<u64>,
pub res_w: Option<u32>,
pub res_h: Option<u32>,
pub java_id: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub res: Option<Resolution>,
pub force_fullscreen: Option<bool>,
pub mem_max: Option<u32>,
#[sea_orm(column_type = "Text", nullable)]
@ -84,9 +84,8 @@ impl Model {
java_id: None,
launch_args: None,
launch_env: None,
mem_max: Some(3072),
res_h: None,
res_w: None,
mem_max: Some(2048),
res: None,
os_extra: Some(SettingsOsExtra::default())
}
}

View file

@ -33,8 +33,7 @@ create table packages (
create table setting_profiles (
name text not null,
java_id integer,
res_w integer,
res_h integer,
res json_text,
force_fullscreen boolean,
mem_max integer,
launch_args text,