mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
feat: instances v2 (#6431)
* feat: base of instances v2 * feat: use old profiles with compat layer * prototype: instances v2 * fix: install_from using profile * fix: skins migration fix * fix: frontend still using profile path * fix: add update proj multiselect guard * fix: cargo fmt * fix: content missing fields * feat: break up app-lib/api/instance.rs * fix: check_content_updates mismatch * fix: updater modal cleanup w/new structure * feat: better update all handling * fix: remove preview_update_all * fix: feedback on bulk update + lint * fix: rem transitions * fix: change to jsonb * feat: app db backup after update * fix: lint * fix: sqlx prepare + use sqlx macros * fix: lint * fix: bugs * feat: defuck the installing process up * fix: bug of hell * fix: shear * fix: fmt * fix: install progress spacing + change mc/content/overrides to bytes * fix: lint * fix: prepr * fix: navtabs anim not working in app * fix: worlds.vue improvements + browse page fixes * feat: optimise queries + adapter fns * fix: lint * fix: lint * feat: shared modrinth-content-management crate (#6469) * feat: disable warnings setting * feat: add instances shortcuts (#6329) * Add modrinth://launch deep link to start a profile Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck. * Change route to /launch/profile/{id} for future extensibility * fix: ensure profile path is url decoded * fix: URL-decode profile path from deep link * fix: use urlencoding crate for URL decoding * feat: implement app instance shortcuts * feat: change windows shortcut creation to use windows api instead * feat: implement creating a shortcut launching world/server * format * fmt * fix multiline inline tables * pnpm prepr * feat: move create shortcut to last item * refactor: split up shortcuts.rs for individual platforms * refactor: turn profile launch url into url type * use string literal and add safety comment * pt2 * refactor: rename anything that's profile into instance * update mac shortcut --------- Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com> --------- Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
co-authored by
DJCheesusReal
Truman Gao
parent
ef4044534f
commit
734720e11e
@@ -1,5 +1,4 @@
|
||||
use crate::worlds::{DisplayStatus, WorldType};
|
||||
use paste::paste;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -11,7 +10,7 @@ pub struct AttachedWorldData {
|
||||
|
||||
impl AttachedWorldData {
|
||||
pub async fn get_for_world(
|
||||
instance: &str,
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
@@ -20,51 +19,51 @@ impl AttachedWorldData {
|
||||
|
||||
let attached_data = sqlx::query!(
|
||||
"
|
||||
SELECT display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE profile_path = $1 and world_type = $2 and world_id = $3
|
||||
",
|
||||
instance,
|
||||
SELECT display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE instance_id = ? and world_type = ? and world_id = ?
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id
|
||||
world_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(attached_data.map(|x| AttachedWorldData {
|
||||
display_status: DisplayStatus::from_string(&x.display_status),
|
||||
project_id: x.project_id,
|
||||
content_kind: x.content_kind,
|
||||
Ok(attached_data.map(|row| AttachedWorldData {
|
||||
display_status: DisplayStatus::from_string(&row.display_status),
|
||||
project_id: row.project_id,
|
||||
content_kind: row.content_kind,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all_for_instance(
|
||||
instance: &str,
|
||||
instance_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<HashMap<(WorldType, String), Self>> {
|
||||
let attached_data = sqlx::query!(
|
||||
"
|
||||
SELECT world_type, world_id, display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE profile_path = $1
|
||||
",
|
||||
instance
|
||||
SELECT world_type, world_id, display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(attached_data
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let world_type = WorldType::from_string(&x.world_type);
|
||||
.map(|row| {
|
||||
let world_type = WorldType::from_string(&row.world_type);
|
||||
let display_status =
|
||||
DisplayStatus::from_string(&x.display_status);
|
||||
DisplayStatus::from_string(&row.display_status);
|
||||
(
|
||||
(world_type, x.world_id),
|
||||
(world_type, row.world_id),
|
||||
AttachedWorldData {
|
||||
display_status,
|
||||
project_id: x.project_id,
|
||||
content_kind: x.content_kind,
|
||||
project_id: row.project_id,
|
||||
content_kind: row.content_kind,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -72,7 +71,7 @@ impl AttachedWorldData {
|
||||
}
|
||||
|
||||
pub async fn remove_for_world(
|
||||
instance: &str,
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
@@ -81,12 +80,12 @@ impl AttachedWorldData {
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM attached_world_data
|
||||
WHERE profile_path = $1 and world_type = $2 and world_id = $3
|
||||
",
|
||||
instance,
|
||||
DELETE FROM attached_world_data
|
||||
WHERE instance_id = ? and world_type = ? and world_id = ?
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id
|
||||
world_id,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
@@ -95,38 +94,84 @@ impl AttachedWorldData {
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! attached_data_setter {
|
||||
($parameter:ident: $parameter_type:ty, $column:expr $(=> $adapter:expr)?) => {
|
||||
paste! {
|
||||
pub async fn [<set_ $parameter>](
|
||||
instance: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
$parameter: $parameter_type,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
$(let $parameter = $adapter;)?
|
||||
pub async fn set_display_status(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
display_status: DisplayStatus,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
let display_status = display_status.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO attached_world_data (profile_path, world_type, world_id, " + $column + ")\n" +
|
||||
"VALUES ($1, $2, $3, $4)\n" +
|
||||
"ON CONFLICT (profile_path, world_type, world_id) DO UPDATE\n" +
|
||||
" SET " + $column + " = $4",
|
||||
instance,
|
||||
world_type,
|
||||
world_id,
|
||||
$parameter
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, display_status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET display_status = excluded.display_status
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
display_status,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
attached_data_setter!(display_status: DisplayStatus, "display_status" => display_status.as_str());
|
||||
attached_data_setter!(project_id: &str, "project_id");
|
||||
attached_data_setter!(content_kind: &str, "content_kind");
|
||||
pub async fn set_project_id(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
project_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, project_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET project_id = excluded.project_id
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
project_id,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content_kind(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
content_kind: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, content_kind)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET content_kind = excluded.content_kind
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
content_kind,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1495,16 +1495,16 @@ impl CachedEntry {
|
||||
CacheValueType::FileHash => {
|
||||
// TODO: Replace state call here
|
||||
let state = crate::State::get().await?;
|
||||
let profiles_dir = state.directories.profiles_dir();
|
||||
let instances_dir = state.directories.instances_dir();
|
||||
|
||||
async fn hash_file(
|
||||
profiles_dir: &Path,
|
||||
instances_dir: &Path,
|
||||
key: String,
|
||||
) -> crate::Result<(CachedEntry, bool)> {
|
||||
let path =
|
||||
key.split_once('-').map(|x| x.1).unwrap_or_default();
|
||||
|
||||
let full_path = profiles_dir.join(path);
|
||||
let full_path = instances_dir.join(path);
|
||||
|
||||
let mut file = tokio::fs::File::open(&full_path).await?;
|
||||
let size = file.metadata().await?.len();
|
||||
@@ -1541,7 +1541,7 @@ impl CachedEntry {
|
||||
|
||||
use futures::stream::StreamExt;
|
||||
let results: Vec<_> = futures::stream::iter(keys)
|
||||
.map(|x| hash_file(&profiles_dir, x.to_string()))
|
||||
.map(|x| hash_file(&instances_dir, x.to_string()))
|
||||
.buffer_unordered(64) // hash 64 files at once
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
@@ -2120,7 +2120,7 @@ impl CachedEntry {
|
||||
|
||||
pub async fn cache_file_hash(
|
||||
bytes: bytes::Bytes,
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
path: &str,
|
||||
known_hash: Option<&str>,
|
||||
project_type: Option<ProjectType>,
|
||||
@@ -2136,7 +2136,7 @@ pub async fn cache_file_hash(
|
||||
};
|
||||
|
||||
cache_file_hash_metadata(
|
||||
profile_path,
|
||||
instance_id,
|
||||
path,
|
||||
size as u64,
|
||||
hash,
|
||||
@@ -2148,7 +2148,7 @@ pub async fn cache_file_hash(
|
||||
}
|
||||
|
||||
pub async fn cache_file_hash_metadata(
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
path: &str,
|
||||
size: u64,
|
||||
hash: String,
|
||||
@@ -2167,7 +2167,7 @@ pub async fn cache_file_hash_metadata(
|
||||
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
|
||||
CachedEntry::upsert_many(
|
||||
&[CacheValue::FileHash(CachedFileHash {
|
||||
path: format!("{profile_path}/{path}"),
|
||||
path: format!("{instance_id}/{path}"),
|
||||
size,
|
||||
hash,
|
||||
project_type,
|
||||
|
||||
@@ -3,7 +3,7 @@ use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
|
||||
};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::str::FromStr;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) async fn connect(
|
||||
@@ -14,22 +14,20 @@ pub(crate) async fn connect(
|
||||
"Could not find valid config dir".to_string(),
|
||||
))?;
|
||||
|
||||
if !settings_dir.exists() {
|
||||
crate::util::io::create_dir_all(&settings_dir).await?;
|
||||
}
|
||||
crate::util::io::create_dir_all(&settings_dir).await?;
|
||||
|
||||
let uri = format!("sqlite:{}", settings_dir.join("app.db").display());
|
||||
let db_path = settings_dir.join("app.db");
|
||||
|
||||
let conn_options = SqliteConnectOptions::from_str(&uri)?
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.optimize_on_close(true, None)
|
||||
.create_if_missing(true);
|
||||
connect_app_db(&db_path).await
|
||||
}
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(100)
|
||||
.connect_with(conn_options)
|
||||
.await?;
|
||||
async fn connect_app_db(db_path: &Path) -> crate::Result<Pool<Sqlite>> {
|
||||
super::db_backup::maybe_backup_existing_app_db(db_path).await?;
|
||||
open_migrated_app_db(db_path).await
|
||||
}
|
||||
|
||||
async fn open_migrated_app_db(db_path: &Path) -> crate::Result<Pool<Sqlite>> {
|
||||
let pool = open_app_db_pool(db_path).await?;
|
||||
|
||||
if let Err(err) = stale_data_cleanup(&pool).await {
|
||||
tracing::warn!(
|
||||
@@ -38,6 +36,7 @@ pub(crate) async fn connect(
|
||||
}
|
||||
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
record_current_app_version(&pool).await?;
|
||||
|
||||
if let Err(err) = stale_data_cleanup(&pool).await {
|
||||
tracing::warn!(
|
||||
@@ -48,6 +47,37 @@ pub(crate) async fn connect(
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
async fn open_app_db_pool(db_path: &Path) -> crate::Result<Pool<Sqlite>> {
|
||||
let conn_options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.optimize_on_close(true, None)
|
||||
.create_if_missing(true);
|
||||
|
||||
Ok(SqlitePoolOptions::new()
|
||||
.max_connections(100)
|
||||
.connect_with(conn_options)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn record_current_app_version(pool: &Pool<Sqlite>) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO app_metadata (key, value, updated_at)
|
||||
VALUES ('app_version', ?, unixepoch())
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleans up data from the database that is no longer referenced, but must be
|
||||
/// kept around for a little while to allow users to recover from accidental
|
||||
/// deletions.
|
||||
@@ -55,18 +85,18 @@ async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let has_skin_tables = sqlx::query!(
|
||||
"SELECT COUNT(*) AS \"count!: i64\" FROM sqlite_master WHERE type = 'table' AND name IN ('custom_minecraft_skins', 'minecraft_users')",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.count == 2;
|
||||
"SELECT COUNT(*) AS \"count!: i64\" FROM sqlite_master WHERE type = 'table' AND name IN ('custom_minecraft_skins', 'minecraft_users')",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.count == 2;
|
||||
|
||||
if has_skin_tables {
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub(crate) async fn maybe_backup_existing_app_db(
|
||||
db_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
if !db_path.try_exists()? {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because {} does not exist",
|
||||
db_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Inspecting {} for a pre-migration app database backup",
|
||||
db_path.display()
|
||||
);
|
||||
|
||||
let mut conn = match open_read_only_db(db_path).await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to open {} read-only before migrations: {err}",
|
||||
db_path.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let has_user_tables = match has_user_tables(&mut conn).await {
|
||||
Ok(has_user_tables) => has_user_tables,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to inspect app database tables before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if !has_user_tables {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because {} has no app data tables",
|
||||
db_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stored_version = match read_stored_app_version(&mut conn).await {
|
||||
Ok(version) => version,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to read stored app database version before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if stored_version.as_deref() == Some(CURRENT_APP_VERSION) {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because app version is already recorded as {CURRENT_APP_VERSION}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stored_version = stored_version.as_deref().unwrap_or("unknown");
|
||||
let backup_dir = match app_db_backup_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to resolve app database backup directory before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let backup_path = match next_backup_path(
|
||||
&backup_dir,
|
||||
stored_version,
|
||||
CURRENT_APP_VERSION,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to choose app database backup path in {} before migrations: {err}",
|
||||
backup_dir.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Creating pre-migration app database backup from version {stored_version} before opening with version {CURRENT_APP_VERSION} at {}",
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
if let Err(err) = create_sqlite_snapshot(&mut conn, &backup_path).await {
|
||||
tracing::error!(
|
||||
"Failed to create pre-migration app database backup at {}: {err}",
|
||||
backup_path.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Created pre-migration app database backup at {}",
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_read_only_db(db_path: &Path) -> crate::Result<SqliteConnection> {
|
||||
let conn_options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.read_only(true)
|
||||
.create_if_missing(false);
|
||||
|
||||
Ok(conn_options.connect().await?)
|
||||
}
|
||||
|
||||
pub fn app_db_backup_dir() -> crate::Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("THESEUS_DB_BACKUP_DIR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
|
||||
let base = dirs::data_local_dir().or_else(dirs::data_dir).ok_or(
|
||||
crate::ErrorKind::FSError(
|
||||
"Could not find valid data dir for app database backups"
|
||||
.to_string(),
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(base.join("Modrinth").join("Backups").join("app-db"))
|
||||
}
|
||||
|
||||
async fn has_user_tables(conn: &mut SqliteConnection) -> crate::Result<bool> {
|
||||
let count = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT IN ('_sqlx_migrations', 'app_metadata')
|
||||
",
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn read_stored_app_version(
|
||||
conn: &mut SqliteConnection,
|
||||
) -> crate::Result<Option<String>> {
|
||||
if !has_table(conn, "app_metadata").await? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT value FROM app_metadata WHERE key = 'app_version'"
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn has_table(
|
||||
conn: &mut SqliteConnection,
|
||||
table_name: &str,
|
||||
) -> crate::Result<bool> {
|
||||
let count = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
",
|
||||
table_name,
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn next_backup_path(
|
||||
backup_dir: &Path,
|
||||
stored_version: &str,
|
||||
current_version: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
crate::util::io::create_dir_all(backup_dir).await?;
|
||||
|
||||
let stored_version = sanitize_version_for_filename(stored_version);
|
||||
let current_version = sanitize_version_for_filename(current_version);
|
||||
|
||||
let backup_path = backup_dir.join(format!(
|
||||
"app-db-before-{current_version}-from-{stored_version}.db"
|
||||
));
|
||||
if !backup_path.try_exists()? {
|
||||
return Ok(backup_path);
|
||||
}
|
||||
|
||||
for suffix in 2.. {
|
||||
let backup_path = backup_dir.join(format!(
|
||||
"app-db-before-{current_version}-from-{stored_version}-{suffix}.db"
|
||||
));
|
||||
if !backup_path.try_exists()? {
|
||||
return Ok(backup_path);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn sanitize_version_for_filename(version: &str) -> String {
|
||||
let mut sanitized = String::new();
|
||||
let mut replaced_last_char = false;
|
||||
|
||||
for character in version.chars() {
|
||||
if character.is_ascii_alphanumeric()
|
||||
|| character == '.'
|
||||
|| character == '-'
|
||||
|| character == '_'
|
||||
{
|
||||
sanitized.push(character);
|
||||
replaced_last_char = false;
|
||||
} else if !replaced_last_char {
|
||||
sanitized.push('-');
|
||||
replaced_last_char = true;
|
||||
}
|
||||
}
|
||||
|
||||
let sanitized = sanitized.trim_matches(&['.', '-', '_'][..]);
|
||||
if sanitized.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
sanitized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_sqlite_snapshot(
|
||||
conn: &mut SqliteConnection,
|
||||
backup_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
let backup_path = backup_path
|
||||
.to_str()
|
||||
.ok_or_else(|| crate::ErrorKind::UTFError(backup_path.to_path_buf()))?;
|
||||
|
||||
sqlx::query!("VACUUM INTO ?", backup_path)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::LoadingBarType;
|
||||
use crate::event::emit::{emit_loading, init_loading};
|
||||
use crate::state::LAUNCHER_STATE;
|
||||
use crate::state::{JavaVersion, Profile, Settings};
|
||||
use crate::state::{JavaVersion, Settings};
|
||||
use crate::util::fetch::IoSemaphore;
|
||||
use dashmap::DashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,7 +11,7 @@ use tokio::fs;
|
||||
|
||||
pub const CACHES_FOLDER_NAME: &str = "caches";
|
||||
pub const LAUNCHER_LOGS_FOLDER_NAME: &str = "launcher_logs";
|
||||
pub const PROFILES_FOLDER_NAME: &str = "profiles";
|
||||
pub const INSTANCES_FOLDER_NAME: &str = "profiles";
|
||||
pub const METADATA_FOLDER_NAME: &str = "meta";
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -148,22 +148,24 @@ impl DirectoryInfo {
|
||||
self.config_dir.join("icons")
|
||||
}
|
||||
|
||||
/// Get the profiles directory for created profiles
|
||||
/// Get the instances directory
|
||||
#[inline]
|
||||
pub fn profiles_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(PROFILES_FOLDER_NAME)
|
||||
pub fn instances_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(INSTANCES_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Gets the logs dir for a given profile
|
||||
/// Gets the logs dir for a given instance path
|
||||
#[inline]
|
||||
pub fn profile_logs_dir(&self, profile_path: &str) -> PathBuf {
|
||||
self.profiles_dir().join(profile_path).join("logs")
|
||||
pub fn instance_logs_dir(&self, instance_path: &str) -> PathBuf {
|
||||
self.instances_dir().join(instance_path).join("logs")
|
||||
}
|
||||
|
||||
/// Gets the crash reports dir for a given profile
|
||||
/// Gets the crash reports dir for a given instance path
|
||||
#[inline]
|
||||
pub fn crash_reports_dir(&self, profile_path: &str) -> PathBuf {
|
||||
self.profiles_dir().join(profile_path).join("crash-reports")
|
||||
pub fn crash_reports_dir(&self, instance_path: &str) -> PathBuf {
|
||||
self.instances_dir()
|
||||
.join(instance_path)
|
||||
.join("crash-reports")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -265,7 +267,7 @@ impl DirectoryInfo {
|
||||
|
||||
const MOVE_DIRS: &[&str] = &[
|
||||
CACHES_FOLDER_NAME,
|
||||
PROFILES_FOLDER_NAME,
|
||||
INSTANCES_FOLDER_NAME,
|
||||
METADATA_FOLDER_NAME,
|
||||
];
|
||||
|
||||
@@ -472,27 +474,37 @@ impl DirectoryInfo {
|
||||
java_version.upsert(exec).await?
|
||||
}
|
||||
|
||||
let profiles = Profile::get_all(exec).await?;
|
||||
|
||||
for mut profile in profiles {
|
||||
profile.icon_path = profile.icon_path.map(|x| {
|
||||
x.replace(
|
||||
prev_custom_dir,
|
||||
new_dir
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches('\\'),
|
||||
)
|
||||
});
|
||||
profile.java_path = profile.java_path.map(|x| {
|
||||
x.replace(
|
||||
prev_custom_dir,
|
||||
new_dir
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches('\\'),
|
||||
)
|
||||
});
|
||||
profile.upsert(exec).await?;
|
||||
}
|
||||
let new_dir = new_dir
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches('\\')
|
||||
.to_string();
|
||||
let new_dir = new_dir.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET icon_path = replace(icon_path, ?, ?)
|
||||
WHERE icon_path IS NOT NULL
|
||||
",
|
||||
prev_custom_dir,
|
||||
new_dir,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_launch_overrides
|
||||
SET overrides = jsonb(json_set(
|
||||
overrides,
|
||||
'$.java_path',
|
||||
replace(json_extract(overrides, '$.java_path'), ?, ?)
|
||||
))
|
||||
WHERE json_type(overrides, '$.java_path') = 'text'
|
||||
",
|
||||
prev_custom_dir,
|
||||
new_dir,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
}
|
||||
|
||||
settings.custom_dir = Some(new_dir);
|
||||
|
||||
@@ -7,7 +7,6 @@ use discord_rich_presence::{
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::State;
|
||||
use crate::state::Profile;
|
||||
|
||||
pub struct DiscordGuard {
|
||||
client: Arc<RwLock<DiscordIpcClient>>,
|
||||
@@ -134,17 +133,13 @@ impl DiscordGuard {
|
||||
return self.clear_activity(true).await;
|
||||
}
|
||||
|
||||
let running_profiles = state.process_manager.get_all();
|
||||
if let Some(existing_child) = running_profiles.first() {
|
||||
let prof =
|
||||
Profile::get(&existing_child.profile_path, &state.pool).await?;
|
||||
if let Some(prof) = prof {
|
||||
self.set_activity(
|
||||
&format!("Playing {}", prof.name),
|
||||
reconnect_if_fail,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let running_instances = state.process_manager.get_all();
|
||||
if let Some(existing_child) = running_instances.first() {
|
||||
self.set_activity(
|
||||
&format!("Playing {}", existing_child.instance_name),
|
||||
reconnect_if_fail,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.set_activity("Idling...", reconnect_if_fail).await?;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::data::ModrinthCredentials;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::event::emit::{emit_friend, emit_notification};
|
||||
use crate::state::tunnel::InternalTunnelSocket;
|
||||
use crate::state::{ProcessManager, Profile, TunnelSocket};
|
||||
use crate::state::{ProcessManager, TunnelSocket};
|
||||
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
|
||||
use ariadne::ids::UserId;
|
||||
use ariadne::networking::message::{
|
||||
@@ -101,13 +101,9 @@ impl FriendsSocket {
|
||||
}
|
||||
|
||||
if let Some(process) = process_manager.get_all().first() {
|
||||
let profile =
|
||||
Profile::get(&process.profile_path, exec).await?;
|
||||
|
||||
if let Some(profile) = profile {
|
||||
let _ =
|
||||
self.update_status(Some(profile.name)).await;
|
||||
}
|
||||
let _ = self
|
||||
.update_status(Some(process.instance_name.clone()))
|
||||
.await;
|
||||
}
|
||||
|
||||
let write_handle = self.write.clone();
|
||||
@@ -312,11 +308,13 @@ impl FriendsSocket {
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
profile_name: Option<String>,
|
||||
instance_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
Self::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::StatusUpdate { profile_name },
|
||||
ClientToServerMessage::StatusUpdate {
|
||||
profile_name: instance_name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
use crate::State;
|
||||
use crate::event::ProfilePayloadType;
|
||||
use crate::event::emit::{emit_profile, emit_warning};
|
||||
use crate::state::{
|
||||
DirectoryInfo, ProfileInstallStage, ProjectType, attached_world_data,
|
||||
};
|
||||
use crate::worlds::WorldType;
|
||||
use notify::{RecommendedWatcher, RecursiveMode};
|
||||
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{RwLock, mpsc::channel};
|
||||
|
||||
pub type FileWatcher = RwLock<Debouncer<RecommendedWatcher>>;
|
||||
|
||||
pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
let (tx, mut rx) = channel(1);
|
||||
|
||||
let file_watcher = new_debouncer(
|
||||
Duration::from_secs_f32(1.0),
|
||||
move |res: DebounceEventResult| {
|
||||
tx.blocking_send(res).ok();
|
||||
},
|
||||
)?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
|
||||
tracing::info!(parent: &span, "Initing watcher");
|
||||
while let Some(res) = rx.recv().await {
|
||||
let _span = span.enter();
|
||||
|
||||
match res {
|
||||
Ok(events) => {
|
||||
let mut visited_profiles = Vec::new();
|
||||
|
||||
events.iter().for_each(|e| {
|
||||
let mut profile_path = None;
|
||||
|
||||
let mut found = false;
|
||||
for component in e.path.components() {
|
||||
if found {
|
||||
profile_path = Some(component.as_os_str());
|
||||
break;
|
||||
}
|
||||
|
||||
if component.as_os_str()
|
||||
== crate::state::dirs::PROFILES_FOLDER_NAME
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(profile_path) = profile_path {
|
||||
let profile_path_str =
|
||||
profile_path.to_string_lossy().to_string();
|
||||
let first_file_name = e
|
||||
.path
|
||||
.components()
|
||||
.skip_while(|x| x.as_os_str() != profile_path)
|
||||
.nth(1)
|
||||
.map(|x| x.as_os_str());
|
||||
if first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "crash-reports")
|
||||
&& e.path
|
||||
.extension()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "txt")
|
||||
{
|
||||
crash_task(profile_path_str);
|
||||
} else if !visited_profiles.contains(&profile_path)
|
||||
{
|
||||
let event = if first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "servers.dat")
|
||||
{
|
||||
Some(ProfilePayloadType::ServersUpdated)
|
||||
} else if first_file_name.as_ref().is_some_and(|x| {
|
||||
*x == "saves"
|
||||
&& e.path
|
||||
.file_name()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "level.dat")
|
||||
}) {
|
||||
tracing::info!(
|
||||
"World updated: {}",
|
||||
e.path.display()
|
||||
);
|
||||
let world = e
|
||||
.path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !e.path.is_file() {
|
||||
let profile_path_str = profile_path_str.clone();
|
||||
let world = world.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(state) = State::get().await
|
||||
&& let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
|
||||
&profile_path_str,
|
||||
WorldType::Singleplayer,
|
||||
&world,
|
||||
&state.pool
|
||||
).await {
|
||||
tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}")
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(ProfilePayloadType::WorldUpdated { world })
|
||||
} else if first_file_name
|
||||
.as_ref()
|
||||
.is_none_or(|x| *x != "saves")
|
||||
{
|
||||
Some(ProfilePayloadType::Synced)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = event {
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_profile(
|
||||
&profile_path_str,
|
||||
event,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
visited_profiles.push(profile_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => tracing::warn!("Unable to watch file: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(RwLock::new(file_watcher))
|
||||
}
|
||||
|
||||
/// Watches all existing profiles
|
||||
pub(crate) async fn watch_profiles_init(
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) {
|
||||
let Ok(mut profiles_dir) = tokio::fs::read_dir(dirs.profiles_dir()).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
while let Ok(Some(profile_dir)) = profiles_dir.next_entry().await {
|
||||
let file_name = profile_dir.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
if file_name.starts_with(".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
|
||||
watch_profile(&file_name, watcher, dirs).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_profile(
|
||||
profile_path: &str,
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) {
|
||||
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||
|
||||
let Ok(metadata) = tokio::fs::metadata(&profile_path).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut to_watch = Vec::new();
|
||||
for sub_path in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = profile_path.join(sub_path);
|
||||
|
||||
let meta = tokio::fs::symlink_metadata(&full_path).await;
|
||||
let exists = meta.is_ok();
|
||||
let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink());
|
||||
|
||||
if !exists
|
||||
&& !is_symlink
|
||||
&& !sub_path.contains(".")
|
||||
&& let Err(e) = crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
to_watch.push(full_path);
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
for full_path in &to_watch {
|
||||
if let Err(e) =
|
||||
watcher.watcher().watch(full_path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&profile_path, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn crash_task(path: String) {
|
||||
tokio::task::spawn(async move {
|
||||
let res = async {
|
||||
let profile = crate::api::profile::get(&path).await?;
|
||||
|
||||
if let Some(profile) = profile {
|
||||
// Hide warning if profile is not yet installed
|
||||
if profile.install_stage == ProfileInstallStage::Installed {
|
||||
emit_warning(&format!("Profile {} has crashed! Visit the logs page to see a crash report.", profile.name)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("Unable to send crash report to frontend: {err}")
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceInstallStage {
|
||||
Installed,
|
||||
MinecraftInstalling,
|
||||
PackInstalled,
|
||||
PackInstalling,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
impl InstanceInstallStage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Installed => "installed",
|
||||
Self::MinecraftInstalling => "minecraft_installing",
|
||||
Self::PackInstalled => "pack_installed",
|
||||
Self::PackInstalling => "pack_installing",
|
||||
Self::NotInstalled => "not_installed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(val: &str) -> Self {
|
||||
match val {
|
||||
"installed" => Self::Installed,
|
||||
"minecraft_installing" => Self::MinecraftInstalling,
|
||||
"installing" => Self::MinecraftInstalling,
|
||||
"pack_installed" => Self::PackInstalled,
|
||||
"pack_installing" => Self::PackInstalling,
|
||||
"not_installed" => Self::NotInstalled,
|
||||
_ => Self::NotInstalled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LauncherFeatureVersion {
|
||||
None,
|
||||
MigratedServerLastPlayTime,
|
||||
MigratedLaunchHooks,
|
||||
}
|
||||
|
||||
impl LauncherFeatureVersion {
|
||||
pub const MOST_RECENT: Self = Self::MigratedLaunchHooks;
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::None => "none",
|
||||
Self::MigratedServerLastPlayTime => {
|
||||
"migrated_server_last_play_time"
|
||||
}
|
||||
Self::MigratedLaunchHooks => "migrated_launch_hooks",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(val: &str) -> Self {
|
||||
match val {
|
||||
"none" => Self::None,
|
||||
"migrated_server_last_play_time" => {
|
||||
Self::MigratedServerLastPlayTime
|
||||
}
|
||||
"migrated_launch_hooks" => Self::MigratedLaunchHooks,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ModLoader {
|
||||
Vanilla,
|
||||
Forge,
|
||||
Fabric,
|
||||
Quilt,
|
||||
NeoForge,
|
||||
}
|
||||
|
||||
impl ModLoader {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Forge => "forge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neoforge",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_meta_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Forge => "forge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neo",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(val: &str) -> Self {
|
||||
match val {
|
||||
"vanilla" => Self::Vanilla,
|
||||
"forge" => Self::Forge,
|
||||
"fabric" => Self::Fabric,
|
||||
"quilt" => Self::Quilt,
|
||||
"neoforge" => Self::NeoForge,
|
||||
_ => Self::Vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentFile {
|
||||
pub hash: String,
|
||||
pub file_name: String,
|
||||
pub size: u64,
|
||||
pub metadata: Option<FileMetadata>,
|
||||
pub update_version_id: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProjectType {
|
||||
Mod,
|
||||
DataPack,
|
||||
ResourcePack,
|
||||
#[serde(alias = "shader")]
|
||||
ShaderPack,
|
||||
}
|
||||
|
||||
impl ProjectType {
|
||||
pub fn get_from_loaders(loaders: Vec<String>) -> Option<Self> {
|
||||
if loaders
|
||||
.iter()
|
||||
.any(|x| ["fabric", "forge", "quilt", "neoforge"].contains(&&**x))
|
||||
{
|
||||
Some(ProjectType::Mod)
|
||||
} else if loaders.iter().any(|x| x == "datapack") {
|
||||
Some(ProjectType::DataPack)
|
||||
} else if loaders.iter().any(|x| ["iris", "optifine"].contains(&&**x)) {
|
||||
Some(ProjectType::ShaderPack)
|
||||
} else if loaders
|
||||
.iter()
|
||||
.any(|x| ["vanilla", "canvas", "minecraft"].contains(&&**x))
|
||||
{
|
||||
Some(ProjectType::ResourcePack)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_from_parent_folder(path: impl AsRef<Path>) -> Option<Self> {
|
||||
match path
|
||||
.as_ref()
|
||||
.parent()?
|
||||
.file_name()?
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"mods" => Some(ProjectType::Mod),
|
||||
"datapacks" => Some(ProjectType::DataPack),
|
||||
"resourcepacks" => Some(ProjectType::ResourcePack),
|
||||
"shaderpacks" => Some(ProjectType::ShaderPack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_name(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectType::Mod => "mod",
|
||||
ProjectType::DataPack => "datapack",
|
||||
ProjectType::ResourcePack => "resourcepack",
|
||||
ProjectType::ShaderPack => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_folder(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectType::Mod => "mods",
|
||||
ProjectType::DataPack => "datapacks",
|
||||
ProjectType::ResourcePack => "resourcepacks",
|
||||
ProjectType::ShaderPack => "shaderpacks",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_loaders(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
ProjectType::Mod => &["fabric", "forge", "quilt", "neoforge"],
|
||||
ProjectType::DataPack => &["datapack"],
|
||||
ProjectType::ResourcePack => &["vanilla", "canvas", "minecraft"],
|
||||
ProjectType::ShaderPack => &["iris", "optifine"],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iterator() -> impl Iterator<Item = ProjectType> {
|
||||
[
|
||||
ProjectType::Mod,
|
||||
ProjectType::DataPack,
|
||||
ProjectType::ResourcePack,
|
||||
ProjectType::ShaderPack,
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectType> for modrinth_content_management::ContentType {
|
||||
fn from(project_type: ProjectType) -> Self {
|
||||
match project_type {
|
||||
ProjectType::Mod => Self::Mod,
|
||||
ProjectType::DataPack => Self::DataPack,
|
||||
ProjectType::ResourcePack => Self::ResourcePack,
|
||||
ProjectType::ShaderPack => Self::Shader,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::state::ProjectType;
|
||||
use crate::util::io::{self, IOError};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ScannedContentFile {
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub size: u64,
|
||||
pub hash_cache_key: String,
|
||||
}
|
||||
|
||||
pub(crate) fn scan_content_files(
|
||||
instances_dir: &Path,
|
||||
instance_path: &str,
|
||||
) -> crate::Result<Vec<ScannedContentFile>> {
|
||||
let instance_dir = io::canonicalize(instances_dir.join(instance_path))?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for project_type in ProjectType::iterator() {
|
||||
let folder = project_type.get_folder();
|
||||
let folder_path = instance_dir.join(folder);
|
||||
|
||||
if !folder_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&folder_path)
|
||||
.map_err(|err| IOError::with_path(err, &folder_path))?
|
||||
{
|
||||
let path = entry.map_err(IOError::from)?.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(file_name) =
|
||||
path.file_name().and_then(|value| value.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !is_scannable_project_file(project_type, file_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let size = path.metadata().map_err(IOError::from)?.len();
|
||||
let relative_path = format!("{folder}/{file_name}");
|
||||
|
||||
files.push(ScannedContentFile {
|
||||
relative_path,
|
||||
file_name: file_name.to_string(),
|
||||
enabled: !file_name.ends_with(".disabled"),
|
||||
size,
|
||||
hash_cache_key: format!(
|
||||
"{size}-{instance_path}/{folder}/{file_name}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub(crate) fn project_type_from_relative_path(
|
||||
relative_path: &str,
|
||||
) -> Option<ProjectType> {
|
||||
ProjectType::get_from_parent_folder(PathBuf::from(relative_path))
|
||||
}
|
||||
|
||||
fn is_scannable_project_file(
|
||||
project_type: ProjectType,
|
||||
file_name: &str,
|
||||
) -> bool {
|
||||
let Some(extension) = Path::new(file_name.trim_end_matches(".disabled"))
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match project_type {
|
||||
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
|
||||
ProjectType::DataPack
|
||||
| ProjectType::ResourcePack
|
||||
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod filesystem;
|
||||
pub(crate) mod sqlite;
|
||||
@@ -0,0 +1,965 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::state::instances::{
|
||||
ContentEntry, ContentRequirement, ContentSet, ContentSetRemoteRef,
|
||||
ContentSetRemoteRefType, ContentSetStatus, ContentSetSyncProvider,
|
||||
ContentSetSyncState, ContentSetSyncStatus, ContentSourceKind,
|
||||
ContentUpdateCheck, InstanceFile,
|
||||
};
|
||||
use crate::state::{ModLoader, ProjectType, ReleaseChannel};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct ContentSetRow {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub source_kind: String,
|
||||
pub status: String,
|
||||
pub game_version: String,
|
||||
pub protocol_version: Option<i64>,
|
||||
pub loader: String,
|
||||
pub loader_version: Option<String>,
|
||||
pub created: i64,
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
impl TryFrom<ContentSetRow> for ContentSet {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: ContentSetRow) -> crate::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
instance_id: row.instance_id,
|
||||
name: row.name,
|
||||
source_kind: ContentSourceKind::from_str(&row.source_kind)?,
|
||||
status: ContentSetStatus::from_str(&row.status)?,
|
||||
game_version: row.game_version,
|
||||
protocol_version: row.protocol_version.map(|value| value as u32),
|
||||
loader: ModLoader::from_string(&row.loader),
|
||||
loader_version: row.loader_version,
|
||||
created: timestamp(row.created),
|
||||
modified: timestamp(row.modified),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct ContentSetRemoteRefRow {
|
||||
pub content_set_id: String,
|
||||
pub ref_type: String,
|
||||
pub ref_id: String,
|
||||
}
|
||||
|
||||
impl TryFrom<ContentSetRemoteRefRow> for ContentSetRemoteRef {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: ContentSetRemoteRefRow) -> crate::Result<Self> {
|
||||
Ok(Self {
|
||||
content_set_id: row.content_set_id,
|
||||
ref_type: ContentSetRemoteRefType::from_str(&row.ref_type)?,
|
||||
ref_id: row.ref_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct ContentSetSyncStateRow {
|
||||
pub content_set_id: String,
|
||||
pub provider: String,
|
||||
pub applied_update_id: Option<String>,
|
||||
pub latest_available_update_id: Option<String>,
|
||||
pub checked_at: Option<i64>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl TryFrom<ContentSetSyncStateRow> for ContentSetSyncState {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: ContentSetSyncStateRow) -> crate::Result<Self> {
|
||||
Ok(Self {
|
||||
content_set_id: row.content_set_id,
|
||||
provider: ContentSetSyncProvider::from_str(&row.provider)?,
|
||||
applied_update_id: row.applied_update_id,
|
||||
latest_available_update_id: row.latest_available_update_id,
|
||||
checked_at: row.checked_at.and_then(optional_timestamp),
|
||||
status: ContentSetSyncStatus::from_str(&row.status)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct InstanceFileRow {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub enabled: i64,
|
||||
pub sha1: String,
|
||||
pub size: i64,
|
||||
pub missing: i64,
|
||||
pub added_at: i64,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
impl TryFrom<InstanceFileRow> for InstanceFile {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: InstanceFileRow) -> crate::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
instance_id: row.instance_id,
|
||||
relative_path: row.relative_path,
|
||||
file_name: row.file_name,
|
||||
enabled: row.enabled == 1,
|
||||
sha1: row.sha1,
|
||||
size: unsigned(row.size, "size")?,
|
||||
missing: row.missing == 1,
|
||||
added_at: timestamp(row.added_at),
|
||||
modified_at: timestamp(row.modified_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct ContentEntryRow {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub content_set_id: String,
|
||||
pub file_id: Option<String>,
|
||||
pub project_type: String,
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub source_kind: String,
|
||||
pub server_requirement: String,
|
||||
pub client_requirement: String,
|
||||
pub enabled: i64,
|
||||
pub added_at: i64,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
impl TryFrom<ContentEntryRow> for ContentEntry {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: ContentEntryRow) -> crate::Result<Self> {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
instance_id: row.instance_id,
|
||||
content_set_id: row.content_set_id,
|
||||
file_id: row.file_id,
|
||||
project_type: project_type_from_str(&row.project_type)?,
|
||||
project_id: row.project_id,
|
||||
version_id: row.version_id,
|
||||
source_kind: ContentSourceKind::from_str(&row.source_kind)?,
|
||||
server_requirement: ContentRequirement::from_str(
|
||||
&row.server_requirement,
|
||||
)?,
|
||||
client_requirement: ContentRequirement::from_str(
|
||||
&row.client_requirement,
|
||||
)?,
|
||||
enabled: row.enabled == 1,
|
||||
added_at: timestamp(row.added_at),
|
||||
modified_at: timestamp(row.modified_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct ContentUpdateCheckRow {
|
||||
pub content_entry_id: String,
|
||||
pub update_channel: String,
|
||||
pub update_version_id: Option<String>,
|
||||
pub checked_at: i64,
|
||||
}
|
||||
|
||||
impl From<ContentUpdateCheckRow> for ContentUpdateCheck {
|
||||
fn from(row: ContentUpdateCheckRow) -> Self {
|
||||
Self {
|
||||
content_entry_id: row.content_entry_id,
|
||||
update_channel: ReleaseChannel::from_key(&row.update_channel),
|
||||
update_version_id: row.update_version_id,
|
||||
checked_at: timestamp(row.checked_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_applied_content_set<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Option<ContentSet>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
ContentSetRow,
|
||||
"
|
||||
SELECT cs.*
|
||||
FROM instances i
|
||||
INNER JOIN instance_content_sets cs
|
||||
ON cs.id = i.applied_content_set_id
|
||||
WHERE i.id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_set<'e, E>(
|
||||
content_set_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Option<ContentSet>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
ContentSetRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_sets
|
||||
WHERE id = ?
|
||||
",
|
||||
content_set_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_sets_for_instance<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Vec<ContentSet>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
ContentSetRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_sets
|
||||
WHERE instance_id = ?
|
||||
ORDER BY created ASC, id ASC
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_content_set(
|
||||
content_set: &ContentSet,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let id = content_set.id.as_str();
|
||||
let instance_id = content_set.instance_id.as_str();
|
||||
let name = content_set.name.as_str();
|
||||
let source_kind = content_set.source_kind.as_str();
|
||||
let status = content_set.status.as_str();
|
||||
let game_version = content_set.game_version.as_str();
|
||||
let protocol_version =
|
||||
content_set.protocol_version.map(|value| value as i64);
|
||||
let loader = content_set.loader.as_str();
|
||||
let loader_version = content_set.loader_version.as_deref();
|
||||
let created = content_set.created.timestamp();
|
||||
let modified = content_set.modified.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_content_sets (
|
||||
id,
|
||||
instance_id,
|
||||
name,
|
||||
source_kind,
|
||||
status,
|
||||
game_version,
|
||||
protocol_version,
|
||||
loader,
|
||||
loader_version,
|
||||
created,
|
||||
modified
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
",
|
||||
id,
|
||||
instance_id,
|
||||
name,
|
||||
source_kind,
|
||||
status,
|
||||
game_version,
|
||||
protocol_version,
|
||||
loader,
|
||||
loader_version,
|
||||
created,
|
||||
modified,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_content_set(
|
||||
content_set: &ContentSet,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let id = content_set.id.as_str();
|
||||
let name = content_set.name.as_str();
|
||||
let source_kind = content_set.source_kind.as_str();
|
||||
let status = content_set.status.as_str();
|
||||
let game_version = content_set.game_version.as_str();
|
||||
let protocol_version =
|
||||
content_set.protocol_version.map(|value| value as i64);
|
||||
let loader = content_set.loader.as_str();
|
||||
let loader_version = content_set.loader_version.as_deref();
|
||||
let modified = content_set.modified.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_sets
|
||||
SET
|
||||
name = ?,
|
||||
source_kind = ?,
|
||||
status = ?,
|
||||
game_version = ?,
|
||||
protocol_version = ?,
|
||||
loader = ?,
|
||||
loader_version = ?,
|
||||
modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
name,
|
||||
source_kind,
|
||||
status,
|
||||
game_version,
|
||||
protocol_version,
|
||||
loader,
|
||||
loader_version,
|
||||
modified,
|
||||
id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_set_remote_refs<'e, E>(
|
||||
content_set_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Vec<ContentSetRemoteRef>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
ContentSetRemoteRefRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_set_remote_refs
|
||||
WHERE content_set_id = ?
|
||||
ORDER BY ref_type ASC
|
||||
",
|
||||
content_set_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_set_sync_state<'e, E>(
|
||||
content_set_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Option<ContentSetSyncState>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
ContentSetSyncStateRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_set_sync_state
|
||||
WHERE content_set_id = ?
|
||||
",
|
||||
content_set_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_files<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Vec<InstanceFile>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
InstanceFileRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_files
|
||||
WHERE instance_id = ?
|
||||
ORDER BY relative_path ASC
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_instance_files_missing(
|
||||
instance_id: &str,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let modified_at = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_files
|
||||
SET
|
||||
missing = 1,
|
||||
modified_at = ?
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
modified_at,
|
||||
instance_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_instance_file(
|
||||
file: &InstanceFile,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let id = file.id.as_str();
|
||||
let instance_id = file.instance_id.as_str();
|
||||
let relative_path = file.relative_path.as_str();
|
||||
let file_name = file.file_name.as_str();
|
||||
let enabled = i64::from(file.enabled);
|
||||
let sha1 = file.sha1.as_str();
|
||||
let size = file.size as i64;
|
||||
let missing = i64::from(file.missing);
|
||||
let added_at = file.added_at.timestamp();
|
||||
let modified_at = file.modified_at.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_files (
|
||||
id,
|
||||
instance_id,
|
||||
relative_path,
|
||||
file_name,
|
||||
enabled,
|
||||
sha1,
|
||||
size,
|
||||
missing,
|
||||
added_at,
|
||||
modified_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, relative_path) DO UPDATE SET
|
||||
file_name = excluded.file_name,
|
||||
enabled = excluded.enabled,
|
||||
sha1 = excluded.sha1,
|
||||
size = excluded.size,
|
||||
missing = excluded.missing,
|
||||
modified_at = excluded.modified_at
|
||||
",
|
||||
id,
|
||||
instance_id,
|
||||
relative_path,
|
||||
file_name,
|
||||
enabled,
|
||||
sha1,
|
||||
size,
|
||||
missing,
|
||||
added_at,
|
||||
modified_at,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_entries<'e, E>(
|
||||
content_set_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Vec<ContentEntry>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let rows = sqlx::query_as!(
|
||||
ContentEntryRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_entries
|
||||
WHERE content_set_id = ?
|
||||
ORDER BY added_at ASC, id ASC
|
||||
",
|
||||
content_set_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_update_check<'e, E>(
|
||||
content_entry_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Option<ContentUpdateCheck>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let row = sqlx::query_as!(
|
||||
ContentUpdateCheckRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_update_checks
|
||||
WHERE content_entry_id = ?
|
||||
",
|
||||
content_entry_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
pub(crate) struct UpsertInstanceFile<'a> {
|
||||
pub instance_id: &'a str,
|
||||
pub relative_path: &'a str,
|
||||
pub file_name: &'a str,
|
||||
pub enabled: bool,
|
||||
pub sha1: &'a str,
|
||||
pub size: u64,
|
||||
pub missing: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_file_by_relative_path(
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceFile>> {
|
||||
let row = sqlx::query_as!(
|
||||
InstanceFileRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_files
|
||||
WHERE instance_id = ? AND relative_path = ?
|
||||
",
|
||||
instance_id,
|
||||
relative_path,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_instance_file_from_parts(
|
||||
input: UpsertInstanceFile<'_>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<InstanceFile> {
|
||||
let existing = get_instance_file_by_relative_path(
|
||||
input.instance_id,
|
||||
input.relative_path,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
let file = InstanceFile {
|
||||
id: existing
|
||||
.as_ref()
|
||||
.map(|file| file.id.clone())
|
||||
.unwrap_or_else(|| format!("instance-file:{}", Uuid::new_v4())),
|
||||
instance_id: input.instance_id.to_string(),
|
||||
relative_path: input.relative_path.to_string(),
|
||||
file_name: input.file_name.to_string(),
|
||||
enabled: input.enabled,
|
||||
sha1: input.sha1.to_string(),
|
||||
size: input.size,
|
||||
missing: input.missing,
|
||||
added_at: existing
|
||||
.as_ref()
|
||||
.map(|file| file.added_at)
|
||||
.unwrap_or_else(Utc::now),
|
||||
modified_at: Utc::now(),
|
||||
};
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
upsert_instance_file(&file, &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_instance_file(
|
||||
instance_id: &str,
|
||||
old_relative_path: &str,
|
||||
new_relative_path: &str,
|
||||
new_file_name: &str,
|
||||
enabled: bool,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceFile>> {
|
||||
let enabled = i64::from(enabled);
|
||||
let modified_at = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_files
|
||||
SET
|
||||
relative_path = ?,
|
||||
file_name = ?,
|
||||
enabled = ?,
|
||||
modified_at = ?
|
||||
WHERE instance_id = ? AND relative_path = ?
|
||||
",
|
||||
new_relative_path,
|
||||
new_file_name,
|
||||
enabled,
|
||||
modified_at,
|
||||
instance_id,
|
||||
old_relative_path,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
get_instance_file_by_relative_path(instance_id, new_relative_path, pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_instance_file_by_relative_path(
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_files
|
||||
WHERE instance_id = ? AND relative_path = ?
|
||||
",
|
||||
instance_id,
|
||||
relative_path,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct UpsertContentEntry<'a> {
|
||||
pub instance_id: &'a str,
|
||||
pub content_set_id: &'a str,
|
||||
pub file_id: Option<&'a str>,
|
||||
pub project_type: ProjectType,
|
||||
pub project_id: Option<&'a str>,
|
||||
pub version_id: Option<&'a str>,
|
||||
pub source_kind: ContentSourceKind,
|
||||
pub server_requirement: ContentRequirement,
|
||||
pub client_requirement: ContentRequirement,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_entry_by_id(
|
||||
id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<ContentEntry>> {
|
||||
let row = sqlx::query_as!(
|
||||
ContentEntryRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_entries
|
||||
WHERE id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_content_entry_by_file(
|
||||
content_set_id: &str,
|
||||
file_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<ContentEntry>> {
|
||||
let row = sqlx::query_as!(
|
||||
ContentEntryRow,
|
||||
"
|
||||
SELECT *
|
||||
FROM instance_content_entries
|
||||
WHERE content_set_id = ? AND file_id = ?
|
||||
ORDER BY modified_at DESC
|
||||
LIMIT 1
|
||||
",
|
||||
content_set_id,
|
||||
file_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_content_entry_from_parts(
|
||||
input: UpsertContentEntry<'_>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<ContentEntry> {
|
||||
let existing_id = if let Some(file_id) = input.file_id {
|
||||
sqlx::query_scalar!(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_content_entries
|
||||
WHERE content_set_id = ? AND file_id = ?
|
||||
ORDER BY modified_at DESC
|
||||
LIMIT 1
|
||||
",
|
||||
input.content_set_id,
|
||||
file_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else if let (Some(project_id), Some(version_id)) =
|
||||
(input.project_id, input.version_id)
|
||||
{
|
||||
sqlx::query_scalar!(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_content_entries
|
||||
WHERE content_set_id = ?
|
||||
AND project_id = ?
|
||||
AND version_id = ?
|
||||
ORDER BY modified_at DESC
|
||||
LIMIT 1
|
||||
",
|
||||
input.content_set_id,
|
||||
project_id,
|
||||
version_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = Utc::now();
|
||||
let entry = ContentEntry {
|
||||
id: existing_id
|
||||
.unwrap_or_else(|| format!("content-entry:{}", Uuid::new_v4())),
|
||||
instance_id: input.instance_id.to_string(),
|
||||
content_set_id: input.content_set_id.to_string(),
|
||||
file_id: input.file_id.map(ToString::to_string),
|
||||
project_type: input.project_type,
|
||||
project_id: input.project_id.map(ToString::to_string),
|
||||
version_id: input.version_id.map(ToString::to_string),
|
||||
source_kind: input.source_kind,
|
||||
server_requirement: input.server_requirement,
|
||||
client_requirement: input.client_requirement,
|
||||
enabled: input.enabled,
|
||||
added_at: now,
|
||||
modified_at: now,
|
||||
};
|
||||
|
||||
let added_at = get_content_entry_by_id(&entry.id, pool)
|
||||
.await?
|
||||
.map(|entry| entry.added_at)
|
||||
.unwrap_or(entry.added_at);
|
||||
let id = entry.id.as_str();
|
||||
let entry_instance_id = entry.instance_id.as_str();
|
||||
let content_set_id = entry.content_set_id.as_str();
|
||||
let file_id = entry.file_id.as_deref();
|
||||
let project_type = entry.project_type.get_name();
|
||||
let project_id = entry.project_id.as_deref();
|
||||
let version_id = entry.version_id.as_deref();
|
||||
let source_kind = entry.source_kind.as_str();
|
||||
let server_requirement = entry.server_requirement.as_str();
|
||||
let client_requirement = entry.client_requirement.as_str();
|
||||
let enabled = i64::from(entry.enabled);
|
||||
let added_at = added_at.timestamp();
|
||||
let modified_at = entry.modified_at.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_content_entries (
|
||||
id,
|
||||
instance_id,
|
||||
content_set_id,
|
||||
file_id,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
source_kind,
|
||||
server_requirement,
|
||||
client_requirement,
|
||||
enabled,
|
||||
added_at,
|
||||
modified_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
file_id = excluded.file_id,
|
||||
project_type = excluded.project_type,
|
||||
project_id = excluded.project_id,
|
||||
version_id = excluded.version_id,
|
||||
source_kind = excluded.source_kind,
|
||||
server_requirement = excluded.server_requirement,
|
||||
client_requirement = excluded.client_requirement,
|
||||
enabled = excluded.enabled,
|
||||
modified_at = excluded.modified_at
|
||||
",
|
||||
id,
|
||||
entry_instance_id,
|
||||
content_set_id,
|
||||
file_id,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
source_kind,
|
||||
server_requirement,
|
||||
client_requirement,
|
||||
enabled,
|
||||
added_at,
|
||||
modified_at,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
get_content_entry_by_id(&entry.id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Failed to read content entry {} after upsert",
|
||||
entry.id
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn set_content_entry_enabled_for_file(
|
||||
content_set_id: &str,
|
||||
file_id: &str,
|
||||
enabled: bool,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let enabled = i64::from(enabled);
|
||||
let modified_at = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_entries
|
||||
SET enabled = ?, modified_at = ?
|
||||
WHERE content_set_id = ? AND file_id = ?
|
||||
",
|
||||
enabled,
|
||||
modified_at,
|
||||
content_set_id,
|
||||
file_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_content_entries_for_file(
|
||||
content_set_id: &str,
|
||||
file_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_content_entries
|
||||
WHERE content_set_id = ? AND file_id = ?
|
||||
",
|
||||
content_set_id,
|
||||
file_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_content_update_check(
|
||||
content_entry_id: &str,
|
||||
update_channel: ReleaseChannel,
|
||||
update_version_id: Option<&str>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let update_channel = update_channel.key();
|
||||
let checked_at = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_content_update_checks (
|
||||
content_entry_id,
|
||||
update_channel,
|
||||
update_version_id,
|
||||
checked_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(content_entry_id) DO UPDATE SET
|
||||
update_channel = excluded.update_channel,
|
||||
update_version_id = excluded.update_version_id,
|
||||
checked_at = excluded.checked_at
|
||||
",
|
||||
content_entry_id,
|
||||
update_channel,
|
||||
update_version_id,
|
||||
checked_at,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_type_from_str(value: &str) -> crate::Result<ProjectType> {
|
||||
match value {
|
||||
"mod" => Ok(ProjectType::Mod),
|
||||
"datapack" => Ok(ProjectType::DataPack),
|
||||
"resourcepack" => Ok(ProjectType::ResourcePack),
|
||||
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
|
||||
other => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown content project type {other}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp(value: i64) -> DateTime<Utc> {
|
||||
Utc.timestamp_opt(value, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
}
|
||||
|
||||
fn optional_timestamp(value: i64) -> Option<DateTime<Utc>> {
|
||||
Utc.timestamp_opt(value, 0).single()
|
||||
}
|
||||
|
||||
fn unsigned(value: i64, column: &str) -> crate::Result<u64> {
|
||||
if value < 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Expected {column} to be non-negative"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(value as u64)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod content_rows;
|
||||
pub(crate) mod instance_rows;
|
||||
@@ -0,0 +1,852 @@
|
||||
use crate::state::instances::{
|
||||
ContentRequirement, ContentSourceKind, Instance, InstanceFile,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, Dependency, DependencyType, KnownModrinthFile,
|
||||
ModLoader, ProjectType, State, Version, cache_file_hash,
|
||||
};
|
||||
use crate::util::fetch::{self, DownloadMeta, DownloadReason};
|
||||
use crate::util::io;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use modrinth_content_management::{
|
||||
ContentMetadataProvider, ContentType, Error as ResolveError,
|
||||
ResolutionPreferences, ResolveContentPlan, ResolveContentRequest,
|
||||
ResolvedContent,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) struct ContentScope {
|
||||
pub instance: Instance,
|
||||
pub content_set_id: String,
|
||||
}
|
||||
|
||||
pub(crate) struct InstalledContentFile {
|
||||
pub relative_path: String,
|
||||
pub project_id: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DownloadedProjectVersion {
|
||||
pub file_name: String,
|
||||
pub bytes: Bytes,
|
||||
pub sha1: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
pub(crate) struct InstanceInstallProjectRequest {
|
||||
pub project_id: String,
|
||||
pub version_id: Option<String>,
|
||||
pub content_type: ContentType,
|
||||
pub selected: ResolutionPreferences,
|
||||
}
|
||||
|
||||
struct CachedEntryContentProvider<'a> {
|
||||
state: &'a State,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContentMetadataProvider for CachedEntryContentProvider<'_> {
|
||||
async fn get_version(
|
||||
&mut self,
|
||||
version_id: &str,
|
||||
) -> Result<Option<modrinth_content_management::Version>, ResolveError>
|
||||
{
|
||||
let version = CachedEntry::get_version(
|
||||
version_id,
|
||||
self.cache_behaviour,
|
||||
&self.state.pool,
|
||||
&self.state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
.map_err(resolve_provider_error)?;
|
||||
|
||||
Ok(version.map(version_to_resolver))
|
||||
}
|
||||
|
||||
async fn get_project_versions(
|
||||
&mut self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<modrinth_content_management::Version>, ResolveError> {
|
||||
let versions = CachedEntry::get_project_versions(
|
||||
project_id,
|
||||
self.cache_behaviour,
|
||||
&self.state.pool,
|
||||
&self.state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
.map_err(resolve_provider_error)?;
|
||||
|
||||
Ok(versions
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(version_to_resolver)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_provider_error(error: crate::Error) -> ResolveError {
|
||||
ResolveError::Provider(error.to_string())
|
||||
}
|
||||
|
||||
fn resolver_error(error: ResolveError) -> crate::Error {
|
||||
crate::ErrorKind::InputError(error.to_string()).into()
|
||||
}
|
||||
|
||||
fn version_to_resolver(
|
||||
version: Version,
|
||||
) -> modrinth_content_management::Version {
|
||||
modrinth_content_management::Version {
|
||||
id: version.id,
|
||||
project_id: version.project_id,
|
||||
date_published: version.date_published,
|
||||
dependencies: version
|
||||
.dependencies
|
||||
.into_iter()
|
||||
.map(dependency_to_resolver)
|
||||
.collect(),
|
||||
game_versions: version.game_versions,
|
||||
loaders: version.loaders,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependency_to_resolver(
|
||||
dependency: Dependency,
|
||||
) -> modrinth_content_management::Dependency {
|
||||
modrinth_content_management::Dependency {
|
||||
version_id: dependency.version_id,
|
||||
project_id: dependency.project_id,
|
||||
file_name: dependency.file_name,
|
||||
dependency_type: match dependency.dependency_type {
|
||||
DependencyType::Required => {
|
||||
modrinth_content_management::DependencyType::Required
|
||||
}
|
||||
DependencyType::Optional => {
|
||||
modrinth_content_management::DependencyType::Optional
|
||||
}
|
||||
DependencyType::Incompatible => {
|
||||
modrinth_content_management::DependencyType::Incompatible
|
||||
}
|
||||
DependencyType::Embedded => {
|
||||
modrinth_content_management::DependencyType::Embedded
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn target_preferences(
|
||||
game_version: String,
|
||||
loader: ModLoader,
|
||||
content_type: ContentType,
|
||||
) -> ResolutionPreferences {
|
||||
let loader = match content_type {
|
||||
ContentType::DataPack => "datapack".to_string(),
|
||||
ContentType::ResourcePack => "minecraft".to_string(),
|
||||
ContentType::Shader => "iris".to_string(),
|
||||
_ => loader.as_str().to_string(),
|
||||
};
|
||||
|
||||
ResolutionPreferences {
|
||||
game_versions: vec![game_version],
|
||||
loaders: vec![loader],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_install_plan(
|
||||
instance_id: &str,
|
||||
request: InstanceInstallProjectRequest,
|
||||
state: &State,
|
||||
) -> crate::Result<ResolveContentPlan> {
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {instance_id} has no applied content set"
|
||||
))
|
||||
})?;
|
||||
let existing_project_ids =
|
||||
crate::state::get_installed_project_ids_for_instance(
|
||||
instance_id,
|
||||
None,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let provider = CachedEntryContentProvider {
|
||||
state,
|
||||
cache_behaviour: Some(CacheBehaviour::MustRevalidate),
|
||||
};
|
||||
let content_type = request.content_type;
|
||||
let request = ResolveContentRequest {
|
||||
project_id: request.project_id,
|
||||
version_id: request.version_id,
|
||||
content_type,
|
||||
selected: request.selected,
|
||||
target: target_preferences(
|
||||
content_set.game_version,
|
||||
content_set.loader,
|
||||
content_type,
|
||||
),
|
||||
existing_project_ids,
|
||||
};
|
||||
|
||||
modrinth_content_management::resolve_content(provider, request)
|
||||
.await
|
||||
.map_err(resolver_error)
|
||||
}
|
||||
|
||||
pub(crate) async fn install_resolved_content_plan(
|
||||
instance_id: &str,
|
||||
plan: &ResolveContentPlan,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
add_resolved_content(
|
||||
instance_id,
|
||||
&plan.primary,
|
||||
DownloadReason::Standalone,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
for dependency in &plan.dependencies {
|
||||
add_resolved_content(
|
||||
instance_id,
|
||||
dependency,
|
||||
DownloadReason::Dependency,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn switch_project_version_with_dependencies(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
version_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let version = CachedEntry::get_version(
|
||||
version_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unable to install version id {version_id}. Not found."
|
||||
))
|
||||
})?;
|
||||
let content_type = ProjectType::get_from_loaders(version.loaders.clone())
|
||||
.map(ContentType::from)
|
||||
.unwrap_or(ContentType::Mod);
|
||||
let plan = resolve_install_plan(
|
||||
instance_id,
|
||||
InstanceInstallProjectRequest {
|
||||
project_id: version.project_id,
|
||||
version_id: Some(version_id.to_string()),
|
||||
content_type,
|
||||
selected: ResolutionPreferences::default(),
|
||||
},
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let was_disabled = project_path.ends_with(".disabled");
|
||||
let mut new_path = add_project_from_version(
|
||||
instance_id,
|
||||
&plan.primary.version_id,
|
||||
DownloadReason::Update,
|
||||
None,
|
||||
ContentSourceKind::Local,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if was_disabled {
|
||||
new_path =
|
||||
toggle_disable_project(instance_id, &new_path, Some(false), state)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for dependency in &plan.dependencies {
|
||||
add_resolved_content(
|
||||
instance_id,
|
||||
dependency,
|
||||
DownloadReason::Dependency,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if new_path != project_path {
|
||||
remove_project(instance_id, project_path, state).await?;
|
||||
}
|
||||
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
async fn add_resolved_content(
|
||||
instance_id: &str,
|
||||
content: &ResolvedContent,
|
||||
reason: DownloadReason,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
add_project_from_version(
|
||||
instance_id,
|
||||
&content.version_id,
|
||||
reason,
|
||||
content.dependent_on_version_id.clone(),
|
||||
ContentSourceKind::Local,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_content_scope(
|
||||
instance_id: &str,
|
||||
content_set_id: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<ContentScope> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let content_set_id = match content_set_id {
|
||||
Some(id) => id.to_string(),
|
||||
None => instance.applied_content_set_id.clone().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {} has no applied content set",
|
||||
instance.id
|
||||
))
|
||||
})?,
|
||||
};
|
||||
|
||||
Ok(ContentScope {
|
||||
instance,
|
||||
content_set_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn add_project_from_version(
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
reason: DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
source_kind: ContentSourceKind,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let downloaded = download_project_version(
|
||||
instance_id,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
add_downloaded_project_version(instance_id, downloaded, source_kind, state)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_project_version(
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
reason: DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
state: &State,
|
||||
) -> crate::Result<DownloadedProjectVersion> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let content_set =
|
||||
content_rows::get_content_set(&scope.content_set_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unknown content set {}",
|
||||
scope.content_set_id
|
||||
))
|
||||
})?;
|
||||
let version = CachedEntry::get_version(
|
||||
version_id,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unable to install version id {version_id}. Not found."
|
||||
))
|
||||
})?;
|
||||
let file = version
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.primary)
|
||||
.or_else(|| version.files.first())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No files for input version present!".to_string(),
|
||||
)
|
||||
})?;
|
||||
let download_meta = DownloadMeta {
|
||||
reason,
|
||||
game_version: content_set.game_version,
|
||||
loader: content_set.loader.as_str().to_string(),
|
||||
dependent_on: dependent_on_version_id,
|
||||
};
|
||||
let bytes = fetch::fetch(
|
||||
&file.url,
|
||||
file.hashes.get("sha1").map(|hash| hash.as_str()),
|
||||
Some(&download_meta),
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let project_type = ProjectType::get_from_loaders(version.loaders.clone())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unable to infer project type for version {version_id}"
|
||||
))
|
||||
})?;
|
||||
let project_id = version.project_id.clone();
|
||||
let version_id = version.id.clone();
|
||||
|
||||
Ok(DownloadedProjectVersion {
|
||||
file_name: file.filename.clone(),
|
||||
bytes,
|
||||
sha1: file.hashes.get("sha1").cloned(),
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn add_downloaded_project_version(
|
||||
instance_id: &str,
|
||||
downloaded: DownloadedProjectVersion,
|
||||
source_kind: ContentSourceKind,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let DownloadedProjectVersion {
|
||||
file_name,
|
||||
bytes,
|
||||
sha1,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
} = downloaded;
|
||||
|
||||
add_project_bytes(
|
||||
instance_id,
|
||||
&file_name,
|
||||
bytes,
|
||||
sha1.as_deref(),
|
||||
Some(project_type),
|
||||
source_kind,
|
||||
Some(project_id.as_str()),
|
||||
Some(version_id.as_str()),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn add_project_from_path(
|
||||
instance_id: &str,
|
||||
path: &Path,
|
||||
project_type: Option<ProjectType>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let file = io::read(path).await?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
add_project_bytes(
|
||||
instance_id,
|
||||
&file_name,
|
||||
Bytes::from(file),
|
||||
None,
|
||||
project_type,
|
||||
ContentSourceKind::Local,
|
||||
None,
|
||||
None,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn add_project_bytes(
|
||||
instance_id: &str,
|
||||
file_name: &str,
|
||||
bytes: Bytes,
|
||||
hash: Option<&str>,
|
||||
project_type: Option<ProjectType>,
|
||||
source_kind: ContentSourceKind,
|
||||
project_id: Option<&str>,
|
||||
version_id: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let project_type = match project_type {
|
||||
Some(project_type) => project_type,
|
||||
None => infer_project_type(&bytes)?,
|
||||
};
|
||||
let relative_path = format!("{}/{}", project_type.get_folder(), file_name);
|
||||
let full_path =
|
||||
instance_full_path(state, &scope.instance).join(&relative_path);
|
||||
let sha1 = match hash {
|
||||
Some(hash) => hash.to_string(),
|
||||
None => fetch::sha1_async(bytes.clone()).await?,
|
||||
};
|
||||
|
||||
cache_file_hash(
|
||||
bytes.clone(),
|
||||
&scope.instance.id,
|
||||
&relative_path,
|
||||
Some(&sha1),
|
||||
Some(project_type),
|
||||
project_id.zip(version_id).map(|(project_id, version_id)| {
|
||||
KnownModrinthFile {
|
||||
project_id,
|
||||
version_id,
|
||||
}
|
||||
}),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
fetch::write(&full_path, &bytes, &state.io_semaphore).await?;
|
||||
|
||||
let file = content_rows::upsert_instance_file_from_parts(
|
||||
content_rows::UpsertInstanceFile {
|
||||
instance_id: &scope.instance.id,
|
||||
relative_path: &relative_path,
|
||||
file_name,
|
||||
enabled: !relative_path.ends_with(".disabled"),
|
||||
sha1: &sha1,
|
||||
size: bytes.len() as u64,
|
||||
missing: false,
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
upsert_entry_for_file(
|
||||
&scope,
|
||||
&file,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
source_kind,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_project_file(
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
sha1: &str,
|
||||
size: u64,
|
||||
project_type: ProjectType,
|
||||
source_kind: ContentSourceKind,
|
||||
project_id: Option<&str>,
|
||||
version_id: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let file_name = Path::new(relative_path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let file = content_rows::upsert_instance_file_from_parts(
|
||||
content_rows::UpsertInstanceFile {
|
||||
instance_id: &scope.instance.id,
|
||||
relative_path,
|
||||
file_name: &file_name,
|
||||
enabled: !relative_path.ends_with(".disabled"),
|
||||
sha1,
|
||||
size,
|
||||
missing: false,
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
upsert_entry_for_file(
|
||||
&scope,
|
||||
&file,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
source_kind,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn toggle_disable_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
desired_enabled: Option<bool>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let base = instance_full_path(state, &scope.instance);
|
||||
let trimmed = project_path.trim_end_matches(".disabled");
|
||||
let current_path = if base.join(project_path).exists() {
|
||||
project_path.to_string()
|
||||
} else if base.join(format!("{trimmed}.disabled")).exists() {
|
||||
format!("{trimmed}.disabled")
|
||||
} else if base.join(trimmed).exists() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"Could not find project file for '{project_path}' in instance"
|
||||
))
|
||||
.into());
|
||||
};
|
||||
let current_enabled = !current_path.ends_with(".disabled");
|
||||
let enabled = desired_enabled.unwrap_or(!current_enabled);
|
||||
let new_path = if enabled {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}.disabled")
|
||||
};
|
||||
|
||||
if current_path != new_path {
|
||||
io::rename_or_move(&base.join(¤t_path), &base.join(&new_path))
|
||||
.await?;
|
||||
}
|
||||
|
||||
let file_name = Path::new(&new_path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let file = match content_rows::rename_instance_file(
|
||||
&scope.instance.id,
|
||||
¤t_path,
|
||||
&new_path,
|
||||
&file_name,
|
||||
enabled,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(file) => file,
|
||||
None if current_path != project_path => {
|
||||
match content_rows::rename_instance_file(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
&new_path,
|
||||
&file_name,
|
||||
enabled,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(file) => file,
|
||||
None => index_existing_file(&scope, &new_path, state).await?,
|
||||
}
|
||||
}
|
||||
None => index_existing_file(&scope, &new_path, state).await?,
|
||||
};
|
||||
content_rows::set_content_entry_enabled_for_file(
|
||||
&scope.content_set_id,
|
||||
&file.id,
|
||||
enabled,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let base = instance_full_path(state, &scope.instance);
|
||||
let file = content_rows::get_instance_file_by_relative_path(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
io::remove_file(base.join(project_path)).await?;
|
||||
|
||||
if let Some(file) = file {
|
||||
content_rows::remove_content_entries_for_file(
|
||||
&scope.content_set_id,
|
||||
&file.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
content_rows::remove_instance_file_by_relative_path(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_project_files(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstalledContentFile>> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let entries =
|
||||
content_rows::get_content_entries(&scope.content_set_id, &state.pool)
|
||||
.await?;
|
||||
let files =
|
||||
content_rows::get_instance_files(&scope.instance.id, &state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|file| (file.id.clone(), file))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let file = files.get(entry.file_id.as_ref()?)?;
|
||||
Some(InstalledContentFile {
|
||||
relative_path: file.relative_path.clone(),
|
||||
project_id: entry.project_id,
|
||||
enabled: entry.enabled && file.enabled,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn instance_full_path(
|
||||
state: &State,
|
||||
instance: &Instance,
|
||||
) -> PathBuf {
|
||||
state.directories.instances_dir().join(&instance.path)
|
||||
}
|
||||
|
||||
async fn index_existing_file(
|
||||
scope: &ContentScope,
|
||||
relative_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<InstanceFile> {
|
||||
let full_path =
|
||||
instance_full_path(state, &scope.instance).join(relative_path);
|
||||
let (size, sha1) = fetch::sha1_file_async(&full_path).await?;
|
||||
let file_name = Path::new(relative_path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let project_type = ProjectType::get_from_parent_folder(relative_path)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unable to infer project type from {relative_path}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let file = content_rows::upsert_instance_file_from_parts(
|
||||
content_rows::UpsertInstanceFile {
|
||||
instance_id: &scope.instance.id,
|
||||
relative_path,
|
||||
file_name: &file_name,
|
||||
enabled: !relative_path.ends_with(".disabled"),
|
||||
sha1: &sha1,
|
||||
size,
|
||||
missing: false,
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
upsert_entry_for_file(
|
||||
scope,
|
||||
&file,
|
||||
project_type,
|
||||
None,
|
||||
None,
|
||||
ContentSourceKind::Local,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
async fn upsert_entry_for_file(
|
||||
scope: &ContentScope,
|
||||
file: &InstanceFile,
|
||||
project_type: ProjectType,
|
||||
project_id: Option<&str>,
|
||||
version_id: Option<&str>,
|
||||
source_kind: ContentSourceKind,
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
content_rows::upsert_content_entry_from_parts(
|
||||
content_rows::UpsertContentEntry {
|
||||
instance_id: &scope.instance.id,
|
||||
content_set_id: &scope.content_set_id,
|
||||
file_id: Some(&file.id),
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
source_kind,
|
||||
server_requirement: ContentRequirement::Required,
|
||||
client_requirement: ContentRequirement::Required,
|
||||
enabled: file.enabled,
|
||||
},
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_project_type(bytes: &Bytes) -> crate::Result<ProjectType> {
|
||||
let cursor = std::io::Cursor::new(&**bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if archive.by_name("fabric.mod.json").is_ok()
|
||||
|| archive.by_name("quilt.mod.json").is_ok()
|
||||
|| archive.by_name("META-INF/neoforge.mods.toml").is_ok()
|
||||
|| archive.by_name("META-INF/mods.toml").is_ok()
|
||||
|| archive.by_name("mcmod.info").is_ok()
|
||||
{
|
||||
Ok(ProjectType::Mod)
|
||||
} else if archive.by_name("pack.mcmeta").is_ok() {
|
||||
if archive.file_names().any(|name| name.starts_with("data/")) {
|
||||
Ok(ProjectType::DataPack)
|
||||
} else {
|
||||
Ok(ProjectType::ResourcePack)
|
||||
}
|
||||
} else if archive
|
||||
.file_names()
|
||||
.any(|name| name.starts_with("shaders/"))
|
||||
{
|
||||
Ok(ProjectType::ShaderPack)
|
||||
} else {
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use crate::state::instances::{
|
||||
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, Dependency, DependencyType, State, Version,
|
||||
};
|
||||
use crate::util::fetch::DownloadReason;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::apply_content_install::{
|
||||
DownloadedProjectVersion, add_downloaded_project_version,
|
||||
add_project_from_version, download_project_version, remove_project,
|
||||
toggle_disable_project,
|
||||
};
|
||||
use super::check_content_updates::{ContentUpdate, check_content_updates};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct BulkUpdatePlan {
|
||||
project_updates: Vec<PlannedProjectUpdate>,
|
||||
dependency_additions: Vec<PlannedDependencyInstall>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlannedProjectUpdate {
|
||||
relative_path: String,
|
||||
current_version_id: String,
|
||||
update_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlannedDependencyInstall {
|
||||
version_id: String,
|
||||
parent_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PlannedDownload {
|
||||
ProjectUpdate(PlannedProjectUpdate),
|
||||
DependencyAddition(PlannedDependencyInstall),
|
||||
}
|
||||
|
||||
enum DownloadedBulkProject {
|
||||
ProjectUpdate(PlannedProjectUpdate, DownloadedProjectVersion),
|
||||
DependencyAddition(DownloadedProjectVersion),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct InstalledProject {
|
||||
relative_path: String,
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ResolvedDependency {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
parent_version_id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn update_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let updates = check_content_updates(
|
||||
instance_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let update = updates
|
||||
.into_iter()
|
||||
.find(|update| update.relative_path == project_path)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This project cannot be updated!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
apply_content_update(instance_id, project_path, &update, state).await
|
||||
}
|
||||
|
||||
async fn apply_content_update(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
update: &ContentUpdate,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let mut new_path = add_project_from_version(
|
||||
instance_id,
|
||||
&update.update_version_id,
|
||||
DownloadReason::Update,
|
||||
Some(update.current_version_id.clone()),
|
||||
ContentSourceKind::Local,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if project_path.ends_with(".disabled") {
|
||||
new_path =
|
||||
toggle_disable_project(instance_id, &new_path, Some(false), state)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if new_path != project_path {
|
||||
remove_project(instance_id, project_path, state).await?;
|
||||
}
|
||||
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_all_projects(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, String>> {
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::ResolvingVersions,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
let plan = plan_bulk_update(instance_id, state).await?;
|
||||
let download_total =
|
||||
plan.project_updates.len() + plan.dependency_additions.len();
|
||||
let downloads =
|
||||
download_planned_projects(instance_id, &plan, download_total, state)
|
||||
.await?;
|
||||
|
||||
let mut changed = HashMap::new();
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Finishing,
|
||||
download_total,
|
||||
download_total,
|
||||
)
|
||||
.await?;
|
||||
for download in downloads {
|
||||
match download {
|
||||
DownloadedBulkProject::ProjectUpdate(update, downloaded) => {
|
||||
let mut new_path = add_downloaded_project_version(
|
||||
instance_id,
|
||||
downloaded,
|
||||
ContentSourceKind::Local,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if update.relative_path.ends_with(".disabled") {
|
||||
new_path = toggle_disable_project(
|
||||
instance_id,
|
||||
&new_path,
|
||||
Some(false),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if new_path != update.relative_path {
|
||||
remove_project(instance_id, &update.relative_path, state)
|
||||
.await?;
|
||||
}
|
||||
|
||||
changed.insert(update.relative_path, new_path);
|
||||
}
|
||||
DownloadedBulkProject::DependencyAddition(downloaded) => {
|
||||
add_downloaded_project_version(
|
||||
instance_id,
|
||||
downloaded,
|
||||
ContentSourceKind::Local,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn download_planned_projects(
|
||||
instance_id: &str,
|
||||
plan: &BulkUpdatePlan,
|
||||
total: usize,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<DownloadedBulkProject>> {
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Downloading,
|
||||
0,
|
||||
total,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut downloads = plan
|
||||
.project_updates
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(PlannedDownload::ProjectUpdate)
|
||||
.chain(
|
||||
plan.dependency_additions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(PlannedDownload::DependencyAddition),
|
||||
)
|
||||
.map(|download| async move {
|
||||
match download {
|
||||
PlannedDownload::ProjectUpdate(update) => {
|
||||
let downloaded = download_project_version(
|
||||
instance_id,
|
||||
&update.update_version_id,
|
||||
DownloadReason::Update,
|
||||
Some(update.current_version_id.clone()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, crate::Error>(DownloadedBulkProject::ProjectUpdate(
|
||||
update, downloaded,
|
||||
))
|
||||
}
|
||||
PlannedDownload::DependencyAddition(dependency) => {
|
||||
let downloaded = download_project_version(
|
||||
instance_id,
|
||||
&dependency.version_id,
|
||||
DownloadReason::Dependency,
|
||||
Some(dependency.parent_version_id.clone()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, crate::Error>(
|
||||
DownloadedBulkProject::DependencyAddition(downloaded),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let mut completed = 0;
|
||||
let mut output = Vec::with_capacity(total);
|
||||
|
||||
while let Some(download) = downloads.next().await {
|
||||
let download = download?;
|
||||
completed += 1;
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Downloading,
|
||||
completed,
|
||||
total,
|
||||
)
|
||||
.await?;
|
||||
output.push(download);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn emit_bulk_update_progress(
|
||||
instance_id: &str,
|
||||
stage: crate::event::InstanceBulkUpdateProgressStage,
|
||||
current: usize,
|
||||
total: usize,
|
||||
) -> crate::Result<()> {
|
||||
crate::event::emit::emit_instance_bulk_update_progress(
|
||||
crate::event::InstanceBulkUpdateProgressPayload {
|
||||
instance_id: instance_id.to_string(),
|
||||
stage,
|
||||
current,
|
||||
total,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn plan_bulk_update(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<BulkUpdatePlan> {
|
||||
let updates = check_content_updates(
|
||||
instance_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {instance_id} has no applied content set"
|
||||
))
|
||||
})?;
|
||||
let installed =
|
||||
installed_projects(instance_id, &content_set, state).await?;
|
||||
let installed_by_project = installed
|
||||
.iter()
|
||||
.filter_map(|project| {
|
||||
project
|
||||
.project_id
|
||||
.as_ref()
|
||||
.map(|project_id| (project_id.clone(), project.clone()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let updates_by_path = updates
|
||||
.iter()
|
||||
.map(|update| {
|
||||
(
|
||||
update.relative_path.clone(),
|
||||
(
|
||||
update.current_version_id.clone(),
|
||||
update.update_version_id.clone(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let version_ids = installed
|
||||
.iter()
|
||||
.filter_map(|project| project.version_id.clone())
|
||||
.chain(
|
||||
updates
|
||||
.iter()
|
||||
.map(|update| update.update_version_id.clone()),
|
||||
)
|
||||
.collect::<HashSet<_>>();
|
||||
let version_id_refs =
|
||||
version_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&version_id_refs,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let versions_by_id = versions
|
||||
.into_iter()
|
||||
.map(|version| (version.id.clone(), version))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let planned_versions = installed
|
||||
.iter()
|
||||
.filter(|project| project.enabled)
|
||||
.filter_map(|project| {
|
||||
let target_version_id = updates_by_path
|
||||
.get(&project.relative_path)
|
||||
.map(|(_, update_version_id)| update_version_id)
|
||||
.or(project.version_id.as_ref())?;
|
||||
|
||||
versions_by_id.get(target_version_id).cloned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let planned_dependencies =
|
||||
dependency_closure(planned_versions, &content_set, state).await?;
|
||||
let dependency_additions = planned_dependencies
|
||||
.values()
|
||||
.filter(|dependency| {
|
||||
!installed_by_project.contains_key(&dependency.project_id)
|
||||
})
|
||||
.map(|dependency| PlannedDependencyInstall {
|
||||
version_id: dependency.version_id.clone(),
|
||||
parent_version_id: dependency.parent_version_id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let project_updates = updates
|
||||
.into_iter()
|
||||
.map(|update| PlannedProjectUpdate {
|
||||
relative_path: update.relative_path,
|
||||
current_version_id: update.current_version_id,
|
||||
update_version_id: update.update_version_id,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(BulkUpdatePlan {
|
||||
project_updates,
|
||||
dependency_additions,
|
||||
})
|
||||
}
|
||||
|
||||
async fn installed_projects(
|
||||
instance_id: &str,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstalledProject>> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let entries =
|
||||
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
|
||||
let entries_by_file_id = entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry.file_id.as_deref().map(|file_id| (file_id, entry))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let files =
|
||||
content_rows::get_instance_files(&instance.id, &state.pool).await?;
|
||||
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.filter_map(|file| {
|
||||
let entry = entries_by_file_id.get(file.id.as_str())?;
|
||||
installed_project_from_row(&file, entry)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn installed_project_from_row(
|
||||
file: &InstanceFile,
|
||||
entry: &ContentEntry,
|
||||
) -> Option<InstalledProject> {
|
||||
if entry.project_id.is_none() && entry.version_id.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(InstalledProject {
|
||||
relative_path: file.relative_path.clone(),
|
||||
project_id: entry.project_id.clone(),
|
||||
version_id: entry.version_id.clone(),
|
||||
enabled: entry.enabled && file.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async fn dependency_closure(
|
||||
root_versions: Vec<Version>,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, ResolvedDependency>> {
|
||||
let mut output = HashMap::new();
|
||||
let mut stack = root_versions;
|
||||
let mut visited_versions = HashSet::new();
|
||||
let mut version_cache = HashMap::new();
|
||||
let mut project_versions_cache = HashMap::new();
|
||||
|
||||
while let Some(version) = stack.pop() {
|
||||
if !visited_versions.insert(version.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for dependency in &version.dependencies {
|
||||
if !is_required_dependency(dependency, content_set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(dependency_version) = resolve_dependency_version(
|
||||
dependency,
|
||||
content_set,
|
||||
state,
|
||||
&mut version_cache,
|
||||
&mut project_versions_cache,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let project_id = dependency
|
||||
.project_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| dependency_version.project_id.clone());
|
||||
|
||||
output.entry(project_id.clone()).or_insert_with(|| {
|
||||
ResolvedDependency {
|
||||
project_id,
|
||||
version_id: dependency_version.id.clone(),
|
||||
parent_version_id: version.id.clone(),
|
||||
}
|
||||
});
|
||||
stack.push(dependency_version);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn is_required_dependency(
|
||||
dependency: &Dependency,
|
||||
content_set: &ContentSet,
|
||||
) -> bool {
|
||||
matches!(dependency.dependency_type, DependencyType::Required)
|
||||
&& !(dependency.project_id.as_deref() == Some("P7dR8mSH")
|
||||
&& content_set.loader.as_str() == "quilt")
|
||||
}
|
||||
|
||||
async fn resolve_dependency_version(
|
||||
dependency: &Dependency,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
version_cache: &mut HashMap<String, Option<Version>>,
|
||||
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
|
||||
) -> crate::Result<Option<Version>> {
|
||||
if let Some(version_id) = &dependency.version_id {
|
||||
return cached_version(version_id, version_cache, state).await;
|
||||
}
|
||||
|
||||
let Some(project_id) = &dependency.project_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(mut versions) =
|
||||
cached_project_versions(project_id, project_versions_cache, state)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
versions.sort_by_key(|version| Reverse(version.date_published));
|
||||
|
||||
Ok(find_preferred_dependency_version(&versions, content_set))
|
||||
}
|
||||
|
||||
async fn cached_version(
|
||||
version_id: &str,
|
||||
version_cache: &mut HashMap<String, Option<Version>>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<Version>> {
|
||||
if !version_cache.contains_key(version_id) {
|
||||
let version = CachedEntry::get_version(
|
||||
version_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
version_cache.insert(version_id.to_string(), version);
|
||||
}
|
||||
|
||||
Ok(version_cache.get(version_id).cloned().flatten())
|
||||
}
|
||||
|
||||
async fn cached_project_versions(
|
||||
project_id: &str,
|
||||
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<Vec<Version>>> {
|
||||
if !project_versions_cache.contains_key(project_id) {
|
||||
let versions = CachedEntry::get_project_versions(
|
||||
project_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
project_versions_cache.insert(project_id.to_string(), versions);
|
||||
}
|
||||
|
||||
Ok(project_versions_cache.get(project_id).cloned().flatten())
|
||||
}
|
||||
|
||||
fn find_preferred_dependency_version(
|
||||
versions: &[Version],
|
||||
content_set: &ContentSet,
|
||||
) -> Option<Version> {
|
||||
versions
|
||||
.iter()
|
||||
.find(|version| {
|
||||
version.game_versions.contains(&content_set.game_version)
|
||||
&& version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader == content_set.loader.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
versions.iter().find(|version| {
|
||||
is_dependency_version_compatible(version, content_set)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn is_dependency_version_compatible(
|
||||
version: &Version,
|
||||
content_set: &ContentSet,
|
||||
) -> bool {
|
||||
version.game_versions.contains(&content_set.game_version)
|
||||
&& (version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader == content_set.loader.as_str())
|
||||
|| version.loaders.iter().any(|loader| loader == "datapack"))
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use crate::state::instances::{
|
||||
ContentEntry, InstanceFile,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, ProjectType, ReleaseChannel, State,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::sync_content_files::{
|
||||
project_type_for_file, sync_instance_content_files,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ContentUpdate {
|
||||
pub relative_path: String,
|
||||
pub current_version_id: String,
|
||||
pub update_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct UpdateCandidate {
|
||||
entry: Option<ContentEntry>,
|
||||
file: InstanceFile,
|
||||
project_type: ProjectType,
|
||||
current_version_id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn check_content_updates(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<ContentUpdate>> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(&instance.id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {} has no applied content set",
|
||||
instance.id
|
||||
))
|
||||
})?;
|
||||
let entries =
|
||||
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
|
||||
let entries_by_file_id = entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry.file_id.as_deref().map(|file_id| (file_id, entry))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let files = sync_instance_content_files(&instance, state).await?;
|
||||
let hashes = files
|
||||
.iter()
|
||||
.map(|file| file.sha1.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let file_info = CachedEntry::get_file_many(
|
||||
&hashes,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let file_info_by_hash = file_info
|
||||
.into_iter()
|
||||
.map(|file| (file.hash.clone(), file))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let candidates = files
|
||||
.into_iter()
|
||||
.filter_map(|file| {
|
||||
let project_type = project_type_for_file(&file)?;
|
||||
let metadata = file_info_by_hash.get(&file.sha1)?;
|
||||
Some(UpdateCandidate {
|
||||
entry: entries_by_file_id
|
||||
.get(file.id.as_str())
|
||||
.copied()
|
||||
.cloned(),
|
||||
file,
|
||||
project_type,
|
||||
current_version_id: metadata.version_id.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let installed_channels =
|
||||
installed_update_channels(&candidates, cache_behaviour, state).await?;
|
||||
let update_keys = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
update_cache_key(
|
||||
&candidate.file,
|
||||
candidate.project_type,
|
||||
effective_update_channel(
|
||||
instance.update_channel,
|
||||
installed_channels.get(&candidate.file.sha1).copied(),
|
||||
),
|
||||
&content_set.game_version,
|
||||
content_set.loader.as_str(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let update_key_refs = update_keys
|
||||
.iter()
|
||||
.map(|key| key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let updates = CachedEntry::get_file_update_many(
|
||||
&update_key_refs,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for update in updates {
|
||||
updates_by_hash
|
||||
.entry(update.hash)
|
||||
.or_default()
|
||||
.push(update.update_version_id);
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
for candidate in candidates {
|
||||
let update_version_id = updates_by_hash
|
||||
.remove(&candidate.file.sha1)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|update_version_id| {
|
||||
update_version_id != &candidate.current_version_id
|
||||
});
|
||||
|
||||
if let Some(entry) = &candidate.entry {
|
||||
content_rows::upsert_content_update_check(
|
||||
&entry.id,
|
||||
instance.update_channel,
|
||||
update_version_id.as_deref(),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(update_version_id) = update_version_id {
|
||||
output.push(ContentUpdate {
|
||||
relative_path: candidate.file.relative_path,
|
||||
current_version_id: candidate.current_version_id,
|
||||
update_version_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn installed_update_channels(
|
||||
candidates: &[UpdateCandidate],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, ReleaseChannel>> {
|
||||
let version_ids = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.current_version_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&version_ids,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let channels_by_version_id = versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
(
|
||||
version.id,
|
||||
ReleaseChannel::from_version_type(&version.version_type),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Ok(candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| {
|
||||
channels_by_version_id
|
||||
.get(&candidate.current_version_id)
|
||||
.copied()
|
||||
.map(|channel| (candidate.file.sha1.clone(), channel))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn effective_update_channel(
|
||||
preferred: ReleaseChannel,
|
||||
installed: Option<ReleaseChannel>,
|
||||
) -> ReleaseChannel {
|
||||
installed.map_or(preferred, |channel| preferred.least_stable(channel))
|
||||
}
|
||||
|
||||
fn update_cache_key(
|
||||
file: &InstanceFile,
|
||||
project_type: ProjectType,
|
||||
channel: ReleaseChannel,
|
||||
game_version: &str,
|
||||
loader: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}-{}-{}-{}",
|
||||
file.sha1,
|
||||
if project_type == ProjectType::Mod {
|
||||
loader.to_string()
|
||||
} else {
|
||||
project_type.get_loaders().join("+")
|
||||
},
|
||||
channel.key(),
|
||||
game_version
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use crate::launcher::get_loader_version_from_profile;
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
|
||||
InstanceLaunchOverrides, InstanceLink,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
State,
|
||||
};
|
||||
use crate::util::fetch::{self, write_cached_icon};
|
||||
use crate::util::io;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateInstance {
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub link: InstanceLink,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance(
|
||||
input: CreateInstance,
|
||||
state: &State,
|
||||
) -> crate::Result<Instance> {
|
||||
trace!("Creating new instance. {}", input.name);
|
||||
|
||||
let (path, full_path) =
|
||||
resolve_instance_path(&input.name, input.path.as_deref(), state)
|
||||
.await?;
|
||||
io::create_dir_all(&full_path).await?;
|
||||
|
||||
let result = async {
|
||||
info!(
|
||||
"Creating instance at path {}",
|
||||
&io::canonicalize(&full_path)?.display()
|
||||
);
|
||||
|
||||
let loader_version = if input.loader != ModLoader::Vanilla {
|
||||
get_loader_version_from_profile(
|
||||
&input.game_version,
|
||||
input.loader,
|
||||
input.loader_version.as_deref(),
|
||||
)
|
||||
.await?
|
||||
.map(|value| value.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let icon_path =
|
||||
resolve_icon_path(input.icon_path.as_deref(), state).await?;
|
||||
let now = Utc::now();
|
||||
let instance_id = format!("local:{}", Uuid::new_v4());
|
||||
let content_set_id = format!("content-set:{}", Uuid::new_v4());
|
||||
let instance = Instance {
|
||||
id: instance_id.clone(),
|
||||
path: path.clone(),
|
||||
applied_content_set_id: Some(content_set_id.clone()),
|
||||
install_stage: InstanceInstallStage::NotInstalled,
|
||||
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
|
||||
update_channel: ReleaseChannel::Release,
|
||||
name: input.name,
|
||||
icon_path,
|
||||
created: now,
|
||||
modified: now,
|
||||
last_played: None,
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
};
|
||||
let content_set = ContentSet {
|
||||
id: content_set_id,
|
||||
instance_id: instance_id.clone(),
|
||||
name: "Default".to_string(),
|
||||
source_kind: content_source_kind(&input.link),
|
||||
status: ContentSetStatus::Available,
|
||||
game_version: input.game_version,
|
||||
protocol_version: None,
|
||||
loader: input.loader,
|
||||
loader_version,
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
let launch_overrides =
|
||||
InstanceLaunchOverrides::empty(instance_id.clone());
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::insert_instance(&instance, &mut tx).await?;
|
||||
content_rows::insert_content_set(&content_set, &mut tx).await?;
|
||||
instance_rows::upsert_instance_link(&instance_id, &input.link, &mut tx)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(&instance_id, &[], &mut tx)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(
|
||||
&launch_overrides,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
crate::state::instances::watcher::watch_instance_folder(
|
||||
&instance.path,
|
||||
&state.file_watcher,
|
||||
&state.directories,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = io::remove_dir_all(&full_path).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn resolve_instance_path(
|
||||
name: &str,
|
||||
path: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<(String, std::path::PathBuf)> {
|
||||
let base_path = path
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| sanitize_instance_name(name));
|
||||
let mut path = base_path.clone();
|
||||
let mut full_path = state.directories.instances_dir().join(&path);
|
||||
|
||||
if path_available(&path, &full_path, state).await? {
|
||||
return Ok((path, full_path));
|
||||
}
|
||||
|
||||
let mut which = 1;
|
||||
loop {
|
||||
path = format!("{base_path} ({which})");
|
||||
full_path = state.directories.instances_dir().join(&path);
|
||||
|
||||
if path_available(&path, &full_path, state).await? {
|
||||
return Ok((path, full_path));
|
||||
}
|
||||
|
||||
which += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async fn path_available(
|
||||
path: &str,
|
||||
full_path: &std::path::Path,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
if full_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(instance_rows::get_instance_by_path(path, &state.pool)
|
||||
.await?
|
||||
.is_none())
|
||||
}
|
||||
|
||||
async fn resolve_icon_path(
|
||||
icon_path: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let Some(icon) = icon_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (bytes, file_name) = if icon.starts_with("https://")
|
||||
|| icon.starts_with("http://")
|
||||
{
|
||||
let fetched = fetch::fetch(
|
||||
icon,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
|
||||
(fetched, name)
|
||||
} else {
|
||||
let data = io::read(state.directories.caches_dir().join(icon)).await?;
|
||||
(bytes::Bytes::from(data), icon.to_string())
|
||||
};
|
||||
|
||||
let file = write_cached_icon(
|
||||
&file_name,
|
||||
&state.directories.caches_dir(),
|
||||
bytes,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(file.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
|
||||
match link {
|
||||
InstanceLink::Unmanaged => ContentSourceKind::Local,
|
||||
InstanceLink::ModrinthModpack { .. } => {
|
||||
ContentSourceKind::ModrinthModpack
|
||||
}
|
||||
InstanceLink::ServerProject { .. }
|
||||
| InstanceLink::ServerProjectModpack { .. } => {
|
||||
ContentSourceKind::ServerProject
|
||||
}
|
||||
InstanceLink::ModrinthHosting { .. } => {
|
||||
ContentSourceKind::ModrinthHosting
|
||||
}
|
||||
InstanceLink::ImportedModpack { .. } => {
|
||||
ContentSourceKind::ImportedModpack
|
||||
}
|
||||
InstanceLink::SharedInstance { .. } => {
|
||||
ContentSourceKind::SharedInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_instance_name(input: &str) -> String {
|
||||
input.replace(
|
||||
['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>', '!'],
|
||||
"_",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use crate::state::instances::{
|
||||
ContentSourceKind, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
Hooks, InstanceInstallStage, LauncherFeatureVersion, MemorySettings,
|
||||
ModLoader, ReleaseChannel, WindowSize,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct EditInstance {
|
||||
pub install_stage: Option<InstanceInstallStage>,
|
||||
pub launcher_feature_version: Option<LauncherFeatureVersion>,
|
||||
pub name: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_path: Option<Option<String>>,
|
||||
pub update_channel: Option<ReleaseChannel>,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub link: Option<InstanceLink>,
|
||||
pub launch_overrides: Option<InstanceLaunchOverridesPatch>,
|
||||
pub content_set_patch: Option<AppliedContentSetPatch>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub last_played: Option<Option<DateTime<Utc>>>,
|
||||
pub submitted_time_played: Option<u64>,
|
||||
pub recent_time_played: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub java_path: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub extra_launch_args: Option<Option<Vec<String>>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub custom_env_vars: Option<Option<Vec<(String, String)>>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub memory: Option<Option<MemorySettings>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub force_fullscreen: Option<Option<bool>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub game_resolution: Option<Option<WindowSize>>,
|
||||
pub hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AppliedContentSetPatch {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
pub game_version: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub protocol_version: Option<Option<u32>>,
|
||||
pub loader: Option<ModLoader>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub loader_version: Option<Option<String>>,
|
||||
}
|
||||
|
||||
pub(crate) async fn edit_instance(
|
||||
instance_id: &str,
|
||||
patch: EditInstance,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Instance> {
|
||||
let mut instance = instance_rows::get_instance_by_id(instance_id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let now = Utc::now();
|
||||
|
||||
apply_instance_patch(&mut instance, &patch, now);
|
||||
|
||||
let mut content_set = match patch.content_set_patch {
|
||||
Some(content_set_patch) => {
|
||||
let applied_content_set =
|
||||
content_rows::get_applied_content_set(&instance.id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {} has no applied content set",
|
||||
instance.id
|
||||
))
|
||||
})?;
|
||||
Some(apply_content_set_patch(
|
||||
applied_content_set,
|
||||
content_set_patch,
|
||||
now,
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut launch_overrides = match patch.launch_overrides {
|
||||
Some(launch_patch) => {
|
||||
let current = instance_rows::get_instance_launch_overrides(
|
||||
&instance.id,
|
||||
pool,
|
||||
)
|
||||
.await?
|
||||
.unwrap_or_else(|| {
|
||||
InstanceLaunchOverrides::empty(instance.id.clone())
|
||||
});
|
||||
Some(apply_launch_overrides_patch(current, launch_patch))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
instance_rows::update_instance(&instance, &mut tx).await?;
|
||||
|
||||
if let Some(content_set) = content_set.as_mut() {
|
||||
content_rows::update_content_set(content_set, &mut tx).await?;
|
||||
}
|
||||
|
||||
if let Some(link) = &patch.link {
|
||||
instance_rows::upsert_instance_link(&instance.id, link, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(groups) = &patch.groups {
|
||||
instance_rows::replace_instance_groups(&instance.id, groups, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(overrides) = launch_overrides.as_mut() {
|
||||
instance_rows::upsert_instance_launch_overrides(overrides, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
fn apply_instance_patch(
|
||||
instance: &mut Instance,
|
||||
patch: &EditInstance,
|
||||
now: DateTime<Utc>,
|
||||
) {
|
||||
if let Some(install_stage) = patch.install_stage {
|
||||
instance.install_stage = install_stage;
|
||||
}
|
||||
if let Some(launcher_feature_version) = patch.launcher_feature_version {
|
||||
instance.launcher_feature_version = launcher_feature_version;
|
||||
}
|
||||
if let Some(name) = &patch.name {
|
||||
instance.name = name.clone();
|
||||
}
|
||||
if let Some(icon_path) = &patch.icon_path {
|
||||
instance.icon_path = icon_path.clone();
|
||||
}
|
||||
if let Some(update_channel) = patch.update_channel {
|
||||
instance.update_channel = update_channel;
|
||||
}
|
||||
if let Some(last_played) = &patch.last_played {
|
||||
instance.last_played = *last_played;
|
||||
}
|
||||
if let Some(submitted_time_played) = patch.submitted_time_played {
|
||||
instance.submitted_time_played = submitted_time_played;
|
||||
}
|
||||
if let Some(recent_time_played) = patch.recent_time_played {
|
||||
instance.recent_time_played = recent_time_played;
|
||||
}
|
||||
|
||||
instance.modified = now;
|
||||
}
|
||||
|
||||
fn apply_content_set_patch(
|
||||
mut content_set: crate::state::instances::ContentSet,
|
||||
patch: AppliedContentSetPatch,
|
||||
now: DateTime<Utc>,
|
||||
) -> crate::state::instances::ContentSet {
|
||||
if let Some(game_version) = patch.game_version {
|
||||
content_set.game_version = game_version;
|
||||
}
|
||||
if let Some(source_kind) = patch.source_kind {
|
||||
content_set.source_kind = source_kind;
|
||||
}
|
||||
if let Some(protocol_version) = patch.protocol_version {
|
||||
content_set.protocol_version = protocol_version;
|
||||
}
|
||||
if let Some(loader) = patch.loader {
|
||||
content_set.loader = loader;
|
||||
}
|
||||
if let Some(loader_version) = patch.loader_version {
|
||||
content_set.loader_version = loader_version;
|
||||
}
|
||||
|
||||
content_set.modified = now;
|
||||
content_set
|
||||
}
|
||||
|
||||
fn apply_launch_overrides_patch(
|
||||
mut overrides: InstanceLaunchOverrides,
|
||||
patch: InstanceLaunchOverridesPatch,
|
||||
) -> InstanceLaunchOverrides {
|
||||
if let Some(java_path) = patch.java_path {
|
||||
overrides.java_path = java_path;
|
||||
}
|
||||
if let Some(extra_launch_args) = patch.extra_launch_args {
|
||||
overrides.extra_launch_args = extra_launch_args;
|
||||
}
|
||||
if let Some(custom_env_vars) = patch.custom_env_vars {
|
||||
overrides.custom_env_vars = custom_env_vars;
|
||||
}
|
||||
if let Some(memory) = patch.memory {
|
||||
overrides.memory = memory;
|
||||
}
|
||||
if let Some(force_fullscreen) = patch.force_fullscreen {
|
||||
overrides.force_fullscreen = force_fullscreen;
|
||||
}
|
||||
if let Some(game_resolution) = patch.game_resolution {
|
||||
overrides.game_resolution = game_resolution;
|
||||
}
|
||||
if let Some(hooks) = patch.hooks {
|
||||
overrides.hooks = hooks;
|
||||
}
|
||||
|
||||
overrides
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::state::instances::{
|
||||
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
adapters::sqlite::instance_rows,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceMetadata {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub groups: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceMetadata>> {
|
||||
get_instance_metadata(instance_id, pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_metadata(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceMetadata>> {
|
||||
Ok(
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool)
|
||||
.await?
|
||||
.map(Into::into),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instances_metadata(
|
||||
instance_ids: &[&str],
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceMetadata>> {
|
||||
Ok(
|
||||
instance_rows::get_instance_metadata_many(instance_ids, pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instances(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceMetadata>> {
|
||||
Ok(instance_rows::list_instance_metadata(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl From<instance_rows::InstanceMetadataRecord> for InstanceMetadata {
|
||||
fn from(record: instance_rows::InstanceMetadataRecord) -> Self {
|
||||
Self {
|
||||
instance: record.instance,
|
||||
applied_content_set: record.applied_content_set,
|
||||
link: record.link,
|
||||
groups: record.groups,
|
||||
launch_overrides: record.launch_overrides,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use crate::state::InstanceInstallStage;
|
||||
use crate::state::instances::{
|
||||
InstanceLaunchContext, adapters::sqlite::instance_rows,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub(crate) async fn get_instance_launch_context(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceLaunchContext>> {
|
||||
instance_rows::get_instance_launch_context(instance_id, pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_install_stage(
|
||||
instance_id: &str,
|
||||
install_stage: InstanceInstallStage,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let install_stage = install_stage.as_str();
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET install_stage = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
install_stage,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_applied_content_set_loader_version(
|
||||
instance_id: &str,
|
||||
loader_version: Option<&str>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_sets
|
||||
SET loader_version = ?, modified = ?
|
||||
WHERE id = (
|
||||
SELECT applied_content_set_id
|
||||
FROM instances
|
||||
WHERE id = ?
|
||||
)
|
||||
",
|
||||
loader_version,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_applied_content_set_protocol_version(
|
||||
instance_id: &str,
|
||||
protocol_version: Option<u32>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let protocol_version = protocol_version.map(i64::from);
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_sets
|
||||
SET protocol_version = ?, modified = ?
|
||||
WHERE id = (
|
||||
SELECT applied_content_set_id
|
||||
FROM instances
|
||||
WHERE id = ?
|
||||
)
|
||||
",
|
||||
protocol_version,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_last_played(
|
||||
instance_id: &str,
|
||||
last_played: DateTime<Utc>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let last_played = last_played.timestamp();
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET last_played = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
last_played,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn add_instance_recent_playtime(
|
||||
instance_id: &str,
|
||||
seconds: u64,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let seconds = seconds as i64;
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET recent_time_played = recent_time_played + ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
seconds,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_instance_playtime_submitted(
|
||||
instance_id: &str,
|
||||
recent_time_played: u64,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let recent_time_played = recent_time_played as i64;
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET
|
||||
submitted_time_played = submitted_time_played + ?,
|
||||
recent_time_played = 0,
|
||||
modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
recent_time_played,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
mod create_instance;
|
||||
pub use self::create_instance::CreateInstance;
|
||||
pub(crate) use self::create_instance::create_instance;
|
||||
|
||||
mod edit_instance;
|
||||
pub(crate) use self::edit_instance::edit_instance;
|
||||
pub use self::edit_instance::{
|
||||
AppliedContentSetPatch, EditInstance, InstanceLaunchOverridesPatch,
|
||||
};
|
||||
|
||||
mod get_instance;
|
||||
pub use self::get_instance::InstanceMetadata;
|
||||
pub(crate) use self::get_instance::{
|
||||
get_instance, get_instance_metadata, get_instances_metadata, list_instances,
|
||||
};
|
||||
|
||||
mod list_content;
|
||||
pub(crate) use self::list_content::{
|
||||
dependencies_to_content_items, get_content_projects,
|
||||
get_installed_project_ids_for_instance, get_instance_install_candidates,
|
||||
get_linked_modpack_info, list_content, list_content_sets,
|
||||
list_linked_modpack_content,
|
||||
};
|
||||
|
||||
mod remove_instance;
|
||||
pub(crate) use self::remove_instance::*;
|
||||
|
||||
mod refresh_instances;
|
||||
pub(crate) use self::refresh_instances::*;
|
||||
|
||||
mod sync_content_files;
|
||||
pub(crate) use self::sync_content_files::sync_content_files;
|
||||
|
||||
mod launch_context;
|
||||
pub(crate) use self::launch_context::*;
|
||||
|
||||
mod apply_content_install;
|
||||
pub(crate) use self::apply_content_install::*;
|
||||
|
||||
mod check_content_updates;
|
||||
|
||||
mod apply_content_update;
|
||||
pub(crate) use self::apply_content_update::*;
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::State;
|
||||
use crate::state::LauncherFeatureVersion;
|
||||
|
||||
use super::edit_instance::EditInstance;
|
||||
|
||||
pub(crate) async fn refresh_all_instances() -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instances = crate::state::instances::adapters::sqlite::instance_rows::list_instances(
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for instance in instances {
|
||||
let launcher_feature_version = (instance.launcher_feature_version
|
||||
< LauncherFeatureVersion::MOST_RECENT)
|
||||
.then_some(LauncherFeatureVersion::MOST_RECENT);
|
||||
|
||||
if launcher_feature_version.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
super::edit_instance::edit_instance(
|
||||
&instance.id,
|
||||
EditInstance {
|
||||
install_stage: None,
|
||||
launcher_feature_version,
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::util::io;
|
||||
|
||||
pub(crate) async fn remove_instance(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
instance_rows::delete_instance_by_id(&instance.id, &state.pool).await?;
|
||||
|
||||
let path = state.directories.instances_dir().join(&instance.path);
|
||||
if path.exists() {
|
||||
io::remove_dir_all(&path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::State;
|
||||
use crate::state::instances::adapters::{filesystem, sqlite};
|
||||
use crate::state::instances::{Instance, InstanceFile};
|
||||
use crate::state::{CachedEntry, ProjectType};
|
||||
use chrono::Utc;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn sync_content_files(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstanceFile>> {
|
||||
let instance =
|
||||
sqlite::instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
sync_instance_content_files(&instance, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_instance_content_files(
|
||||
instance: &Instance,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstanceFile>> {
|
||||
let scanned = filesystem::scan_content_files(
|
||||
&state.directories.instances_dir(),
|
||||
&instance.path,
|
||||
)?;
|
||||
let cache_keys = scanned
|
||||
.iter()
|
||||
.map(|file| file.hash_cache_key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let hashes = CachedEntry::get_file_hash_many(
|
||||
&cache_keys,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let hashes_by_path = hashes
|
||||
.into_iter()
|
||||
.map(|hash| (hash.path.clone(), hash))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let existing_files =
|
||||
sqlite::content_rows::get_instance_files(&instance.id, &state.pool)
|
||||
.await?;
|
||||
let existing_files_by_path = existing_files
|
||||
.into_iter()
|
||||
.map(|file| (file.relative_path.clone(), file))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let now = Utc::now();
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in scanned {
|
||||
let cache_path = format!("{}/{}", instance.path, file.relative_path);
|
||||
let Some(hash) = hashes_by_path.get(&cache_path) else {
|
||||
continue;
|
||||
};
|
||||
let existing_file = existing_files_by_path.get(&file.relative_path);
|
||||
|
||||
files.push(InstanceFile {
|
||||
id: existing_file
|
||||
.map(|file| file.id.clone())
|
||||
.unwrap_or_else(instance_file_id),
|
||||
instance_id: instance.id.clone(),
|
||||
relative_path: file.relative_path,
|
||||
file_name: file.file_name,
|
||||
enabled: file.enabled,
|
||||
sha1: hash.hash.clone(),
|
||||
size: file.size,
|
||||
missing: false,
|
||||
added_at: existing_file.map(|file| file.added_at).unwrap_or(now),
|
||||
modified_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
sqlite::content_rows::mark_instance_files_missing(&instance.id, &mut tx)
|
||||
.await?;
|
||||
|
||||
for file in &files {
|
||||
sqlite::content_rows::upsert_instance_file(file, &mut tx).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub(crate) fn project_type_for_file(
|
||||
file: &InstanceFile,
|
||||
) -> Option<ProjectType> {
|
||||
filesystem::project_type_from_relative_path(&file.relative_path)
|
||||
}
|
||||
|
||||
fn instance_file_id() -> String {
|
||||
format!("instance-file:{}", Uuid::new_v4())
|
||||
}
|
||||
@@ -1,65 +1,22 @@
|
||||
//! # Content API
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//!
|
||||
//! 1. Frontend calls `get_content_items(profile_path)`
|
||||
//! 2. If profile is linked to a modpack:
|
||||
//! - Fetch modpack file hashes from cache (populated during installation)
|
||||
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
|
||||
//! - Filter out files that belong to the modpack before update lookup
|
||||
//! 3. For remaining files, fetch project/version/owner metadata in parallel
|
||||
//! 4. Return sorted `ContentItem` list
|
||||
//!
|
||||
//! ## Caching
|
||||
//!
|
||||
//! Modpack file hashes are cached in `CacheValueType::ModpackFiles`
|
||||
//! during modpack installation. The cache never expires (version_id is
|
||||
//! immutable), so re-download is only needed if cache was cleared or
|
||||
//! profile predates this caching mechanism.
|
||||
|
||||
use crate::pack::install_from::{PackFileHash, PackFormat};
|
||||
use crate::state::profiles::{Profile, ProfileFile, ProjectType};
|
||||
use crate::state::{CacheBehaviour, CachedEntry, ReleaseChannel};
|
||||
use crate::util::fetch::{
|
||||
DownloadMeta, DownloadReason, FetchSemaphore, fetch_mirrors, sha1_async,
|
||||
};
|
||||
use async_zip::base::read::seek::ZipFileReader;
|
||||
use crate::state::{Project, ProjectType, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Content item with rich metadata for frontend display
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItem {
|
||||
/// Display file name.
|
||||
pub file_name: String,
|
||||
/// Relative path to the file within the profile
|
||||
pub file_path: String,
|
||||
/// SHA1 hash of file content. Stable across renames, but not unique when
|
||||
/// duplicate files have identical contents.
|
||||
pub id: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Whether the file is enabled (not .disabled)
|
||||
pub enabled: bool,
|
||||
/// Type of project (mod, resourcepack, etc.)
|
||||
pub project_type: ProjectType,
|
||||
/// Modrinth project info if recognized
|
||||
pub project: Option<ContentItemProject>,
|
||||
/// Version info if recognized
|
||||
pub version: Option<ContentItemVersion>,
|
||||
/// Owner info (organization or user)
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
/// Whether an update is available
|
||||
pub has_update: bool,
|
||||
/// The recommended version ID to update to (if has_update is true)
|
||||
pub update_version_id: Option<String>,
|
||||
/// When the file was added to the instance (file modification time)
|
||||
pub date_added: Option<String>,
|
||||
}
|
||||
|
||||
/// Project information for content item display
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemProject {
|
||||
pub id: String,
|
||||
@@ -68,7 +25,6 @@ pub struct ContentItemProject {
|
||||
pub icon_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Version information for content item display
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemVersion {
|
||||
pub id: String,
|
||||
@@ -77,7 +33,6 @@ pub struct ContentItemVersion {
|
||||
pub date_published: Option<String>,
|
||||
}
|
||||
|
||||
/// Owner information for content item display
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemOwner {
|
||||
pub id: String,
|
||||
@@ -87,7 +42,6 @@ pub struct ContentItemOwner {
|
||||
pub owner_type: OwnerType,
|
||||
}
|
||||
|
||||
/// Type of content owner
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OwnerType {
|
||||
@@ -95,894 +49,12 @@ pub enum OwnerType {
|
||||
Organization,
|
||||
}
|
||||
|
||||
use crate::state::cache::{Dependency, Organization, TeamMember};
|
||||
use crate::state::{Project, Version};
|
||||
|
||||
/// Full linked modpack information including owner and update status
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LinkedModpackInfo {
|
||||
pub project: Project,
|
||||
pub version: Version,
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
/// Whether an update is available for this modpack
|
||||
pub has_update: bool,
|
||||
/// The version ID to update to (if has_update is true)
|
||||
pub update_version_id: Option<String>,
|
||||
/// The full version info for the update (if has_update is true)
|
||||
pub update_version: Option<Version>,
|
||||
}
|
||||
|
||||
/// Get linked modpack info including project, version, owner, and update status.
|
||||
/// Returns None if the profile is not linked to a modpack.
|
||||
pub async fn get_linked_modpack_info(
|
||||
profile: &Profile,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Option<LinkedModpackInfo>> {
|
||||
let Some(linked_data) = &profile.linked_data else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Vanilla server projects have linked_data with an empty version_id
|
||||
if linked_data.version_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fetch project, version, and all project versions in parallel
|
||||
let (project, version, all_versions) = tokio::try_join!(
|
||||
CachedEntry::get_project(
|
||||
&linked_data.project_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
CachedEntry::get_version(
|
||||
&linked_data.version_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
CachedEntry::get_project_versions(
|
||||
&linked_data.project_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
)?;
|
||||
|
||||
let version = version.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Linked modpack version {} not found",
|
||||
linked_data.version_id
|
||||
))
|
||||
})?;
|
||||
|
||||
// For server instances, linked_data.project_id is the server project,
|
||||
// but the version may belong to a different (modpack) project.
|
||||
// If so, fetch the actual modpack project for display and update checking.
|
||||
let (project, all_versions) =
|
||||
if version.project_id != linked_data.project_id {
|
||||
let (modpack_project, modpack_versions) = tokio::try_join!(
|
||||
CachedEntry::get_project(
|
||||
&version.project_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
CachedEntry::get_project_versions(
|
||||
&version.project_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
)?;
|
||||
(modpack_project.or(project), modpack_versions)
|
||||
} else {
|
||||
(project, all_versions)
|
||||
};
|
||||
|
||||
let project = project.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Linked modpack project {} not found",
|
||||
linked_data.project_id
|
||||
))
|
||||
})?;
|
||||
|
||||
// Resolve owner - prefer organization, fall back to team owner
|
||||
let owner = if let Some(org_id) = &project.organization {
|
||||
let org = CachedEntry::get_organization(
|
||||
org_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
org.map(|o| ContentItemOwner {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
avatar_url: o.icon_url,
|
||||
owner_type: OwnerType::Organization,
|
||||
})
|
||||
} else {
|
||||
let team = CachedEntry::get_team(
|
||||
&project.team,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
team.and_then(|t| {
|
||||
t.into_iter()
|
||||
.find(|m| m.is_owner)
|
||||
.map(|m| ContentItemOwner {
|
||||
id: m.user.id,
|
||||
name: m.user.username,
|
||||
avatar_url: m.user.avatar_url,
|
||||
owner_type: OwnerType::User,
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
// Check for updates
|
||||
let (has_update, update_version_id, update_version) = check_modpack_update(
|
||||
&linked_data.version_id,
|
||||
&version,
|
||||
all_versions,
|
||||
profile.preferred_update_channel,
|
||||
);
|
||||
|
||||
Ok(Some(LinkedModpackInfo {
|
||||
project,
|
||||
version,
|
||||
owner,
|
||||
has_update,
|
||||
update_version_id,
|
||||
update_version,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if a newer version exists for the linked modpack.
|
||||
/// Returns (has_update, update_version_id, update_version).
|
||||
fn check_modpack_update(
|
||||
installed_version_id: &str,
|
||||
installed_version: &Version,
|
||||
all_versions: Option<Vec<Version>>,
|
||||
preferred_update_channel: ReleaseChannel,
|
||||
) -> (bool, Option<String>, Option<Version>) {
|
||||
let Some(versions) = all_versions else {
|
||||
return (false, None, None);
|
||||
};
|
||||
|
||||
let installed_channel =
|
||||
ReleaseChannel::from_version_type(&installed_version.version_type);
|
||||
let effective_channel =
|
||||
preferred_update_channel.least_stable(installed_channel);
|
||||
|
||||
for version_types in effective_channel.version_type_fallbacks() {
|
||||
if !versions
|
||||
.iter()
|
||||
.any(|v| version_types.contains(&v.version_type.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut newer_versions: Vec<&Version> = versions
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.id != installed_version_id
|
||||
&& v.date_published > installed_version.date_published
|
||||
&& version_types.contains(&v.version_type.as_str())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by date_published descending (newest first)
|
||||
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
|
||||
|
||||
if let Some(newest) = newer_versions.first() {
|
||||
return (true, Some(newest.id.clone()), Some((*newest).clone()));
|
||||
}
|
||||
|
||||
return (false, None, None);
|
||||
}
|
||||
|
||||
(false, None, None)
|
||||
}
|
||||
|
||||
/// Get content items with rich metadata, filtered to exclude modpack content.
|
||||
/// Returns only user-added content (not part of the linked modpack).
|
||||
pub async fn get_content_items(
|
||||
profile: &Profile,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
|
||||
if linked_data.version_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Fetching modpack identifiers for version_id={}, project_id={}",
|
||||
linked_data.version_id,
|
||||
linked_data.project_id
|
||||
);
|
||||
match get_modpack_identifiers(
|
||||
&linked_data.version_id,
|
||||
profile,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => {
|
||||
tracing::info!(
|
||||
"Got {} modpack file hashes, {} project IDs for version {}",
|
||||
ids.hashes.len(),
|
||||
ids.project_ids.len(),
|
||||
linked_data.version_id
|
||||
);
|
||||
Some(ids)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to fetch modpack identifiers for version {}: {}",
|
||||
linked_data.version_id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
|
||||
{
|
||||
let filtered_files = profile
|
||||
.get_projects_excluding_modpack_files(
|
||||
&ids.hashes,
|
||||
&ids.project_ids,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
filtered_files.into_iter().collect()
|
||||
} else {
|
||||
let all_files = profile
|
||||
.get_projects(cache_behaviour, pool, fetch_semaphore)
|
||||
.await?;
|
||||
all_files.into_iter().collect()
|
||||
};
|
||||
|
||||
let content_items = profile_files_to_content_items(
|
||||
&profile.path,
|
||||
&user_files,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(content_items)
|
||||
}
|
||||
|
||||
/// Pre-fetched metadata for projects, versions, teams, and organizations.
|
||||
struct ResolvedMetadata {
|
||||
projects: Vec<Project>,
|
||||
versions: Vec<Version>,
|
||||
teams: Vec<Vec<TeamMember>>,
|
||||
organizations: Vec<Organization>,
|
||||
}
|
||||
|
||||
/// Fetch project, version, team, and organization metadata in parallel batches.
|
||||
async fn resolve_metadata(
|
||||
project_ids: &HashSet<String>,
|
||||
version_ids: &HashSet<String>,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<ResolvedMetadata> {
|
||||
let project_ids_vec: Vec<&str> =
|
||||
project_ids.iter().map(|s| s.as_str()).collect();
|
||||
let version_ids_vec: Vec<&str> =
|
||||
version_ids.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let (projects, versions) =
|
||||
if !project_ids.is_empty() || !version_ids.is_empty() {
|
||||
tokio::try_join!(
|
||||
async {
|
||||
if project_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
CachedEntry::get_project_many(
|
||||
&project_ids_vec,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
async {
|
||||
if version_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
CachedEntry::get_version_many(
|
||||
&version_ids_vec,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
)?
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
let team_ids: HashSet<String> =
|
||||
projects.iter().map(|p| p.team.clone()).collect();
|
||||
let org_ids: HashSet<String> = projects
|
||||
.iter()
|
||||
.filter_map(|p| p.organization.clone())
|
||||
.collect();
|
||||
|
||||
let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect();
|
||||
let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty()
|
||||
{
|
||||
tokio::try_join!(
|
||||
async {
|
||||
if team_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
CachedEntry::get_team_many(
|
||||
&team_ids_vec,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
async {
|
||||
if org_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
CachedEntry::get_organization_many(
|
||||
&org_ids_vec,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
)?
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(ResolvedMetadata {
|
||||
projects,
|
||||
versions,
|
||||
teams,
|
||||
organizations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared helper: convert profile files to ContentItems with rich metadata.
|
||||
/// Used by both `get_content_items` (user-added files) and
|
||||
/// `get_linked_modpack_content` (modpack-bundled files).
|
||||
async fn profile_files_to_content_items(
|
||||
profile_path: &str,
|
||||
files: &[(String, ProfileFile)],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let project_ids: HashSet<String> = files
|
||||
.iter()
|
||||
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone()))
|
||||
.collect();
|
||||
|
||||
let version_ids: HashSet<String> = files
|
||||
.iter()
|
||||
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone()))
|
||||
.collect();
|
||||
|
||||
let meta = resolve_metadata(
|
||||
&project_ids,
|
||||
&version_ids,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_base_path =
|
||||
crate::api::profile::get_full_path(profile_path).await?;
|
||||
|
||||
// Batch-read file modification times off the main async runtime
|
||||
let paths: Vec<std::path::PathBuf> = files
|
||||
.iter()
|
||||
.map(|(path, _)| profile_base_path.join(path))
|
||||
.collect();
|
||||
|
||||
let modification_times: Vec<Option<String>> =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok().map(
|
||||
|t| {
|
||||
chrono::DateTime::<chrono::Utc>::from(t)
|
||||
.to_rfc3339()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<ContentItem> = files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (path, file))| {
|
||||
let project = file.metadata.as_ref().and_then(|m| {
|
||||
meta.projects.iter().find(|p| p.id == m.project_id)
|
||||
});
|
||||
|
||||
let version = file.metadata.as_ref().and_then(|m| {
|
||||
meta.versions.iter().find(|v| v.id == m.version_id)
|
||||
});
|
||||
|
||||
let owner = project.and_then(|p| {
|
||||
resolve_owner(p, &meta.teams, &meta.organizations)
|
||||
});
|
||||
|
||||
ContentItem {
|
||||
file_name: file.file_name.clone(),
|
||||
file_path: path.clone(),
|
||||
id: file.hash.clone(),
|
||||
size: file.size,
|
||||
enabled: !file.file_name.ends_with(".disabled"),
|
||||
project_type: file.project_type,
|
||||
project: project.map(|p| ContentItemProject {
|
||||
id: p.id.clone(),
|
||||
slug: p.slug.clone(),
|
||||
title: p.title.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
}),
|
||||
version: version.map(|v| ContentItemVersion {
|
||||
id: v.id.clone(),
|
||||
version_number: v.version_number.clone(),
|
||||
file_name: file.file_name.clone(),
|
||||
date_published: Some(v.date_published.to_rfc3339()),
|
||||
}),
|
||||
owner,
|
||||
has_update: file.update_version_id.is_some(),
|
||||
update_version_id: file.update_version_id.clone(),
|
||||
date_added: modification_times[i].clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
let name_a = a
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|p| p.title.as_str())
|
||||
.unwrap_or(&a.file_name);
|
||||
let name_b = b
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|p| p.title.as_str())
|
||||
.unwrap_or(&b.file_name);
|
||||
name_a
|
||||
.to_lowercase()
|
||||
.cmp(&name_b.to_lowercase())
|
||||
.then_with(|| a.file_name.cmp(&b.file_name))
|
||||
});
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Resolve the owner of a project from pre-fetched teams and organizations.
|
||||
fn resolve_owner(
|
||||
project: &Project,
|
||||
teams: &[Vec<TeamMember>],
|
||||
organizations: &[Organization],
|
||||
) -> Option<ContentItemOwner> {
|
||||
if let Some(org_id) = &project.organization {
|
||||
organizations.iter().find(|o| &o.id == org_id).map(|o| {
|
||||
ContentItemOwner {
|
||||
id: o.id.clone(),
|
||||
name: o.name.clone(),
|
||||
avatar_url: o.icon_url.clone(),
|
||||
owner_type: OwnerType::Organization,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
teams
|
||||
.iter()
|
||||
.find(|t| t.first().is_some_and(|m| m.team_id == project.team))
|
||||
.and_then(|t| t.iter().find(|m| m.is_owner))
|
||||
.map(|m| ContentItemOwner {
|
||||
id: m.user.id.clone(),
|
||||
name: m.user.username.clone(),
|
||||
avatar_url: m.user.avatar_url.clone(),
|
||||
owner_type: OwnerType::User,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get content items that are part of the linked modpack (not user-added).
|
||||
/// Returns modpack-bundled files with full on-disk metadata (file_path, enabled, etc).
|
||||
/// Returns empty vec if the profile is not linked to a modpack.
|
||||
pub async fn get_linked_modpack_content(
|
||||
profile: &Profile,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let Some(linked_data) = &profile.linked_data else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let all_files = profile
|
||||
.get_projects(cache_behaviour, pool, fetch_semaphore)
|
||||
.await?;
|
||||
|
||||
let modpack_ids = match get_modpack_identifiers(
|
||||
&linked_data.version_id,
|
||||
profile,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch modpack identifiers: {}", e);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
// Inverse of get_content_items: keep only modpack-bundled files
|
||||
let modpack_files: Vec<(String, ProfileFile)> = all_files
|
||||
.into_iter()
|
||||
.filter(|(_, file)| modpack_ids.is_modpack_file(file))
|
||||
.collect();
|
||||
|
||||
profile_files_to_content_items(
|
||||
&profile.path,
|
||||
&modpack_files,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Convert a list of dependencies into ContentItems with rich metadata.
|
||||
/// Fetches project, version, and owner info for each dependency.
|
||||
pub async fn dependencies_to_content_items(
|
||||
dependencies: &[Dependency],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let project_ids: HashSet<String> = dependencies
|
||||
.iter()
|
||||
.filter_map(|d| d.project_id.clone())
|
||||
.collect();
|
||||
|
||||
if project_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let version_ids: HashSet<String> = dependencies
|
||||
.iter()
|
||||
.filter_map(|d| d.version_id.clone())
|
||||
.collect();
|
||||
|
||||
let meta = resolve_metadata(
|
||||
&project_ids,
|
||||
&version_ids,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<ContentItem> = dependencies
|
||||
.iter()
|
||||
.filter_map(|dep| {
|
||||
let project_id = dep.project_id.as_ref()?;
|
||||
let project = meta.projects.iter().find(|p| &p.id == project_id)?;
|
||||
|
||||
let version = dep
|
||||
.version_id
|
||||
.as_ref()
|
||||
.and_then(|vid| meta.versions.iter().find(|v| &v.id == vid));
|
||||
|
||||
let owner =
|
||||
resolve_owner(project, &meta.teams, &meta.organizations);
|
||||
|
||||
let project_type = match project.project_type.as_str() {
|
||||
"mod" => ProjectType::Mod,
|
||||
"resourcepack" => ProjectType::ResourcePack,
|
||||
"shader" => ProjectType::ShaderPack,
|
||||
"datapack" => ProjectType::DataPack,
|
||||
_ => ProjectType::Mod,
|
||||
};
|
||||
|
||||
Some(ContentItem {
|
||||
file_name: version
|
||||
.and_then(|v| v.files.first())
|
||||
.map(|f| f.filename.clone())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}.jar",
|
||||
project.slug.as_deref().unwrap_or(&project.id)
|
||||
)
|
||||
}),
|
||||
file_path: String::new(),
|
||||
id: String::new(),
|
||||
size: version
|
||||
.and_then(|v| v.files.first())
|
||||
.map(|f| f.size as u64)
|
||||
.unwrap_or(0),
|
||||
enabled: true,
|
||||
project_type,
|
||||
project: Some(ContentItemProject {
|
||||
id: project.id.clone(),
|
||||
slug: project.slug.clone(),
|
||||
title: project.title.clone(),
|
||||
icon_url: project.icon_url.clone(),
|
||||
}),
|
||||
version: version.map(|v| ContentItemVersion {
|
||||
id: v.id.clone(),
|
||||
version_number: v.version_number.clone(),
|
||||
file_name: v
|
||||
.files
|
||||
.first()
|
||||
.map(|f| f.filename.clone())
|
||||
.unwrap_or_default(),
|
||||
date_published: Some(v.date_published.to_rfc3339()),
|
||||
}),
|
||||
owner,
|
||||
has_update: false,
|
||||
update_version_id: None,
|
||||
date_added: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
let name_a = a
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|p| p.title.as_str())
|
||||
.unwrap_or(&a.file_name);
|
||||
let name_b = b
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|p| p.title.as_str())
|
||||
.unwrap_or(&b.file_name);
|
||||
name_a
|
||||
.to_lowercase()
|
||||
.cmp(&name_b.to_lowercase())
|
||||
.then_with(|| a.file_name.cmp(&b.file_name))
|
||||
});
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Modpack file identifiers: hashes for exact matching and project IDs for
|
||||
/// matching files whose version was switched by the user.
|
||||
struct ModpackIdentifiers {
|
||||
hashes: HashSet<String>,
|
||||
project_ids: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ModpackIdentifiers {
|
||||
fn is_modpack_file(&self, file: &ProfileFile) -> bool {
|
||||
self.hashes.contains(&file.hash)
|
||||
|| file
|
||||
.metadata
|
||||
.as_ref()
|
||||
.is_some_and(|m| self.project_ids.contains(&m.project_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets SHA1 hashes and project IDs of all files in a modpack version.
|
||||
/// Checks cache first, falls back to downloading mrpack if not cached.
|
||||
async fn get_modpack_identifiers(
|
||||
version_id: &str,
|
||||
profile: &crate::state::Profile,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<ModpackIdentifiers> {
|
||||
if let Some(cached) =
|
||||
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
|
||||
.await?
|
||||
{
|
||||
if !cached.project_ids.is_empty() {
|
||||
tracing::info!(
|
||||
"Cache hit: {} modpack file hashes, {} project IDs for version {}",
|
||||
cached.file_hashes.len(),
|
||||
cached.project_ids.len(),
|
||||
version_id
|
||||
);
|
||||
return Ok(ModpackIdentifiers {
|
||||
hashes: cached.file_hashes.into_iter().collect(),
|
||||
project_ids: cached.project_ids.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy cache entry without project_ids — resolve via hash lookup API
|
||||
tracing::info!(
|
||||
"Legacy cache entry without project IDs, resolving via API for version {}",
|
||||
version_id
|
||||
);
|
||||
let hash_refs: Vec<&str> =
|
||||
cached.file_hashes.iter().map(|s| s.as_str()).collect();
|
||||
let files =
|
||||
CachedEntry::get_file_many(&hash_refs, None, pool, fetch_semaphore)
|
||||
.await?;
|
||||
|
||||
let project_ids: Vec<String> = files
|
||||
.iter()
|
||||
.map(|f| f.project_id.clone())
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Update cache with project_ids for next time
|
||||
CachedEntry::cache_modpack_files(
|
||||
version_id,
|
||||
cached.file_hashes.clone(),
|
||||
project_ids.clone(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(ModpackIdentifiers {
|
||||
hashes: cached.file_hashes.into_iter().collect(),
|
||||
project_ids: project_ids.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
"Cache miss: modpack files not cached, downloading mrpack for version {}",
|
||||
version_id
|
||||
);
|
||||
|
||||
let version =
|
||||
CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Modpack version {version_id} not found"
|
||||
))
|
||||
})?;
|
||||
|
||||
let primary_file = version
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.primary)
|
||||
.or_else(|| version.files.first())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"No files found for modpack version {version_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let download_meta = DownloadMeta {
|
||||
reason: DownloadReason::Modpack,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: Some(version_id.to_string()),
|
||||
};
|
||||
|
||||
let mrpack_bytes = fetch_mirrors(
|
||||
&[&primary_file.url],
|
||||
primary_file.hashes.get("sha1").map(|s| s.as_str()),
|
||||
Some(&download_meta),
|
||||
None,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reader = Cursor::new(&mrpack_bytes);
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(reader).await.map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Failed to read modpack zip".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest_idx = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| {
|
||||
matches!(f.filename().as_str(), Ok("modrinth.index.json"))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No modrinth.index.json found in mrpack".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut manifest = String::new();
|
||||
let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?;
|
||||
entry_reader.read_to_string_checked(&mut manifest).await?;
|
||||
|
||||
let pack: PackFormat = serde_json::from_str(&manifest)?;
|
||||
|
||||
let mut hashes: Vec<String> = pack
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
|
||||
.collect();
|
||||
|
||||
let project_ids: Vec<String> = pack
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
f.downloads.iter().find_map(|url| {
|
||||
let parts: Vec<&str> = url.split('/').collect();
|
||||
let data_idx = parts.iter().position(|&p| p == "data")?;
|
||||
parts.get(data_idx + 1).map(|s| s.to_string())
|
||||
})
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Also hash files from overrides folders (these aren't in modrinth.index.json)
|
||||
let override_entries: Vec<usize> = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, entry)| {
|
||||
let filename = entry.filename().as_str().ok()?;
|
||||
let is_override = (filename.starts_with("overrides/")
|
||||
|| filename.starts_with("client-overrides/")
|
||||
|| filename.starts_with("server-overrides/"))
|
||||
&& !filename.ends_with('/');
|
||||
is_override.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for index in override_entries {
|
||||
let mut file_bytes = Vec::new();
|
||||
let mut entry_reader = zip_reader.reader_with_entry(index).await?;
|
||||
entry_reader.read_to_end_checked(&mut file_bytes).await?;
|
||||
|
||||
let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
|
||||
hashes.push(hash);
|
||||
}
|
||||
|
||||
CachedEntry::cache_modpack_files(
|
||||
version_id,
|
||||
hashes.clone(),
|
||||
project_ids.clone(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ModpackIdentifiers {
|
||||
hashes: hashes.into_iter().collect(),
|
||||
project_ids: project_ids.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
//! Instance-related modules for profile/instance management.
|
||||
|
||||
mod content;
|
||||
pub use self::content::*;
|
||||
|
||||
mod model;
|
||||
pub use self::model::*;
|
||||
|
||||
pub(crate) mod adapters;
|
||||
pub(crate) mod commands;
|
||||
pub use self::commands::{
|
||||
AppliedContentSetPatch, CreateInstance, EditInstance,
|
||||
InstanceLaunchOverridesPatch, InstanceMetadata,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
create_instance, edit_instance, get_instance, get_instances_metadata,
|
||||
list_instances, refresh_all_instances, remove_instance,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
dependencies_to_content_items, get_content_projects,
|
||||
get_installed_project_ids_for_instance, get_instance_install_candidates,
|
||||
get_linked_modpack_info, list_content, list_content_sets,
|
||||
list_linked_modpack_content, sync_content_files,
|
||||
};
|
||||
pub(crate) mod watcher;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::state::ProjectType;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ContentSourceKind, unknown_value};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentRequirement {
|
||||
Required,
|
||||
Optional,
|
||||
Unsupported,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ContentRequirement {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Optional => "optional",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"required" => Ok(Self::Required),
|
||||
"optional" => Ok(Self::Optional),
|
||||
"unsupported" => Ok(Self::Unsupported),
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
other => Err(unknown_value("content requirement", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentEntry {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub content_set_id: String,
|
||||
pub file_id: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub source_kind: ContentSourceKind,
|
||||
pub server_requirement: ContentRequirement,
|
||||
pub client_requirement: ContentRequirement,
|
||||
pub enabled: bool,
|
||||
pub added_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::state::ModLoader;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSourceKind {
|
||||
Local,
|
||||
ModrinthModpack,
|
||||
ServerProject,
|
||||
ModrinthHosting,
|
||||
ImportedModpack,
|
||||
SharedInstance,
|
||||
}
|
||||
|
||||
impl ContentSourceKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::ModrinthModpack => "modrinth_modpack",
|
||||
Self::ServerProject => "server_project",
|
||||
Self::ModrinthHosting => "modrinth_hosting",
|
||||
Self::ImportedModpack => "imported_modpack",
|
||||
Self::SharedInstance => "shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"local" => Ok(Self::Local),
|
||||
"modrinth_modpack" => Ok(Self::ModrinthModpack),
|
||||
"server_project" => Ok(Self::ServerProject),
|
||||
"modrinth_hosting" => Ok(Self::ModrinthHosting),
|
||||
"imported_modpack" => Ok(Self::ImportedModpack),
|
||||
"shared_instance" => Ok(Self::SharedInstance),
|
||||
other => Err(unknown_value("content source kind", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetStatus {
|
||||
Available,
|
||||
Installing,
|
||||
Stale,
|
||||
MissingFiles,
|
||||
}
|
||||
|
||||
impl ContentSetStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "available",
|
||||
Self::Installing => "installing",
|
||||
Self::Stale => "stale",
|
||||
Self::MissingFiles => "missing_files",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"available" => Ok(Self::Available),
|
||||
"installing" => Ok(Self::Installing),
|
||||
"stale" => Ok(Self::Stale),
|
||||
"missing_files" => Ok(Self::MissingFiles),
|
||||
other => Err(unknown_value("content set status", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a playable setup slot for an instance.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSet {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub source_kind: ContentSourceKind,
|
||||
pub status: ContentSetStatus,
|
||||
pub game_version: String,
|
||||
pub protocol_version: Option<u32>,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetRemoteRefType {
|
||||
SharedContentSet,
|
||||
HostingInstance,
|
||||
}
|
||||
|
||||
impl ContentSetRemoteRefType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SharedContentSet => "shared_content_set",
|
||||
Self::HostingInstance => "hosting_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"shared_content_set" => Ok(Self::SharedContentSet),
|
||||
"hosting_instance" => Ok(Self::HostingInstance),
|
||||
other => Err(unknown_value("content set remote ref type", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSetRemoteRef {
|
||||
pub content_set_id: String,
|
||||
pub ref_type: ContentSetRemoteRefType,
|
||||
pub ref_id: String,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetSyncProvider {
|
||||
SharedInstance,
|
||||
}
|
||||
|
||||
impl ContentSetSyncProvider {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SharedInstance => "shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"shared_instance" => Ok(Self::SharedInstance),
|
||||
other => Err(unknown_value("content set sync provider", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetSyncStatus {
|
||||
Unknown,
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Applying,
|
||||
Stale,
|
||||
NotReady,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ContentSetSyncStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::UpToDate => "up_to_date",
|
||||
Self::UpdateAvailable => "update_available",
|
||||
Self::Applying => "applying",
|
||||
Self::Stale => "stale",
|
||||
Self::NotReady => "not_ready",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
"up_to_date" => Ok(Self::UpToDate),
|
||||
"update_available" => Ok(Self::UpdateAvailable),
|
||||
"applying" => Ok(Self::Applying),
|
||||
"stale" => Ok(Self::Stale),
|
||||
"not_ready" => Ok(Self::NotReady),
|
||||
"error" => Ok(Self::Error),
|
||||
other => Err(unknown_value("content set sync status", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSetSyncState {
|
||||
pub content_set_id: String,
|
||||
pub provider: ContentSetSyncProvider,
|
||||
pub applied_update_id: Option<String>,
|
||||
pub latest_available_update_id: Option<String>,
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
pub status: ContentSetSyncStatus,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceFile {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub sha1: String,
|
||||
pub size: u64,
|
||||
pub missing: bool,
|
||||
pub added_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::state::ModLoader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceInstallTarget {
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InstanceInstallCandidate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub icon_path: Option<String>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub installed: bool,
|
||||
pub compatible: bool,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ReleaseChannel,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub applied_content_set_id: Option<String>,
|
||||
pub install_stage: InstanceInstallStage,
|
||||
pub launcher_feature_version: LauncherFeatureVersion,
|
||||
pub update_channel: ReleaseChannel,
|
||||
pub name: String,
|
||||
pub icon_path: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
pub submitted_time_played: u64,
|
||||
pub recent_time_played: u64,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::state::{
|
||||
ContentSet, Hooks, Instance, InstanceLink, MemorySettings, WindowSize,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
pub instance_id: String,
|
||||
pub java_path: Option<String>,
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
pub custom_env_vars: Option<Vec<(String, String)>>,
|
||||
pub memory: Option<MemorySettings>,
|
||||
pub force_fullscreen: Option<bool>,
|
||||
pub game_resolution: Option<WindowSize>,
|
||||
pub hooks: Hooks,
|
||||
}
|
||||
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
instance_id,
|
||||
java_path: None,
|
||||
extra_launch_args: None,
|
||||
custom_env_vars: None,
|
||||
memory: None,
|
||||
force_fullscreen: None,
|
||||
game_resolution: None,
|
||||
hooks: Hooks {
|
||||
pre_launch: None,
|
||||
wrapper: None,
|
||||
post_exit: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_env_vars: Option<Vec<(String, String)>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<MemorySettings>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub force_fullscreen: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub game_resolution: Option<WindowSize>,
|
||||
#[serde(default)]
|
||||
pub hooks: Hooks,
|
||||
}
|
||||
|
||||
impl InstanceLaunchOverridesData {
|
||||
pub(crate) fn into_launch_overrides(
|
||||
self,
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
extra_launch_args: self.extra_launch_args,
|
||||
custom_env_vars: self.custom_env_vars,
|
||||
memory: self.memory,
|
||||
force_fullscreen: self.force_fullscreen,
|
||||
game_resolution: self.game_resolution,
|
||||
hooks: self.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
custom_env_vars: overrides.custom_env_vars.clone(),
|
||||
memory: overrides.memory,
|
||||
force_fullscreen: overrides.force_fullscreen,
|
||||
game_resolution: overrides.game_resolution,
|
||||
hooks: overrides.hooks.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchContext {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceLink {
|
||||
Unmanaged,
|
||||
ModrinthModpack {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
},
|
||||
ServerProject {
|
||||
project_id: String,
|
||||
},
|
||||
/// A server project that points at a separate content project/version.
|
||||
ServerProjectModpack {
|
||||
server_project_id: String,
|
||||
content_project_id: String,
|
||||
content_version_id: String,
|
||||
},
|
||||
/// Hosting sync still flows through the shared-instance service.
|
||||
ModrinthHosting {
|
||||
server_id: Uuid,
|
||||
instance_ids: Vec<Uuid>,
|
||||
active_instance_id: Option<Uuid>,
|
||||
},
|
||||
/// A custom modpack source without a Modrinth project/version link.
|
||||
ImportedModpack {
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
name: Option<String>,
|
||||
version_number: Option<String>,
|
||||
filename: Option<String>,
|
||||
},
|
||||
SharedInstance {
|
||||
shared_instance_id: Uuid,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ContentEntry, InstanceFile};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceContentManifest {
|
||||
pub instance_id: String,
|
||||
pub content_set_id: String,
|
||||
pub entries: Vec<ContentEntry>,
|
||||
pub files: Vec<InstanceFile>,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod content_entry;
|
||||
pub use self::content_entry::*;
|
||||
|
||||
mod content_set;
|
||||
pub use self::content_set::*;
|
||||
|
||||
mod content_set_remote_ref;
|
||||
pub use self::content_set_remote_ref::*;
|
||||
|
||||
mod content_set_sync_state;
|
||||
pub use self::content_set_sync_state::*;
|
||||
|
||||
mod file;
|
||||
pub use self::file::*;
|
||||
|
||||
mod instance;
|
||||
pub use self::instance::*;
|
||||
|
||||
mod install_candidate;
|
||||
pub use self::install_candidate::*;
|
||||
|
||||
mod launch;
|
||||
pub use self::launch::*;
|
||||
|
||||
mod link;
|
||||
pub use self::link::*;
|
||||
|
||||
mod manifest;
|
||||
|
||||
mod update_check;
|
||||
pub use self::update_check::*;
|
||||
|
||||
fn unknown_value(kind: &str, value: &str) -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!("Unknown {kind} {value}")).into()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::state::ReleaseChannel;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentUpdateCheck {
|
||||
pub content_entry_id: String,
|
||||
pub update_channel: ReleaseChannel,
|
||||
pub update_version_id: Option<String>,
|
||||
pub checked_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use crate::State;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::{emit_instance, emit_warning};
|
||||
use crate::state::{
|
||||
DirectoryInfo, InstanceInstallStage, ProjectType, attached_world_data,
|
||||
};
|
||||
use crate::worlds::WorldType;
|
||||
use notify::{RecommendedWatcher, RecursiveMode};
|
||||
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{RwLock, mpsc::channel};
|
||||
|
||||
use super::adapters::sqlite::instance_rows;
|
||||
|
||||
pub type FileWatcher = RwLock<Debouncer<RecommendedWatcher>>;
|
||||
|
||||
pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
let (tx, mut rx) = channel(1);
|
||||
|
||||
let file_watcher = new_debouncer(
|
||||
Duration::from_secs_f32(1.0),
|
||||
move |res: DebounceEventResult| {
|
||||
tx.blocking_send(res).ok();
|
||||
},
|
||||
)?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
|
||||
tracing::info!(parent: &span, "Initing watcher");
|
||||
while let Some(res) = rx.recv().await {
|
||||
let _span = span.enter();
|
||||
|
||||
match res {
|
||||
Ok(events) => {
|
||||
let mut visited_instances = Vec::new();
|
||||
|
||||
events.iter().for_each(|e| {
|
||||
let mut instance_path = None;
|
||||
|
||||
let mut found = false;
|
||||
for component in e.path.components() {
|
||||
if found {
|
||||
instance_path = Some(component.as_os_str());
|
||||
break;
|
||||
}
|
||||
|
||||
if component.as_os_str()
|
||||
== crate::state::dirs::INSTANCES_FOLDER_NAME
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(instance_path) = instance_path {
|
||||
let instance_path_str =
|
||||
instance_path.to_string_lossy().to_string();
|
||||
let first_file_name = e
|
||||
.path
|
||||
.components()
|
||||
.skip_while(|x| x.as_os_str() != instance_path)
|
||||
.nth(1)
|
||||
.map(|x| x.as_os_str());
|
||||
if first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "crash-reports")
|
||||
&& e.path
|
||||
.extension()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "txt")
|
||||
{
|
||||
crash_task(instance_path_str);
|
||||
} else if !visited_instances.contains(&instance_path)
|
||||
{
|
||||
let event = if first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "servers.dat")
|
||||
{
|
||||
Some(InstancePayloadType::ServersUpdated)
|
||||
} else if first_file_name.as_ref().is_some_and(|x| {
|
||||
*x == "saves"
|
||||
&& e.path
|
||||
.file_name()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "level.dat")
|
||||
}) {
|
||||
tracing::info!(
|
||||
"World updated: {}",
|
||||
e.path.display()
|
||||
);
|
||||
let world = e
|
||||
.path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !e.path.is_file() {
|
||||
let instance_path_str = instance_path_str.clone();
|
||||
let world = world.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(state) = State::get().await {
|
||||
let instance_id = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT id
|
||||
FROM instances
|
||||
WHERE path = ?
|
||||
",
|
||||
instance_path_str,
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await;
|
||||
let Ok(Some(instance_id)) = instance_id else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
|
||||
&instance_id,
|
||||
WorldType::Singleplayer,
|
||||
&world,
|
||||
&state.pool
|
||||
).await {
|
||||
tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}")
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(InstancePayloadType::WorldUpdated { world })
|
||||
} else if first_file_name
|
||||
.as_ref()
|
||||
.is_none_or(|x| *x != "saves")
|
||||
{
|
||||
Some(InstancePayloadType::Synced)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = event {
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_instance(
|
||||
&instance_path_str,
|
||||
event,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
visited_instances.push(instance_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => tracing::warn!("Unable to watch file: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(RwLock::new(file_watcher))
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_instances_init(
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) {
|
||||
let Ok(mut instances_dir) = tokio::fs::read_dir(dirs.instances_dir()).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
while let Ok(Some(instance_dir)) = instances_dir.next_entry().await {
|
||||
let file_name = instance_dir.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
if file_name.starts_with(".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
|
||||
watch_instance_folder(&file_name, watcher, dirs).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_instance_folder(
|
||||
instance_path: &str,
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) {
|
||||
let instance_path = dirs.instances_dir().join(instance_path);
|
||||
|
||||
let Ok(metadata) = tokio::fs::metadata(&instance_path).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut to_watch = Vec::new();
|
||||
for sub_path in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = instance_path.join(sub_path);
|
||||
|
||||
let meta = tokio::fs::symlink_metadata(&full_path).await;
|
||||
let exists = meta.is_ok();
|
||||
let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink());
|
||||
|
||||
if !exists
|
||||
&& !is_symlink
|
||||
&& !sub_path.contains(".")
|
||||
&& let Err(e) = crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
to_watch.push(full_path);
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
for full_path in &to_watch {
|
||||
if let Err(e) =
|
||||
watcher.watcher().watch(full_path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&instance_path, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root instance directory for watcher {instance_path:?}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn crash_task(path: String) {
|
||||
tokio::task::spawn(async move {
|
||||
let res = async {
|
||||
let state = State::get().await?;
|
||||
let Some(instance) =
|
||||
instance_rows::get_instance_by_path(&path, &state.pool).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if instance.install_stage == InstanceInstallStage::Installed {
|
||||
emit_warning(&format!(
|
||||
"Instance {} has crashed! Visit the logs page to see a crash report.",
|
||||
instance.name
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("Unable to send crash report to frontend: {err}")
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,14 @@ use crate::data::{Dependency, ProjectType, User, Version};
|
||||
use crate::jre::check_jre;
|
||||
use crate::prelude::ModLoader;
|
||||
use crate::state;
|
||||
use crate::state::instances::{
|
||||
InstanceLaunchOverrides, InstanceLaunchOverridesData,
|
||||
};
|
||||
use crate::state::{
|
||||
CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate,
|
||||
Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey,
|
||||
DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData,
|
||||
MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage,
|
||||
DeviceTokenPair, FileType, Hooks, InstanceInstallStage,
|
||||
LauncherFeatureVersion, MemorySettings, ModrinthCredentials,
|
||||
ReleaseChannel, TeamMember, Theme, VersionFile, WindowSize,
|
||||
};
|
||||
use crate::util::fetch::{IoSemaphore, read_json};
|
||||
@@ -161,23 +164,25 @@ where
|
||||
|
||||
let mut cached_entries = vec![];
|
||||
|
||||
if let Ok(profiles_dir) = std::fs::read_dir(
|
||||
if let Ok(legacy_instances_dir) = std::fs::read_dir(
|
||||
legacy_settings
|
||||
.loaded_config_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| old_launcher_root.clone())
|
||||
.join("profiles"),
|
||||
) {
|
||||
for entry in profiles_dir.flatten() {
|
||||
for entry in legacy_instances_dir.flatten() {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let profile_path = entry.path().join("profile.json");
|
||||
let legacy_config_path = entry.path().join("profile.json");
|
||||
|
||||
let Ok(profile) =
|
||||
read_json::<LegacyProfile>(&profile_path, &io_semaphore)
|
||||
.await
|
||||
let Ok(profile) = read_json::<LegacyInstanceConfig>(
|
||||
&legacy_config_path,
|
||||
&io_semaphore,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -299,85 +304,72 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
Profile {
|
||||
path: profile.path,
|
||||
install_stage: match profile.install_stage {
|
||||
LegacyProfileInstallStage::Installed => {
|
||||
ProfileInstallStage::Installed
|
||||
}
|
||||
LegacyProfileInstallStage::Installing => {
|
||||
ProfileInstallStage::MinecraftInstalling
|
||||
}
|
||||
LegacyProfileInstallStage::PackInstalling => {
|
||||
ProfileInstallStage::PackInstalling
|
||||
}
|
||||
LegacyProfileInstallStage::NotInstalled => {
|
||||
ProfileInstallStage::NotInstalled
|
||||
}
|
||||
},
|
||||
launcher_feature_version: LauncherFeatureVersion::None,
|
||||
name: profile.metadata.name,
|
||||
icon_path: profile.metadata.icon,
|
||||
game_version: profile.metadata.game_version,
|
||||
protocol_version: None,
|
||||
loader: profile.metadata.loader.into(),
|
||||
loader_version: profile
|
||||
.metadata
|
||||
.loader_version
|
||||
.map(|x| x.id),
|
||||
groups: profile.metadata.groups,
|
||||
linked_data: profile.metadata.linked_data.and_then(|x| {
|
||||
if let Some(project_id) = x.project_id
|
||||
&& let Some(version_id) = x.version_id
|
||||
&& let Some(locked) = x.locked
|
||||
{
|
||||
return Some(LinkedData {
|
||||
project_id,
|
||||
version_id,
|
||||
locked,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}),
|
||||
preferred_update_channel: ReleaseChannel::Release,
|
||||
created: profile.metadata.date_created,
|
||||
modified: profile.metadata.date_modified,
|
||||
last_played: profile.metadata.last_played,
|
||||
submitted_time_played: profile
|
||||
.metadata
|
||||
.submitted_time_played,
|
||||
recent_time_played: profile.metadata.recent_time_played,
|
||||
java_path: profile.java.as_ref().and_then(|x| {
|
||||
x.override_version.clone().map(|x| x.path)
|
||||
}),
|
||||
extra_launch_args: profile
|
||||
.java
|
||||
.as_ref()
|
||||
.and_then(|x| x.extra_arguments.clone()),
|
||||
custom_env_vars: profile
|
||||
.java
|
||||
.and_then(|x| x.custom_env_args),
|
||||
memory: profile
|
||||
.memory
|
||||
.map(|x| MemorySettings { maximum: x.maximum }),
|
||||
force_fullscreen: profile.fullscreen,
|
||||
game_resolution: profile
|
||||
.resolution
|
||||
.map(|x| WindowSize(x.0, x.1)),
|
||||
hooks: Hooks {
|
||||
pre_launch: profile
|
||||
.hooks
|
||||
upsert_legacy_instance(
|
||||
exec,
|
||||
LegacyInstanceUpsert {
|
||||
path: profile.path,
|
||||
install_stage: match profile.install_stage {
|
||||
LegacyInstanceInstallStage::Installed => {
|
||||
InstanceInstallStage::Installed
|
||||
}
|
||||
LegacyInstanceInstallStage::Installing => {
|
||||
InstanceInstallStage::MinecraftInstalling
|
||||
}
|
||||
LegacyInstanceInstallStage::PackInstalling => {
|
||||
InstanceInstallStage::PackInstalling
|
||||
}
|
||||
LegacyInstanceInstallStage::NotInstalled => {
|
||||
InstanceInstallStage::NotInstalled
|
||||
}
|
||||
},
|
||||
launcher_feature_version: LauncherFeatureVersion::None,
|
||||
name: profile.metadata.name,
|
||||
icon_path: profile.metadata.icon,
|
||||
game_version: profile.metadata.game_version,
|
||||
loader: profile.metadata.loader.into(),
|
||||
loader_version: profile
|
||||
.metadata
|
||||
.loader_version
|
||||
.map(|x| x.id),
|
||||
groups: profile.metadata.groups,
|
||||
linked_data: profile.metadata.linked_data,
|
||||
created: profile.metadata.date_created,
|
||||
modified: profile.metadata.date_modified,
|
||||
last_played: profile.metadata.last_played,
|
||||
submitted_time_played: profile
|
||||
.metadata
|
||||
.submitted_time_played,
|
||||
recent_time_played: profile.metadata.recent_time_played,
|
||||
java_path: profile.java.as_ref().and_then(|x| {
|
||||
x.override_version.clone().map(|x| x.path)
|
||||
}),
|
||||
extra_launch_args: profile
|
||||
.java
|
||||
.as_ref()
|
||||
.and_then(|x| x.pre_launch.clone()),
|
||||
wrapper: profile
|
||||
.hooks
|
||||
.as_ref()
|
||||
.and_then(|x| x.wrapper.clone()),
|
||||
post_exit: profile.hooks.and_then(|x| x.post_exit),
|
||||
.and_then(|x| x.extra_arguments.clone()),
|
||||
custom_env_vars: profile
|
||||
.java
|
||||
.and_then(|x| x.custom_env_args),
|
||||
memory: profile
|
||||
.memory
|
||||
.map(|x| MemorySettings { maximum: x.maximum }),
|
||||
force_fullscreen: profile.fullscreen,
|
||||
game_resolution: profile
|
||||
.resolution
|
||||
.map(|x| WindowSize(x.0, x.1)),
|
||||
hooks: Hooks {
|
||||
pre_launch: profile
|
||||
.hooks
|
||||
.as_ref()
|
||||
.and_then(|x| x.pre_launch.clone()),
|
||||
wrapper: profile
|
||||
.hooks
|
||||
.as_ref()
|
||||
.and_then(|x| x.wrapper.clone()),
|
||||
post_exit: profile.hooks.and_then(|x| x.post_exit),
|
||||
},
|
||||
},
|
||||
}
|
||||
.upsert(exec)
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -403,6 +395,234 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LegacyInstanceUpsert {
|
||||
path: String,
|
||||
install_stage: InstanceInstallStage,
|
||||
launcher_feature_version: LauncherFeatureVersion,
|
||||
name: String,
|
||||
icon_path: Option<String>,
|
||||
game_version: String,
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
groups: Vec<String>,
|
||||
linked_data: Option<LegacyLinkedData>,
|
||||
created: DateTime<Utc>,
|
||||
modified: DateTime<Utc>,
|
||||
last_played: Option<DateTime<Utc>>,
|
||||
submitted_time_played: u64,
|
||||
recent_time_played: u64,
|
||||
java_path: Option<String>,
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
custom_env_vars: Option<Vec<(String, String)>>,
|
||||
memory: Option<MemorySettings>,
|
||||
force_fullscreen: Option<bool>,
|
||||
game_resolution: Option<WindowSize>,
|
||||
hooks: Hooks,
|
||||
}
|
||||
|
||||
async fn upsert_legacy_instance<'a, E>(
|
||||
exec: E,
|
||||
input: LegacyInstanceUpsert,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
||||
{
|
||||
let instance_id = format!("local:{}", Uuid::new_v4());
|
||||
let content_set_id = format!("content-set:{}", Uuid::new_v4());
|
||||
let instance_id_str = instance_id.as_str();
|
||||
let content_set_id_str = content_set_id.as_str();
|
||||
let path = input.path.as_str();
|
||||
let install_stage = input.install_stage.as_str();
|
||||
let launcher_feature_version = input.launcher_feature_version.as_str();
|
||||
let update_channel = ReleaseChannel::Release.key();
|
||||
let name = input.name.as_str();
|
||||
let icon_path = input.icon_path.as_deref();
|
||||
let game_version = input.game_version.as_str();
|
||||
let loader = input.loader.as_str();
|
||||
let loader_version = input.loader_version.as_deref();
|
||||
let created = input.created.timestamp();
|
||||
let modified = input.modified.timestamp();
|
||||
let last_played = input.last_played.map(|value| value.timestamp());
|
||||
let submitted_time_played = input.submitted_time_played as i64;
|
||||
let recent_time_played = input.recent_time_played as i64;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT OR REPLACE INTO instances (
|
||||
id,
|
||||
path,
|
||||
applied_content_set_id,
|
||||
install_stage,
|
||||
launcher_feature_version,
|
||||
update_channel,
|
||||
name,
|
||||
icon_path,
|
||||
created,
|
||||
modified,
|
||||
last_played,
|
||||
submitted_time_played,
|
||||
recent_time_played
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
",
|
||||
instance_id_str,
|
||||
path,
|
||||
content_set_id_str,
|
||||
install_stage,
|
||||
launcher_feature_version,
|
||||
update_channel,
|
||||
name,
|
||||
icon_path,
|
||||
created,
|
||||
modified,
|
||||
last_played,
|
||||
submitted_time_played,
|
||||
recent_time_played,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
let (
|
||||
source_kind,
|
||||
link_kind,
|
||||
modrinth_project_id,
|
||||
modrinth_version_id,
|
||||
server_project_id,
|
||||
) = match input.linked_data {
|
||||
Some(linked_data) => {
|
||||
match (linked_data.project_id, linked_data.version_id) {
|
||||
(Some(project_id), Some(version_id))
|
||||
if version_id.is_empty() =>
|
||||
{
|
||||
(
|
||||
"server_project",
|
||||
"server_project",
|
||||
None,
|
||||
None,
|
||||
Some(project_id),
|
||||
)
|
||||
}
|
||||
(Some(project_id), Some(version_id)) => (
|
||||
"modrinth_modpack",
|
||||
"modrinth_modpack",
|
||||
Some(project_id),
|
||||
Some(version_id),
|
||||
None,
|
||||
),
|
||||
_ => ("local", "unmanaged", None, None, None),
|
||||
}
|
||||
}
|
||||
None => ("local", "unmanaged", None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT OR REPLACE INTO instance_content_sets (
|
||||
id,
|
||||
instance_id,
|
||||
name,
|
||||
source_kind,
|
||||
status,
|
||||
game_version,
|
||||
protocol_version,
|
||||
loader,
|
||||
loader_version,
|
||||
created,
|
||||
modified
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
",
|
||||
content_set_id_str,
|
||||
instance_id_str,
|
||||
"Default",
|
||||
source_kind,
|
||||
"available",
|
||||
game_version,
|
||||
None::<i64>,
|
||||
loader,
|
||||
loader_version,
|
||||
created,
|
||||
modified,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT OR REPLACE INTO instance_links (
|
||||
instance_id,
|
||||
link_kind,
|
||||
modrinth_project_id,
|
||||
modrinth_version_id,
|
||||
server_project_id,
|
||||
content_project_id,
|
||||
content_version_id,
|
||||
hosting_server_id,
|
||||
hosting_instance_ids,
|
||||
hosting_active_instance_id,
|
||||
shared_instance_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?)
|
||||
",
|
||||
instance_id_str,
|
||||
link_kind,
|
||||
modrinth_project_id,
|
||||
modrinth_version_id,
|
||||
server_project_id,
|
||||
None::<&str>,
|
||||
None::<&str>,
|
||||
None::<&str>,
|
||||
"[]",
|
||||
None::<&str>,
|
||||
None::<&str>,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
for group in input.groups {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_groups (instance_id, group_name)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
instance_id_str,
|
||||
group,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
extra_launch_args: input.extra_launch_args,
|
||||
custom_env_vars: input.custom_env_vars,
|
||||
memory: input.memory,
|
||||
force_fullscreen: input.force_fullscreen,
|
||||
game_resolution: input.game_resolution,
|
||||
hooks: input.hooks,
|
||||
};
|
||||
let launch_overrides_data = serde_json::to_string(
|
||||
&InstanceLaunchOverridesData::from(&launch_overrides),
|
||||
)?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT OR REPLACE INTO instance_launch_overrides (
|
||||
instance_id,
|
||||
overrides
|
||||
)
|
||||
VALUES (?, jsonb(?))
|
||||
",
|
||||
instance_id_str,
|
||||
launch_overrides_data,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct LegacySettings {
|
||||
pub theme: LegacyTheme,
|
||||
@@ -534,12 +754,12 @@ struct LegacyDeviceToken {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct LegacyProfile {
|
||||
struct LegacyInstanceConfig {
|
||||
#[serde(default)]
|
||||
pub install_stage: LegacyProfileInstallStage,
|
||||
pub install_stage: LegacyInstanceInstallStage,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
pub metadata: LegacyProfileMetadata,
|
||||
pub metadata: LegacyInstanceMetadata,
|
||||
pub java: Option<LegacyJavaSettings>,
|
||||
pub memory: Option<LegacyMemorySettings>,
|
||||
pub resolution: Option<LegacyWindowSize>,
|
||||
@@ -737,7 +957,7 @@ enum LegacyFileType {
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct LegacyProfileMetadata {
|
||||
struct LegacyInstanceMetadata {
|
||||
pub name: String,
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -788,13 +1008,6 @@ impl From<LegacyModLoader> for ModLoader {
|
||||
struct LegacyLinkedData {
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
|
||||
#[serde(default = "default_locked")]
|
||||
pub locked: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_locked() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
@@ -811,7 +1024,7 @@ struct LegacyLoaderVersion {
|
||||
|
||||
#[derive(Deserialize, Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum LegacyProfileInstallStage {
|
||||
enum LegacyInstanceInstallStage {
|
||||
Installed,
|
||||
Installing,
|
||||
PackInstalling,
|
||||
|
||||
@@ -4,17 +4,17 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tokio::sync::{OnceCell, Semaphore};
|
||||
|
||||
use crate::state::fs_watcher::FileWatcher;
|
||||
use crate::state::instances::watcher::FileWatcher;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// Submodules
|
||||
mod dirs;
|
||||
pub use self::dirs::*;
|
||||
|
||||
mod profiles;
|
||||
pub use self::profiles::*;
|
||||
mod instance_types;
|
||||
pub use self::instance_types::*;
|
||||
|
||||
mod instances;
|
||||
pub(crate) mod instances;
|
||||
pub use self::instances::*;
|
||||
|
||||
mod settings;
|
||||
@@ -44,7 +44,7 @@ mod tunnel;
|
||||
pub use self::tunnel::*;
|
||||
|
||||
pub mod db;
|
||||
pub mod fs_watcher;
|
||||
pub(crate) mod db_backup;
|
||||
mod mr_auth;
|
||||
|
||||
pub use self::mr_auth::*;
|
||||
@@ -97,8 +97,14 @@ impl State {
|
||||
.get_or_try_init(move || Self::initialize_state(app_identifier))
|
||||
.await?;
|
||||
|
||||
if let Err(e) =
|
||||
crate::install::recovery::recover_interrupted_jobs(state).await
|
||||
{
|
||||
tracing::error!("Error recovering interrupted install jobs: {e}");
|
||||
}
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
fs_watcher::watch_profiles_init(
|
||||
instances::watcher::watch_instances_init(
|
||||
&state.file_watcher,
|
||||
&state.directories,
|
||||
)
|
||||
@@ -106,7 +112,7 @@ impl State {
|
||||
|
||||
let res = tokio::try_join!(
|
||||
state.discord_rpc.clear_to_default(true),
|
||||
Profile::refresh_all(),
|
||||
instances::refresh_all_instances(),
|
||||
Settings::migrate(&state.pool),
|
||||
ModrinthCredentials::refresh_all(),
|
||||
);
|
||||
@@ -187,7 +193,7 @@ impl State {
|
||||
let discord_rpc = DiscordGuard::init()?;
|
||||
|
||||
tracing::info!("Initializing file watcher");
|
||||
let file_watcher = fs_watcher::init_watcher().await?;
|
||||
let file_watcher = instances::watcher::init_watcher().await?;
|
||||
|
||||
let process_manager = ProcessManager::new();
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::event::emit::{emit_process, emit_profile};
|
||||
use crate::event::emit::{emit_instance, emit_process};
|
||||
use crate::event::{InstancePayloadType, ProcessPayloadType};
|
||||
#[cfg(feature = "tauri")]
|
||||
use crate::event::{LogEvent, LogPayload};
|
||||
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||
use crate::profile;
|
||||
use crate::util::io::IOError;
|
||||
use crate::util::rpc::RpcServer;
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
@@ -58,28 +57,28 @@ impl LogRingBuffer {
|
||||
static LOG_BUFFERS: LazyLock<DashMap<String, LogRingBuffer>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
|
||||
pub fn push_log_line(profile_path: &str, line: String) {
|
||||
pub fn push_log_line(instance_id: &str, line: String) {
|
||||
LOG_BUFFERS
|
||||
.entry(profile_path.to_string())
|
||||
.entry(instance_id.to_string())
|
||||
.or_insert_with(LogRingBuffer::new)
|
||||
.push(line);
|
||||
}
|
||||
|
||||
pub fn get_log_buffer(profile_path: &str) -> Vec<String> {
|
||||
pub fn get_log_buffer(instance_id: &str) -> Vec<String> {
|
||||
LOG_BUFFERS
|
||||
.get(profile_path)
|
||||
.get(instance_id)
|
||||
.map(|buf| buf.get_all())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear_log_buffer(profile_path: &str) {
|
||||
if let Some(mut buf) = LOG_BUFFERS.get_mut(profile_path) {
|
||||
pub fn clear_log_buffer(instance_id: &str) {
|
||||
if let Some(mut buf) = LOG_BUFFERS.get_mut(instance_id) {
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_log_buffer(profile_path: &str) {
|
||||
LOG_BUFFERS.remove(profile_path);
|
||||
pub fn remove_log_buffer(instance_id: &str) {
|
||||
LOG_BUFFERS.remove(instance_id);
|
||||
}
|
||||
|
||||
pub struct ProcessManager {
|
||||
@@ -102,7 +101,9 @@ impl ProcessManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn insert_new_process(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
instance_path: &str,
|
||||
instance_name: &str,
|
||||
mut mc_command: Command,
|
||||
post_exit_command: Option<String>,
|
||||
logs_folder: PathBuf,
|
||||
@@ -127,7 +128,9 @@ impl ProcessManager {
|
||||
metadata: ProcessMetadata {
|
||||
uuid: Uuid::new_v4(),
|
||||
start_time: Utc::now(),
|
||||
profile_path: profile_path.to_string(),
|
||||
instance_id: instance_id.to_string(),
|
||||
instance_path: instance_path.to_string(),
|
||||
instance_name: instance_name.to_string(),
|
||||
},
|
||||
child: mc_proc,
|
||||
rpc_server,
|
||||
@@ -152,7 +155,7 @@ impl ProcessManager {
|
||||
|
||||
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
|
||||
|
||||
clear_log_buffer(profile_path);
|
||||
clear_log_buffer(instance_id);
|
||||
|
||||
{
|
||||
let mut log_file = OpenOptions::new()
|
||||
@@ -170,7 +173,7 @@ impl ProcessManager {
|
||||
now.format("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
writeln!(log_file, "# Profile: {profile_path} \n")
|
||||
writeln!(log_file, "# Instance: {instance_path} \n")
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
}
|
||||
@@ -178,10 +181,12 @@ impl ProcessManager {
|
||||
if let Some(stdout) = stdout {
|
||||
let log_path_clone = log_path.clone();
|
||||
|
||||
let profile_path = metadata.profile_path.clone();
|
||||
let instance_id = metadata.instance_id.clone();
|
||||
let instance_path = metadata.instance_path.clone();
|
||||
tokio::spawn(async move {
|
||||
Process::process_output(
|
||||
&profile_path,
|
||||
&instance_id,
|
||||
&instance_path,
|
||||
stdout,
|
||||
log_path_clone,
|
||||
xml_logging,
|
||||
@@ -193,10 +198,12 @@ impl ProcessManager {
|
||||
if let Some(stderr) = stderr {
|
||||
let log_path_clone = log_path.clone();
|
||||
|
||||
let profile_path = metadata.profile_path.clone();
|
||||
let instance_id = metadata.instance_id.clone();
|
||||
let instance_path = metadata.instance_path.clone();
|
||||
tokio::spawn(async move {
|
||||
Process::process_output(
|
||||
&profile_path,
|
||||
&instance_id,
|
||||
&instance_path,
|
||||
stderr,
|
||||
log_path_clone,
|
||||
xml_logging,
|
||||
@@ -206,7 +213,8 @@ impl ProcessManager {
|
||||
}
|
||||
|
||||
tokio::spawn(Process::sequential_process_manager(
|
||||
profile_path.to_string(),
|
||||
instance_id.to_string(),
|
||||
instance_path.to_string(),
|
||||
post_exit_command,
|
||||
metadata.uuid,
|
||||
));
|
||||
@@ -214,7 +222,7 @@ impl ProcessManager {
|
||||
self.processes.insert(process.metadata.uuid, process);
|
||||
|
||||
emit_process(
|
||||
profile_path,
|
||||
instance_id,
|
||||
metadata.uuid,
|
||||
ProcessPayloadType::Launched,
|
||||
"Launched Minecraft",
|
||||
@@ -273,7 +281,9 @@ impl ProcessManager {
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ProcessMetadata {
|
||||
pub uuid: Uuid,
|
||||
pub profile_path: String,
|
||||
pub instance_id: String,
|
||||
pub instance_path: String,
|
||||
pub instance_name: String,
|
||||
pub start_time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -297,7 +307,8 @@ pub struct Log4jEvent {
|
||||
|
||||
impl Process {
|
||||
async fn process_output<R>(
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
_instance_path: &str,
|
||||
reader: R,
|
||||
log_path: impl AsRef<Path>,
|
||||
xml_logging: bool,
|
||||
@@ -423,7 +434,7 @@ impl Process {
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(
|
||||
profile_path,
|
||||
instance_id,
|
||||
¤t_event,
|
||||
);
|
||||
}
|
||||
@@ -458,16 +469,16 @@ impl Process {
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if let Err(e) = Self::maybe_handle_server_join_logging(
|
||||
profile_path,
|
||||
×tamp,
|
||||
message,
|
||||
instance_id,
|
||||
×tamp,
|
||||
message,
|
||||
).await {
|
||||
tracing::error!("Failed to handle server join logging: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(
|
||||
profile_path,
|
||||
instance_id,
|
||||
¤t_event,
|
||||
);
|
||||
}
|
||||
@@ -494,7 +505,7 @@ impl Process {
|
||||
e
|
||||
);
|
||||
}
|
||||
Self::emit_legacy_log(profile_path, &text);
|
||||
Self::emit_legacy_log(instance_id, &text);
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(e)) => {
|
||||
@@ -521,9 +532,9 @@ impl Process {
|
||||
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
|
||||
tracing::warn!("Failed to write to log file: {}", e);
|
||||
}
|
||||
Self::emit_legacy_log(profile_path, line.trim_ascii_end());
|
||||
Self::emit_legacy_log(instance_id, line.trim_ascii_end());
|
||||
if let Err(e) = Self::maybe_handle_old_server_join_logging(
|
||||
profile_path,
|
||||
instance_id,
|
||||
line.trim_ascii_end(),
|
||||
)
|
||||
.await
|
||||
@@ -580,13 +591,13 @@ impl Process {
|
||||
))
|
||||
}
|
||||
|
||||
fn emit_log4j_event(profile_path: &str, event: &Log4jEvent) {
|
||||
fn emit_log4j_event(instance_id: &str, event: &Log4jEvent) {
|
||||
if let Some(formatted) = Self::format_log4j_entry(event) {
|
||||
push_log_line(profile_path, formatted.trim_end().to_string());
|
||||
push_log_line(instance_id, formatted.trim_end().to_string());
|
||||
}
|
||||
if let Some(ref throwable) = event.throwable {
|
||||
for line in throwable.lines().filter(|l| !l.is_empty()) {
|
||||
push_log_line(profile_path, line.to_string());
|
||||
push_log_line(instance_id, line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +607,7 @@ impl Process {
|
||||
let _ = event_state.app.emit(
|
||||
"log",
|
||||
LogPayload {
|
||||
profile_path_id: profile_path.to_string(),
|
||||
instance_id: instance_id.to_string(),
|
||||
event: LogEvent::Log4j(event.clone()),
|
||||
},
|
||||
);
|
||||
@@ -604,12 +615,12 @@ impl Process {
|
||||
}
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
{
|
||||
let _ = (profile_path, event);
|
||||
let _ = (instance_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_legacy_log(profile_path: &str, message: &str) {
|
||||
push_log_line(profile_path, message.to_string());
|
||||
fn emit_legacy_log(instance_id: &str, message: &str) {
|
||||
push_log_line(instance_id, message.to_string());
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
@@ -617,7 +628,7 @@ impl Process {
|
||||
let _ = event_state.app.emit(
|
||||
"log",
|
||||
LogPayload {
|
||||
profile_path_id: profile_path.to_string(),
|
||||
instance_id: instance_id.to_string(),
|
||||
event: LogEvent::Legacy {
|
||||
message: message.to_string(),
|
||||
},
|
||||
@@ -627,7 +638,7 @@ impl Process {
|
||||
}
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
{
|
||||
let _ = (profile_path, message);
|
||||
let _ = (instance_id, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +654,7 @@ impl Process {
|
||||
}
|
||||
|
||||
async fn maybe_handle_server_join_logging(
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
timestamp: &str,
|
||||
message: &str,
|
||||
) -> crate::Result<()> {
|
||||
@@ -662,12 +673,12 @@ impl Process {
|
||||
)
|
||||
})
|
||||
})?;
|
||||
Self::parse_and_insert_server_join(profile_path, message, timestamp)
|
||||
Self::parse_and_insert_server_join(instance_id, message, timestamp)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn maybe_handle_old_server_join_logging(
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
line: &str,
|
||||
) -> crate::Result<()> {
|
||||
if let Some((timestamp, message)) = line.split_once(" [CLIENT] [INFO] ")
|
||||
@@ -678,16 +689,16 @@ impl Process {
|
||||
.map(|x| x.to_utc())
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now);
|
||||
Self::parse_and_insert_server_join(profile_path, message, timestamp)
|
||||
Self::parse_and_insert_server_join(instance_id, message, timestamp)
|
||||
.await
|
||||
} else {
|
||||
Self::parse_and_insert_server_join(profile_path, line, Utc::now())
|
||||
Self::parse_and_insert_server_join(instance_id, line, Utc::now())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_and_insert_server_join(
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
message: &str,
|
||||
timestamp: DateTime<Utc>,
|
||||
) -> crate::Result<()> {
|
||||
@@ -705,7 +716,7 @@ impl Process {
|
||||
|
||||
let state = crate::State::get().await?;
|
||||
crate::state::server_join_log::JoinLogEntry {
|
||||
profile_path: profile_path.to_owned(),
|
||||
instance_id: instance_id.to_owned(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
join_time: timestamp,
|
||||
@@ -713,12 +724,12 @@ impl Process {
|
||||
.upsert(&state.pool)
|
||||
.await?;
|
||||
{
|
||||
let profile_path = profile_path.to_owned();
|
||||
let instance_id = instance_id.to_owned();
|
||||
let host = host.to_owned();
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_profile(
|
||||
&profile_path,
|
||||
ProfilePayloadType::ServerJoined {
|
||||
let _ = emit_instance(
|
||||
&instance_id,
|
||||
InstancePayloadType::ServerJoined {
|
||||
host,
|
||||
port,
|
||||
timestamp,
|
||||
@@ -735,28 +746,42 @@ impl Process {
|
||||
// Also, as the process ends, it spawns the follow-up process if it exists
|
||||
// By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status
|
||||
async fn sequential_process_manager(
|
||||
profile_path: String,
|
||||
instance_id: String,
|
||||
instance_path: String,
|
||||
post_exit_command: Option<String>,
|
||||
uuid: Uuid,
|
||||
) -> crate::Result<()> {
|
||||
async fn update_playtime(
|
||||
last_updated_playtime: &mut DateTime<Utc>,
|
||||
profile_path: &str,
|
||||
instance_id: &str,
|
||||
force_update: bool,
|
||||
) {
|
||||
let diff = Utc::now()
|
||||
.signed_duration_since(*last_updated_playtime)
|
||||
.num_seconds();
|
||||
if diff >= 60 || force_update {
|
||||
if let Err(e) = profile::edit(profile_path, |prof| {
|
||||
prof.recent_time_played += diff as u64;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
let state = match crate::State::get().await {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to get state for playtime update on instance {}: {}",
|
||||
instance_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::state::instances::commands::add_instance_recent_playtime(
|
||||
instance_id,
|
||||
diff as u64,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to update playtime for profile {}: {}",
|
||||
&profile_path,
|
||||
"Failed to update playtime for instance {}: {}",
|
||||
instance_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -784,13 +809,13 @@ impl Process {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
// Auto-update playtime every minute
|
||||
update_playtime(&mut last_updated_playtime, &profile_path, false)
|
||||
update_playtime(&mut last_updated_playtime, &instance_id, false)
|
||||
.await;
|
||||
}
|
||||
|
||||
state.process_manager.remove(uuid);
|
||||
emit_process(
|
||||
&profile_path,
|
||||
&instance_id,
|
||||
uuid,
|
||||
ProcessPayloadType::Finished,
|
||||
"Exited process",
|
||||
@@ -798,23 +823,28 @@ impl Process {
|
||||
.await?;
|
||||
|
||||
// Now fully complete- update playtime one last time
|
||||
update_playtime(&mut last_updated_playtime, &profile_path, true).await;
|
||||
update_playtime(&mut last_updated_playtime, &instance_id, true).await;
|
||||
|
||||
// Publish play time update
|
||||
// Allow failure, it will be stored locally and sent next time
|
||||
// Sent in another thread as first call may take a couple seconds and hold up process ending
|
||||
let profile = profile_path.clone();
|
||||
let playtime_instance_id = instance_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = profile::try_update_playtime(&profile).await {
|
||||
if let Err(e) =
|
||||
crate::api::instance::try_update_playtime_by_instance_id(
|
||||
&playtime_instance_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to update playtime for profile {}: {}",
|
||||
profile,
|
||||
"Failed to update playtime for instance {}: {}",
|
||||
playtime_instance_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path);
|
||||
let logs_folder = state.directories.instance_logs_dir(&instance_path);
|
||||
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
|
||||
|
||||
if log_path.exists()
|
||||
@@ -855,7 +885,7 @@ impl Process {
|
||||
if let Some(command) = cmd.next() {
|
||||
let mut command = Command::new(command);
|
||||
command.args(cmd).current_dir(
|
||||
profile::get_full_path(&profile_path).await?,
|
||||
state.directories.instances_dir().join(&instance_path),
|
||||
);
|
||||
command.spawn().map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct JoinLogEntry {
|
||||
pub profile_path: String,
|
||||
pub instance_id: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub join_time: DateTime<Utc>,
|
||||
@@ -16,18 +16,21 @@ impl JoinLogEntry {
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let join_time = self.join_time.timestamp();
|
||||
let instance_id = self.instance_id.as_str();
|
||||
let host = self.host.as_str();
|
||||
let port = i64::from(self.port);
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO join_log (profile_path, host, port, join_time)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (profile_path, host, port) DO UPDATE SET
|
||||
join_time = $4
|
||||
",
|
||||
self.profile_path,
|
||||
self.host,
|
||||
self.port,
|
||||
join_time
|
||||
INSERT INTO join_log (instance_id, host, port, join_time)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, host, port) DO UPDATE SET
|
||||
join_time = excluded.join_time
|
||||
",
|
||||
instance_id,
|
||||
host,
|
||||
port,
|
||||
join_time,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
@@ -37,26 +40,26 @@ impl JoinLogEntry {
|
||||
}
|
||||
|
||||
pub async fn get_joins(
|
||||
instance: &str,
|
||||
instance_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<HashMap<(String, u16), DateTime<Utc>>> {
|
||||
let joins = sqlx::query!(
|
||||
"
|
||||
SELECT profile_path, host, port, join_time
|
||||
FROM join_log
|
||||
WHERE profile_path = $1
|
||||
",
|
||||
instance
|
||||
SELECT host, port, join_time
|
||||
FROM join_log
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(joins
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
.map(|row| {
|
||||
(
|
||||
(x.host, x.port as u16),
|
||||
Utc.timestamp_opt(x.join_time, 0)
|
||||
(row.host, row.port as u16),
|
||||
Utc.timestamp_opt(row.join_time, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now),
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ pub enum FeatureFlag {
|
||||
ServerProjectQa,
|
||||
I18nDebug,
|
||||
ShowInstancePlayTime,
|
||||
SkipNonEssentialWarnings,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -361,7 +362,7 @@ pub struct MemorySettings {
|
||||
pub struct WindowSize(pub u16, pub u16);
|
||||
|
||||
/// Game initialization hooks
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[serde_with::serde_as]
|
||||
pub struct Hooks {
|
||||
#[serde_as(as = "serde_with::NoneAsEmptyString")]
|
||||
|
||||
Reference in New Issue
Block a user