diff --git a/Cargo.lock b/Cargo.lock index 176b72b8..c5d39e78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5114,6 +5114,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 47f604a0..90ee9b1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ sysinfo = {version = "=0.39.5"} trash = {version = "=5.2.6"} url = {version = "=2.5.8", features = ["serde"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]} +windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]} # util (codegen/macro) anyhow = {version = "=1.0.102"} diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 1b7e09a9..8379189a 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use crate::hooks::PumpSignal; use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; -use crate::state::{AppChannel, AppState, LoginProgress}; +use crate::state::{AppChannel, AppState, LoginProgress, StorageScanProgress}; /// Quiet period before log lines are written without it every line wakes the log view const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); @@ -127,6 +127,7 @@ impl EventPump { let mut logs: Vec<(i64, String)> = Vec::new(); let mut failed: Option<(i64, String)> = None; let mut login: Option> = None; + let mut storage_scan: Option> = None; let mut sync_complete = false; for event in batch { @@ -148,7 +149,6 @@ impl EventPump { cluster_id, message, }) => failed = Some((cluster_id, message)), - // Lifted out so it never reaches the engine the sign-in modal renders it inline Event::Progress(ProgressEvent::Update { id, ref label, @@ -161,6 +161,18 @@ impl EventPump { total, })); } + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => { + storage_scan = Some((current < total).then(|| StorageScanProgress { + label: label.clone(), + current, + total, + })); + } other => engine_events.push(other), } } @@ -197,6 +209,12 @@ impl EventPump { .microsoft_login = progress; } + if let Some(progress) = storage_scan { + self.station + .write_channel(AppChannel::StorageScan) + .storage_scan = progress; + } + if !engine_events.is_empty() { folded.touched_engine = true; let mut guard = self.station.write_channel(AppChannel::Notifications); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index ec3da18a..abaf6e42 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -58,6 +58,7 @@ pub use queries::{ use crate::notifications::NotificationSnapshot; use crate::state::{ AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, + StorageScanProgress, }; use freya::prelude::*; use freya::radio::use_radio; @@ -107,6 +108,13 @@ pub fn use_installs_snapshot() -> InstallState { use_radio(AppChannel::Installs).read().installs.clone() } +pub fn use_storage_scan_progress() -> Option { + use_radio(AppChannel::StorageScan) + .read() + .storage_scan + .clone() +} + pub fn use_microsoft_login_status() -> Option { use_radio(AppChannel::MicrosoftLogin) .read() diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 25f793cf..4a083533 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -397,9 +397,11 @@ impl NotificationState { ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - // The sign-in modal renders this progress itself it must not also become a toast + Event::Progress(ProgressEvent::Update { id, .. }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => {} Event::Progress(ProgressEvent::Update { id, label, diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index eae4ac29..a73eb0a5 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -21,6 +21,7 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + StorageScan, } impl RadioChannel for AppChannel {} @@ -40,6 +41,7 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub storage_scan: Option, } /// In-flight installs so the button that started one stays disabled until it lands @@ -103,6 +105,13 @@ pub struct LoginProgress { pub total: u64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorageScanProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct GameState { pub stages: HashMap, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 22587ce6..0ebc1fd0 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -441,6 +441,11 @@ impl Component for PackageManager { cluster_id, package_type, )) + .maybe_child( + content_type + .is_global() + .then(|| views::global_notice(noun_plural)), + ) .maybe_child(session_live.then(|| views::running_notice(noun_plural))) .child(ContentBox::new( filtered, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 056c9fe2..79c96935 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -213,8 +213,19 @@ pub(super) fn toolbar_bar( .into_element() } -/// Enabling still stores and applies at the next launch but the running session cannot pick it up pub(super) fn running_notice(noun_plural: &'static str) -> Element { + notice_bar(format!( + "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." + )) +} + +pub(super) fn global_notice(noun_plural: &'static str) -> Element { + notice_bar(format!( + "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." + )) +} + +fn notice_bar(text: String) -> Element { rect() .horizontal() .width(Size::fill()) @@ -233,9 +244,7 @@ pub(super) fn running_notice(noun_plural: &'static str) -> Element { ) .child( label() - .text(format!( - "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." - )) + .text(text) .font_size(12.) .width(Size::flex(1.0)) .color(colors::fg_secondary()), diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index e3883dc2..bd617045 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -5,7 +5,9 @@ use super::{section_header, settings_page}; use crate::components::{Button, Icon, IconType, open_folder_button}; use crate::hooks::{ StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, + use_storage_scan_progress, }; +use crate::state::StorageScanProgress; use crate::theme::colors; /// Rows narrower than this would render as a sliver so they get a floor @@ -18,10 +20,11 @@ impl Component for SettingsStorage { fn render(&self) -> impl IntoElement { // Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order let report_query = use_storage_report(); + let scan = use_storage_scan_progress(); let Some(report) = try_storage_report(&report_query) else { return settings_page() - .child(hero_placeholder()) + .child(scan_card(scan.as_ref())) .into_element(); }; @@ -33,8 +36,13 @@ impl Component for SettingsStorage { }) .child(label().text("Refresh")); - let mut page = settings_page() - .child(hero(&report, refresh.into_element())) + let mut page = settings_page().child(hero(&report, refresh.into_element())); + + if scan.is_some() { + page = page.child(scan_card(scan.as_ref())); + } + + page = page .child(section_header("FREE UP SPACE")) .child( ReclaimRow { @@ -130,18 +138,47 @@ fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { .into_element() } -fn hero_placeholder() -> impl IntoElement { - rect() +// the first-load placeholder and the strip shown while a refresh rescans +fn scan_card(scan: Option<&StorageScanProgress>) -> impl IntoElement { + let counting = scan.filter(|scan| scan.total > 0); + let fraction = counting.map_or(0.0, |scan| scan.current as f32 / scan.total as f32); + + let mut header = rect() + .horizontal() .width(Size::fill()) - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) .child( + rect().width(Size::flex(1.0)).child( + label() + .text(scan.map_or_else( + || "Measuring disk usage…".to_string(), + |scan| format!("{}…", scan.label), + )) + .font_size(14.) + .color(colors::fg_secondary()), + ), + ); + + if let Some(scan) = counting { + header = header.child( label() - .text("Measuring disk usage…") - .font_size(16.) + .text(format!("{} / {}", scan.current, scan.total)) + .font_size(12.) .color(colors::fg_secondary()), - ) + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_symmetric(16., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(header) + .child(proportion_bar(fraction, colors::brand())) .into_element() } diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 4c6111bc..60a89fa7 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -15,6 +15,23 @@ use crate::stage::ClusterStage; pub use oneclient_common::paths::DEDICATED_MARKER; +// takes a cluster out of the shared `mods` folder +pub async fn remove_mods_link(folder_name: &str) { + let Ok(link) = paths::shared_mods_link(folder_name) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&link).await { + tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); + } + } + + _ => {} + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { pub id: ClusterId, diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 825353fb..904113bd 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -13,7 +13,7 @@ pub mod logs; pub mod profiles; pub mod screenshots; -pub use cluster::{Cluster, ClusterLinkTarget}; +pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; pub use manager::ClusterManager; pub use options::{ClusterUpdate, CreateClusterOptions}; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 9b77379d..70929201 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -13,7 +13,7 @@ use crate::error::ClusterResult; use oneclient_db::DbPool; use tokio::sync::Mutex; -use crate::cluster::Cluster; +use crate::cluster::{Cluster, remove_mods_link}; use crate::error::ClusterError; use crate::options::{ClusterUpdate, CreateClusterOptions}; use crate::stage::ClusterStage; @@ -160,6 +160,8 @@ impl ClusterManager { return Err(ClusterError::NotFound(cluster_id)); } + remove_mods_link(&cluster.folder_name).await; + if remove_files { let path = cluster.dir()?; if path.exists() { diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index 045fe085..ee67f33a 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -56,6 +56,12 @@ impl ContentType { } } + // installed once for the whole launcher rather than per cluster + #[must_use] + pub const fn is_global(self) -> bool { + matches!(self, Self::ResourcePack | Self::Shader) + } + pub fn from_folder_name(name: &str) -> Option { match name.to_lowercase().as_str() { "mods" | "mod" => Some(Self::Mod), diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 143b81f6..a38b5ab2 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -90,6 +90,22 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult { } } +pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { + Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) +} + +pub fn shared_mods_dir() -> PathsResult { + Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) +} + +pub fn global_content_dir(content_type: ContentType) -> PathsResult { + Ok(shared_minecraft_dir()?.join(content_type.folder_name())) +} + +pub fn shared_mods_link(folder_name: &str) -> PathsResult { + Ok(shared_mods_dir()?.join(folder_name)) +} + pub fn packages_cache_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("packages")) } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 8fac85da..a366c442 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -1,5 +1,6 @@ use futures_util::StreamExt; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; @@ -624,12 +625,45 @@ pub async fn reconcile_duplicate_activity( Ok(()) } +// which clusters have to record what the user just did +async fn override_scope( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(ContentType::is_global); + + if !global { + return Ok(vec![cluster_id]); + } + + let mut ids: Vec = cluster_dao::list_all(&ctx.db) + .await? + .into_iter() + .map(|row| row.id) + .collect(); + + if !ids.contains(&cluster_id) { + ids.push(cluster_id); + } + + Ok(ids) +} + +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn on_user_disable_artifact( cluster_id: i64, hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await + for id in override_scope(cluster_id, hash, ctx).await? { + handle_user_artifact_action(id, hash, ctx, OverrideType::Disabled).await?; + } + + Ok(()) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -638,10 +672,13 @@ pub async fn on_user_enable_artifact( hash: &str, ctx: &ContentCtx, ) -> ContentResult<()> { - if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? - && let Some(package_id) = tracked.package_id { - clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; - } + for id in override_scope(cluster_id, hash, ctx).await? { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await? + && let Some(package_id) = tracked.package_id { + clear_suppressing_overrides(id, &package_id, ctx).await?; + } + } + Ok(()) } diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 045d3595..573c68b2 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -34,31 +34,53 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> { Ok(()) } -/// Best-effort only the folder is reconciled at the next launch regardless -/// A running game holds its jars open which on Windows blocks deletion so failure here is expected +fn materialized_root( + cluster: &ClusterRow, + content_type: ContentType, +) -> Option<(std::path::PathBuf, &'static str)> { + if content_type.is_global() { + return paths::shared_minecraft_dir() + .ok() + .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); + } + + if content_type == ContentType::Mod { + return paths::cluster_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MODS_MANIFEST_NAME)); + } + + paths::cluster_game_dir(&cluster.folder_name) + .ok() + .map(|dir| (dir, manifest::MANIFEST_NAME)) +} + #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( cluster: &ClusterRow, content_type: ContentType, file_name: &str, ) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + let Some((root, manifest_name)) = materialized_root(cluster, content_type) else { return false; }; - let Some(mut loaded) = manifest::load(&game_dir).await else { + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { return false; }; - // The shared game dir belongs to whichever cluster played last - // touching a file we did not put there would delete another cluster's or - // the user's content let relative = manifest::entry_path(content_type.folder_name(), file_name); - if !loaded.owns(cluster.id, &relative) { + let ours = if content_type.is_global() { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; + + if !ours { return false; } - let path = game_dir.join(content_type.folder_name()).join(file_name); + let path = root.join(&relative); if let Err(err) = remove_entry(&path).await { tracing::debug!( file = file_name, @@ -69,7 +91,7 @@ pub async fn try_unlink_materialized( } loaded.entries.retain(|entry| entry.path != relative); - manifest::save(&game_dir, &loaded).await; + manifest::save(&root, manifest_name, &loaded).await; true } @@ -78,6 +100,7 @@ pub async fn try_unlink_materialized( mod tests { use super::*; + #[cfg(not(windows))] #[tokio::test] async fn remove_entry_clears_a_dangling_link() { let root = polyio::testing::ScratchDir::new("dangling_link"); @@ -102,6 +125,27 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + #[cfg(windows)] + #[tokio::test] + async fn remove_entry_clears_a_hard_link() { + let root = polyio::testing::ScratchDir::new("hard_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); + + assert!(link.exists(), "the link is still holding the file"); + + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + #[tokio::test] async fn remove_entry_is_fine_with_nothing_there() { let root = polyio::testing::ScratchDir::new("remove_missing"); diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index 34d5ee59..f1d6279b 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; +pub const MODS_MANIFEST_NAME: &str = ".oneclient-mods.json"; +pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json"; const MANIFEST_VERSION: u32 = 1; @@ -50,14 +52,14 @@ impl MaterializedManifest { } #[must_use] -pub fn manifest_path(game_dir: &Path) -> PathBuf { - game_dir.join(MANIFEST_NAME) +pub fn manifest_path(dir: &Path, name: &str) -> PathBuf { + dir.join(name) } /// An unparseable manifest reads as `None` not an error /// stashing links as user content is recoverable refusing to launch is not -pub async fn load(game_dir: &Path) -> Option { - let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; +pub async fn load(dir: &Path, name: &str) -> Option { + let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?; match serde_json::from_str::(&raw) { Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), @@ -75,7 +77,7 @@ pub async fn load(game_dir: &Path) -> Option { } } -pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { +pub async fn save(dir: &Path, name: &str, manifest: &MaterializedManifest) { let body = match serde_json::to_vec_pretty(manifest) { Ok(body) => body, Err(err) => { @@ -84,13 +86,13 @@ pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { } }; - if let Err(err) = polyio::write(manifest_path(game_dir), body).await { + if let Err(err) = polyio::write(manifest_path(dir, name), body).await { tracing::warn!(error = %err, "failed to write materialized manifest"); } } -pub async fn clear(game_dir: &Path) { - polyio::remove_file(manifest_path(game_dir)).await.ok(); +pub async fn clear(dir: &Path, name: &str) { + polyio::remove_file(manifest_path(dir, name)).await.ok(); } #[must_use] @@ -98,6 +100,11 @@ pub fn entry_path(content_folder: &str, file_name: &str) -> String { format!("{content_folder}/{file_name}") } +// whether this cluster keeps its mods in its own folder rather than in the game directory +pub async fn mods_live_in_cluster(cluster_dir: &Path) -> bool { + load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -127,15 +134,40 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - assert!(load(dir).await.is_none(), "no manifest yet"); + assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); - save(dir, &manifest()).await; - let loaded = load(dir).await.expect("manifest should load"); + save(dir, MANIFEST_NAME, &manifest()).await; + let loaded = load(dir, MANIFEST_NAME).await.expect("manifest should load"); assert_eq!(loaded.cluster_id, 7); assert!(loaded.contains("mods/sodium.jar")); - clear(dir).await; - assert!(load(dir).await.is_none(), "cleared manifest should be gone"); + clear(dir, MANIFEST_NAME).await; + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "cleared manifest should be gone" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn the_two_manifests_do_not_collide() { + let root = polyio::testing::ScratchDir::new("manifest_two_names"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + save(dir, MODS_MANIFEST_NAME, &manifest()).await; + + assert!( + load(dir, MODS_MANIFEST_NAME).await.is_some(), + "the mods manifest is there" + ); + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "and it is not mistaken for the game-dir one" + ); + + clear(dir, MODS_MANIFEST_NAME).await; std::fs::remove_dir_all(root.path()).ok(); } @@ -145,11 +177,11 @@ mod tests { let root = polyio::testing::ScratchDir::new("manifest_garbage"); let dir = root.path(); polyio::create_dir_all(dir).await.unwrap(); - polyio::write(manifest_path(dir), b"not json".as_slice()) + polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) .await .unwrap(); - assert!(load(dir).await.is_none()); + assert!(load(dir, MANIFEST_NAME).await.is_none()); std::fs::remove_dir_all(root.path()).ok(); } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 5362f5b7..bb321a68 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -239,6 +239,10 @@ impl PackageStore { ) .await?; + if content_type.is_global() { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, i64::from(enabled)).await?; + } + if !enabled { link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; if link.cluster_file_name != file_name { diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 297b1462..e93bcf38 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -10,12 +10,16 @@ use oneclient_content::packages::store::manifest::{ }; use oneclient_content::packages::store::artifact_absolute_path; -const SWEPT_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const SWEPT_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; +const SWEPT_TYPES_REDIRECTED: [ContentType; 1] = [ContentType::DataPack]; + +async fn swept_types(cluster_root: &Path) -> &'static [ContentType] { + if manifest::mods_live_in_cluster(cluster_root).await { + &SWEPT_TYPES_REDIRECTED + } else { + &SWEPT_TYPES + } +} #[derive(Debug, Default, Clone, Copy)] pub struct SweepReport { @@ -24,8 +28,6 @@ pub struct SweepReport { pub skipped: usize, } -/// User-triggered only the "hash matches a cached artifact so it is ours" rule -/// is true during the transition and false once cluster folders hold user content #[tracing::instrument(skip(state))] pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { let mut report = SweepReport::default(); @@ -36,10 +38,12 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes continue; }; + let swept = swept_types(&cluster_root).await; + if dedicated { - adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; } else { - sweep_shared(state, &cluster_root, &mut report).await; + sweep_shared(state, &cluster_root, swept, &mut report).await; } } @@ -55,8 +59,13 @@ pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherRes Ok(report) } -async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { - for content_type in SWEPT_TYPES { +async fn sweep_shared( + state: &LauncherState, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, +) { + for content_type in swept { let dir = cluster_root.join(content_type.folder_name()); let Ok(mut entries) = polyio::read_dir(&dir).await else { continue; @@ -105,9 +114,13 @@ async fn adopt_dedicated( state: &LauncherState, cluster: &crate::clusters::Cluster, cluster_root: &Path, + swept: &[ContentType], report: &mut SweepReport, ) { - if manifest::load(cluster_root).await.is_some() { + if manifest::load(cluster_root, manifest::MANIFEST_NAME) + .await + .is_some() + { return; } @@ -126,7 +139,7 @@ async fn adopt_dedicated( let mut entries = Vec::new(); for link in linked { - if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + if !link.enabled || !swept.contains(&link.content_type) { continue; } @@ -146,7 +159,12 @@ async fn adopt_dedicated( } report.adopted += entries.len(); - manifest::save(cluster_root, &MaterializedManifest::new(cluster.id, entries)).await; + manifest::save( + cluster_root, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; } enum CacheMatch { diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs new file mode 100644 index 00000000..325dd724 --- /dev/null +++ b/packages/oneclient_core/src/game/fabric.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use oneclient_common::domain::GameLoader; + +const MODS_FOLDER_PROPERTY: &str = "fabric.modsFolder"; + +// version 0.15.0 is required for fabric.modsFolder to work +const MIN_LOADER_VERSION: Version = Version(0, 15, 0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Version(u32, u32, u32); + +fn parse_version(raw: &str) -> Option { + let core = raw.trim().split(['+', '-']).next()?; + let mut parts = core.split('.'); + + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + + Some(Version(major, minor, patch)) +} + +#[must_use] +pub fn uses_cluster_mods_folder( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, +) -> bool { + if loader != GameLoader::Fabric { + return false; + } + + if custom_args.contains(MODS_FOLDER_PROPERTY) { + tracing::info!("launch args already set {MODS_FOLDER_PROPERTY}; leaving the layout alone"); + return false; + } + + parse_version(loader_version.unwrap_or_default()).is_some_and(|v| v >= MIN_LOADER_VERSION) +} + +#[must_use] +pub fn mods_folder_argument( + loader: GameLoader, + loader_version: Option<&str>, + custom_args: &str, + mods_dir: &Path, +) -> Option { + if !uses_cluster_mods_folder(loader, loader_version, custom_args) { + return None; + } + + Some(format!("-D{MODS_FOLDER_PROPERTY}={}", mods_dir.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_compare_by_component_not_by_string() { + assert!(parse_version("0.9.0") < parse_version("0.15.0")); + assert!(parse_version("0.16.5") > parse_version("0.15.0")); + assert_eq!(parse_version("0.15"), Some(Version(0, 15, 0))); + } + + #[test] + fn build_metadata_is_ignored() { + assert_eq!(parse_version("0.16.0+build.1"), Some(Version(0, 16, 0))); + assert_eq!(parse_version("0.16.0-rc.1"), Some(Version(0, 16, 0))); + } + + #[test] + fn only_fabric_and_only_new_enough() { + assert!(uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.16.5"), + "" + )); + assert!(!uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.11.0"), + "" + )); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, None, "")); + + for loader in [ + GameLoader::Vanilla, + GameLoader::Forge, + GameLoader::NeoForge, + GameLoader::Quilt, + GameLoader::LegacyFabric, + ] { + assert!(!uses_cluster_mods_folder(loader, Some("0.16.5"), "")); + } + } + + #[test] + fn a_user_set_property_takes_the_whole_layout_with_it() { + let dir = Path::new("/clusters/one/mods"); + let mine = "-Dfabric.modsFolder=/elsewhere"; + + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), "-Xmx4G", dir).is_some()); + + assert!(!uses_cluster_mods_folder(GameLoader::Fabric, Some("0.16.5"), mine)); + assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), mine, dir).is_none()); + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 0d42e1fd..001b5d8b 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -223,15 +223,22 @@ pub async fn launch_cluster( tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); } - // The one moment nothing holds the content open so this is where a package - // removed or disabled mid-session actually leaves the folder - if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + let custom_args = profile.launch_args.clone().unwrap_or_default(); + let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str()); + + let mods_in_cluster = crate::game::uses_cluster_mods_folder( + cluster.mc_loader, + loader_version_id, + &custom_args, + ); + + if let Err(err) = + crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await + { tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } if !dedicated { - // Redirects the shared dir's `logs`/`crash-reports` into this cluster's - // folder so output is attributable unlinked on exit crate::game::link_cluster_logs(&cluster, &cwd).await; } @@ -252,7 +259,7 @@ pub async fn launch_cluster( updated, )?; - let jvm_args = arguments::java_arguments( + let mut jvm_args = arguments::java_arguments( updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, @@ -260,11 +267,22 @@ pub async fn launch_cluster( &classpaths, &version_name, profile.mem_max.unwrap_or(2048), - profile.launch_args.clone().unwrap_or_default(), + custom_args.clone(), &java.os_arch, java.major, )?; + let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; + if let Some(arg) = crate::game::mods_folder_argument( + cluster.mc_loader, + loader_version_id, + &custom_args, + &mods_dir, + ) { + tracing::debug!(cluster_id, mods_dir = %mods_dir.display(), "redirecting fabric mods folder"); + jvm_args.push(arg); + } + let mut mc_args = arguments::minecraft_arguments( updated, arg_map.get(&ArgumentType::Game).map(Vec::as_slice), diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 39bd11a7..1298152a 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,5 +1,6 @@ mod analytics; mod error; +pub mod fabric; mod launch; mod log_replay; mod process; @@ -33,6 +34,7 @@ pub use oneclient_mc::{ validate_rules, verify_game_files, }; +pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use shared_dir::{ dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, unlink_cluster_logs, write_allowed_symlinks, diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index eb837337..48606f9c 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -3,10 +3,13 @@ use std::fs::FileType; use std::path::Path; use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; use crate::LauncherResult; use crate::clusters::Cluster; +use oneclient_cluster::remove_mods_link; use oneclient_common::domain::ContentType; +use oneclient_common::paths; use oneclient_content::packages::store::manifest::{ self, ManifestEntry, MaterializedManifest, }; @@ -16,11 +19,13 @@ use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; -const SWAP_TYPES: [ContentType; 3] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, -]; +const GLOBAL_TYPES: [ContentType; 2] = [ContentType::ResourcePack, ContentType::Shader]; + +const SWAP_TYPES: [ContentType; 1] = [ContentType::Mod]; + +fn swap_types(mods_in_cluster: bool) -> &'static [ContentType] { + if mods_in_cluster { &[] } else { &SWAP_TYPES } +} const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; @@ -38,25 +43,93 @@ impl Desired { } } -/// Safe to run over a directory left by a crashed session another cluster or -/// a launcher version predating the manifest #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn materialize_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, + mods_in_cluster: bool, ) -> LauncherResult<()> { let dedicated = cluster.uses_dedicated_dir(); + let cluster_dir = cluster.dir()?; + let global_root = paths::shared_minecraft_dir()?; + polyio::create_dir_all(game_dir).await.ok(); + polyio::create_dir_all(&global_root).await.ok(); + + if mods_in_cluster { + polyio::create_dir_all(paths::cluster_mods_dir(&cluster.folder_name)?) + .await + .ok(); + ensure_mods_link(cluster).await; + prune_mods_links(services).await; + } else { + unwind_cluster_mods(cluster, &cluster_dir).await; + } + + adopt_into_global(game_dir, &global_root).await; + ensure_global_links(game_dir, &global_root).await; + + let mods_swapped = !dedicated && !mods_in_cluster; + drop_stale_notes(&[game_dir, &cluster_dir, &global_root], mods_swapped).await; // In the shared directory this often belongs to another cluster so every // use of it checks the id - let previous = manifest::load(game_dir).await; + let previous = manifest::load(game_dir, manifest::MANIFEST_NAME) + .await + .map(without_global_entries); + let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; + let previous_global = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; - import_manual_content(services, cluster, game_dir).await; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + if mods_in_cluster && previous_mods.is_none() && !dedicated { + let from = game_dir.join(ContentType::Mod.folder_name()); + let into = cluster_dir.join(ContentType::Mod.folder_name()); + let ours = ours_in_folder(ContentType::Mod, &linked, previous.as_ref()); + + tracing::info!(cluster_id = cluster.id, "moving mods out of the shared game directory"); + stash_content_files(&from, &into, &ours).await; + } + + if mods_in_cluster { + let mods_dir = cluster_dir.join(ContentType::Mod.folder_name()); + let disabled = disable_hand_removed( + services, + cluster, + &mods_dir, + ContentType::Mod, + previous_mods.as_ref(), + ) + .await; + + if !disabled.is_empty() { + let (title, body) = removal_notice(&disabled, Some(&cluster.name)); + services.events.notify(title).body(body).send(); + } + } + + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let disabled = disable_hand_removed( + services, + cluster, + &dir, + content_type, + previous_global.as_ref(), + ) + .await; + + if !disabled.is_empty() { + let (title, body) = removal_notice(&disabled, None); + services.events.notify(title).body(body).send(); + } + } + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await; - // Before the folder is built not after handing the game several enabled - // versions of one mod is a classloader conflict if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( cluster.id, &services.content(), @@ -67,42 +140,374 @@ pub async fn materialize_content( tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); } - let desired = desired_content(services, cluster).await?; - let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); + let (mods, rest): (Vec, Vec) = desired_mods(services, cluster) + .await? + .into_iter() + .partition(|_| mods_in_cluster); - // While the game is still closed this is what lands a package removed - // mid-session and clears another cluster's content from the shared dir - prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + // read across every cluster rather than this one so a pack installed anywhere is present here too + let packs = desired_global(services).await?; - if !dedicated { - let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) - .await - .unwrap_or_default(); + let mod_paths: HashSet = mods.iter().map(Desired::relative_path).collect(); + let rest_paths: HashSet = rest.iter().map(Desired::relative_path).collect(); + let pack_paths: HashSet = packs.iter().map(Desired::relative_path).collect(); + + prune_previous(&cluster_dir, previous_mods.as_ref(), &mod_paths).await; + prune_previous(game_dir, previous.as_ref(), &rest_paths).await; + prune_previous(&global_root, previous_global.as_ref(), &pack_paths).await; - for content_type in SWAP_TYPES { + if !dedicated { + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - // Whatever is still here belongs to whoever played (or crashed) - // last so take it into this cluster rather than deleting it - let ours = ours_in_folder(content_type, &linked, previous.as_ref()); + let ours = ours_in_folder(*content_type, &linked, previous.as_ref()); stash_content_files(&dir, &stash, &ours).await; - ensure_note(&dir, content_type).await; - restore_stashed(&stash, &dir, content_type, &ours).await; + ensure_note(&dir, *content_type).await; + restore_stashed(&stash, &dir, *content_type, &ours).await; } } - let entries = link_desired(game_dir, &desired).await; - manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + if mods_in_cluster { + let mod_entries = link_desired(&cluster_dir, &mods).await; + manifest::save( + &cluster_dir, + manifest::MODS_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, mod_entries), + ) + .await; + } + + let pack_entries = link_desired(&global_root, &packs).await; + manifest::save( + &global_root, + manifest::GLOBAL_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, pack_entries), + ) + .await; + + let entries = link_desired(game_dir, &rest).await; + manifest::save( + game_dir, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; sync_fabric_dep_overrides(cluster, game_dir).await?; Ok(()) } -/// Only for shared directories a dedicated cluster's content stays put between -/// sessions and [`materialize_content`] reconciles it at the next launch +// every enabled pack across every cluster +async fn desired_global(services: &LauncherServices) -> LauncherResult> { + let mut desired = Vec::new(); + + for content_type in GLOBAL_TYPES { + for row in artifact_dao::list_global_artifacts(&services.db, content_type as i64).await? { + if row.enabled == 0 { + continue; + } + + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? + else { + continue; + }; + + let src = artifact_absolute_path(&artifact.path)?; + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %row.hash, "cached artifact missing; skipping"); + continue; + } + + desired.push(Desired { + content_type, + file_name: row.file_name, + hash: row.hash, + src, + }); + } + } + + Ok(desired) +} + +fn without_global_entries(mut manifest: MaterializedManifest) -> MaterializedManifest { + let prefixes: Vec = GLOBAL_TYPES + .iter() + .map(|content_type| format!("{}/", content_type.folder_name())) + .collect(); + + manifest + .entries + .retain(|entry| !prefixes.iter().any(|prefix| entry.path.starts_with(prefix))); + + manifest +} + +// moves a cluster's own pack folders into the shared one before [`ensure_global_links`] replaces them with links +async fn adopt_into_global(game_dir: &Path, global_root: &Path) { + if game_dir == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let own = game_dir.join(content_type.folder_name()); + let shared = global_root.join(content_type.folder_name()); + + match polyio::symlink_metadata(&own).await { + Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {} + _ => continue, + } + + polyio::create_dir_all(&shared).await.ok(); + + let Ok(mut entries) = polyio::read_dir(&own).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let dest = shared.join(&name); + + if polyio::symlink_metadata(&dest).await.is_ok() { + continue; + } + + if let Err(err) = move_entry(&entry.path(), &dest).await { + tracing::warn!( + file = %name.to_string_lossy(), + error = %err, + "failed to move content into the shared folder; leaving it in place" + ); + } + } + } +} + +// points a cluster's pack folders at the shared ones +async fn ensure_global_links(game_dir: &Path, global_root: &Path) { + if game_dir == global_root { + return; + } + + for content_type in GLOBAL_TYPES { + let link = game_dir.join(content_type.folder_name()); + let target = global_root.join(content_type.folder_name()); + polyio::create_dir_all(&target).await.ok(); + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + continue; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(meta) if meta.is_dir() => { + if polyio::remove_dir_all(&link).await.is_err() { + tracing::warn!( + dir = %link.display(), + "cannot clear the cluster's own pack folder; leaving it unlinked" + ); + continue; + } + } + + Ok(_) => continue, + Err(_) => {} + } + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + dir = %link.display(), + error = %err, + "failed to link the shared pack folder into the game directory" + ); + } + } +} + +const MASS_REMOVAL_FLOOR: usize = 3; + +// names in a manifest whose file is no longer on disk paired with the hash that identifies the row to disable +async fn hand_removed_content( + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec<(String, String)> { + let Some(previous) = previous else { + return Vec::new(); + }; + + if polyio::read_dir(dir).await.is_err() { + tracing::warn!( + dir = %dir.display(), + "cannot read the content folder; leaving activity alone" + ); + return Vec::new(); + } + + let prefix = format!("{}/", content_type.folder_name()); + let mut considered = 0usize; + let mut removed = Vec::new(); + + for entry in &previous.entries { + let Some(name) = entry.path.strip_prefix(&prefix) else { + continue; + }; + considered += 1; + + if polyio::symlink_metadata(dir.join(name)).await.is_ok() { + continue; + } + + removed.push((name.to_owned(), entry.hash.clone())); + } + + if removed.len() == considered && considered >= MASS_REMOVAL_FLOOR { + tracing::warn!( + count = considered, + dir = %dir.display(), + "everything materialized here is missing; reading that as a folder problem, not as deletions" + ); + return Vec::new(); + } + + removed +} + +// turns hand-removed content off so the next launch stops putting it back +async fn disable_hand_removed( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + previous: Option<&MaterializedManifest>, +) -> Vec { + let removed = hand_removed_content(dir, content_type, previous).await; + if removed.is_empty() { + return Vec::new(); + } + + let ctx = services.content(); + let mut disabled = Vec::new(); + + for (name, hash) in removed { + let outcome = if content_type.is_global() { + disable_globally(cluster, &hash, &ctx).await + } else { + oneclient_content::bundles::set_artifact_enabled_to(cluster.id, &hash, false, &ctx) + .await + .map_err(Into::into) + }; + + match outcome { + Ok(()) => { + tracing::info!( + cluster_id = cluster.id, + file = %name, + ?content_type, + "removed by hand; disabling it instead of restoring it" + ); + disabled.push(name); + } + Err(err) => tracing::warn!( + cluster_id = cluster.id, + file = %name, + error = %err, + "failed to disable hand-removed content; it will be restored" + ), + } + } + + disabled +} + +// switches a globally installed artifact off for every cluster that has it +async fn disable_globally( + cluster: &Cluster, + hash: &str, + ctx: &oneclient_content::ContentCtx, +) -> LauncherResult<()> { + artifact_dao::set_enabled_for_hash(&ctx.db, hash, 0).await?; + oneclient_content::bundles::on_user_disable_artifact(cluster.id, hash, ctx).await?; + Ok(()) +} + +// at most three names +fn removal_summary(disabled: &[String]) -> String { + const SHOWN: usize = 3; + + let names = disabled + .iter() + .take(SHOWN) + .map(String::as_str) + .collect::>() + .join(", "); + + match disabled.len().saturating_sub(SHOWN) { + 0 => names, + rest => format!("{names} and {rest} more"), + } +} + +fn removal_notice(disabled: &[String], cluster_name: Option<&str>) -> (&'static str, String) { + let names = removal_summary(disabled); + + let folder = match cluster_name { + Some(name) => format!("{name}'s folder"), + None => "your shared folder".to_string(), + }; + + let scope = if cluster_name.is_some() { + "" + } else { + " on every cluster" + }; + + if disabled.len() == 1 { + return ( + "Content disabled", + format!( + "{names} is gone from {folder}, so it has been switched off{scope}. \ + Turn it back on in OneClient to restore it." + ), + ); + } + + ( + "Content disabled", + format!( + "{names} are gone from {folder}, so they have been switched off{scope}. \ + Turn them back on in OneClient to restore them." + ), + ) +} + +// puts a cluster back on the old layout after its loader stopped supporting `fabric.modsFolder` (a downgrade or a switch away from Fabric 0.15.0) +async fn unwind_cluster_mods(cluster: &Cluster, cluster_dir: &Path) { + remove_mods_link(&cluster.folder_name).await; + + let Some(previous) = manifest::load(cluster_dir, manifest::MODS_MANIFEST_NAME).await else { + return; + }; + + tracing::info!( + cluster_id = cluster.id, + "loader cannot be redirected; returning mods to the game directory" + ); + + prune_previous(cluster_dir, Some(&previous), &HashSet::new()).await; + manifest::clear(cluster_dir, manifest::MODS_MANIFEST_NAME).await; +} + #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn dematerialize_content( services: &LauncherServices, @@ -113,26 +518,29 @@ pub async fn dematerialize_content( // by now and gets dropped rather than stashed as a loose file import_manual_content(services, cluster, game_dir).await; - let current = manifest::load(game_dir).await; + let cluster_dir = cluster.dir()?; + let current = manifest::load(game_dir, manifest::MANIFEST_NAME).await; let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) .await .unwrap_or_default(); - for content_type in SWAP_TYPES { + let mods_in_cluster = manifest::mods_live_in_cluster(&cluster_dir).await; + + for content_type in swap_types(mods_in_cluster) { let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); + let stash = cluster_dir.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - let ours = ours_in_folder(content_type, &linked, current.as_ref()); + let ours = ours_in_folder(*content_type, &linked, current.as_ref()); stash_content_files(&dir, &stash, &ours).await; - ensure_note(&dir, content_type).await; + ensure_note(&dir, *content_type).await; } - manifest::clear(game_dir).await; + manifest::clear(game_dir, manifest::MANIFEST_NAME).await; Ok(()) } -async fn desired_content( +async fn desired_mods( services: &LauncherServices, cluster: &Cluster, ) -> LauncherResult> { @@ -140,7 +548,7 @@ async fn desired_content( let mut desired = Vec::with_capacity(linked.len()); for link in linked { - if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + if !link.enabled || link.content_type != ContentType::Mod { continue; } @@ -166,13 +574,11 @@ async fn desired_content( Ok(desired) } -/// Only files that made it are recorded so a failed link is never later -/// mistaken for ours and deleted out from under the user -async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { +async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { let mut entries = Vec::with_capacity(desired.len()); for item in desired { - let dest = game_dir + let dest = root .join(item.content_type.folder_name()) .join(&item.file_name); @@ -195,7 +601,7 @@ async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec, keep: &HashSet, ) { @@ -208,7 +614,7 @@ async fn prune_previous( continue; } - let path = game_dir.join(&entry.path); + let path = root.join(&entry.path); if let Err(err) = remove_entry(&path).await { tracing::warn!( file = %entry.path, @@ -252,6 +658,20 @@ pub async fn import_manual_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, +) { + let mods_in_cluster = match cluster.dir() { + Ok(dir) => manifest::mods_live_in_cluster(&dir).await, + Err(_) => false, + }; + + import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await; +} + +async fn import_manual_content_with( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, + mods_in_cluster: bool, ) { let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { Ok(linked) => linked, @@ -261,72 +681,140 @@ pub async fn import_manual_content( } }; - let manifest = manifest::load(game_dir).await; + let manifest = manifest::load(game_dir, manifest::MANIFEST_NAME).await; - for content_type in SWAP_TYPES { - let dir = game_dir.join(content_type.folder_name()); - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + // under the old layout mods sit in the game directory and are matched + // against its manifest exactly like resource packs and shaders + let cluster_dir = cluster.dir().ok(); + let mods_manifest = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => manifest::load(dir, manifest::MODS_MANIFEST_NAME).await, + _ => None, + }; - let known: HashSet<&str> = linked - .iter() - .filter(|link| link.content_type == content_type) - .map(|link| link.cluster_file_name.as_str()) - .collect(); + let (mods_dir, mods_manifest) = match cluster_dir.as_deref() { + Some(dir) if mods_in_cluster => ( + dir.join(ContentType::Mod.folder_name()), + mods_manifest.as_ref(), + ), + _ => ( + game_dir.join(ContentType::Mod.folder_name()), + manifest.as_ref(), + ), + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; - if !file_type.is_file() { - continue; - } + import_from_dir( + services, + cluster, + &mods_dir, + ContentType::Mod, + &names_linked_here(&linked, ContentType::Mod), + mods_manifest, + ) + .await; - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if name.starts_with('.') || !has_content_extension(content_type, name) { - continue; - } - if known.contains(name) { - continue; - } + let Ok(global_root) = paths::shared_minecraft_dir() else { + return; + }; + let global_manifest = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; - // Ours from a previous launch `prune_previous` handles it - // Taking it as a user drop-in would reinstall a just-deleted package - let relative = manifest::entry_path(content_type.folder_name(), name); - if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { + for content_type in GLOBAL_TYPES { + let dir = global_root.join(content_type.folder_name()); + let known = match artifact_dao::list_global_artifacts(&services.db, content_type as i64) + .await + { + Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list global content; skipping its import"); continue; } + }; - // No manifest a directory from a launcher version predating it - // If the cache holds this exact file the launcher put it here - if manifest.is_none() && is_cached_artifact(services, &path).await { - tracing::debug!(file = name, "discarding stale launcher content in game dir"); - if let Err(err) = polyio::remove_file(&path).await { - tracing::warn!(file = name, error = %err, "failed to discard stale content"); - } - continue; + import_from_dir( + services, + cluster, + &dir, + content_type, + &known, + global_manifest.as_ref(), + ) + .await; + } +} + +fn names_linked_here( + linked: &[oneclient_content::packages::LinkedArtifactInfo], + content_type: ContentType, +) -> HashSet { + linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect() +} + +async fn import_from_dir( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + content_type: ContentType, + known: &HashSet, + manifest: Option<&MaterializedManifest>, +) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.is_some_and(|m| m.contains(&relative)) { + continue; + } + + if manifest.is_none() && is_cached_artifact(services, &path).await { + tracing::debug!( + file = name, + dir = %dir.display(), + "discarding stale launcher content; the cache still holds it" + ); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale content"); } + continue; + } - match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await { - Ok(_) => { - tracing::debug!(file = name, "registered manually-added content") - } - Err(err) => tracing::warn!( - file = name, - error = %err, - "failed to register manually-added content" - ), + match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()) + .await + { + Ok(_) => { + tracing::debug!(file = name, "registered manually-added content") } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ), } } } -/// Already in the artifact cache i.e. the launcher put it in the game dir -/// rather than the user dropping it there async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { return false; @@ -348,6 +836,108 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool { } } +// puts this cluster's mods folder into the shared `mods` directory +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] +async fn ensure_mods_link(cluster: &Cluster) { + let (Ok(link), Ok(target)) = ( + paths::shared_mods_link(&cluster.folder_name), + paths::cluster_mods_dir(&cluster.folder_name), + ) else { + return; + }; + + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + let aimed_right = matches!( + (polyio::canonicalize(&link), polyio::canonicalize(&target)), + (Ok(from), Ok(to)) if from == to + ); + if aimed_right { + return; + } + + polyio::remove_symlink_dir(&link).await.ok(); + } + + Ok(_) => { + tracing::warn!( + folder = %cluster.folder_name, + "shared mods folder holds a real entry under this name; not linking" + ); + return; + } + + Err(_) => {} + } + + if let Some(parent) = link.parent() { + polyio::create_dir_all(parent).await.ok(); + ensure_links_note(parent).await; + } + polyio::create_dir_all(&target).await.ok(); + + if let Err(err) = polyio::symlink_dir(&target, &link).await { + tracing::warn!( + folder = %cluster.folder_name, + error = %err, + "failed to link cluster mods into the shared minecraft folder" + ); + } +} + +async fn points_into_clusters_dir(path: &Path) -> bool { + let (Ok(target), Ok(root)) = (polyio::read_link(path).await, paths::clusters_dir()) else { + return false; + }; + + target.starts_with(root) +} + +// drops links a deleted cluster left behind +#[tracing::instrument(skip(services), level = "debug")] +async fn prune_mods_links(services: &LauncherServices) { + let Ok(root) = paths::shared_mods_dir() else { + return; + }; + + let Ok(mut entries) = polyio::read_dir(&root).await else { + return; + }; + + let known: HashSet = match cluster_dao::list_all(&services.db).await { + Ok(rows) => rows.into_iter().map(|row| row.folder_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list clusters; leaving shared mods links alone"); + return; + } + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_symlink() { + continue; + } + + let name = entry.file_name().to_string_lossy().into_owned(); + if known.contains(&name) { + continue; + } + + if !points_into_clusters_dir(&entry.path()).await { + continue; + } + + match polyio::remove_symlink_dir(entry.path()).await { + Ok(()) => tracing::debug!(link = %name, "cleared mods link for a deleted cluster"), + Err(err) => { + tracing::warn!(link = %name, error = %err, "failed to clear stale cluster mods link") + } + } + } +} + const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; #[tracing::instrument(level = "debug")] @@ -363,10 +953,35 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { } const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; +const LINKS_NOTE_NAME: &str = "EACH_FOLDER_IS_A_CLUSTER.txt"; + +fn is_note(name: &str) -> bool { + name == EMPTY_NOTE_NAME || name == LINKS_NOTE_NAME +} + +async fn ensure_links_note(dir: &Path) { + let note = dir.join(LINKS_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + polyio::write( + ¬e, + "Every folder in here is one of your OneClient clusters.\n\ + \n\ + They're shortcuts. Open one and you land in that cluster's own mods \ + folder, which is where its mods really live. Drop a jar in there and \ + that cluster will pick it up the next time you play - and only that \ + cluster.\n\ + \n\ + Loose jars sitting directly in this folder aren't read by anything, so \ + put them inside a cluster's folder instead.\n", + ) + .await + .ok(); +} -/// Launcher-owned content is dropped (the cache has it) everything else -/// (sidecars unzipped packs stray configs) is *moved* into the cluster folder -/// so it stays attached to that cluster and [`restore_stashed`] links it back async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Ok(mut entries) = polyio::read_dir(dir).await else { return; @@ -377,7 +992,7 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') { + if is_note(&name) || name.starts_with('.') { continue; } @@ -385,9 +1000,16 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { continue; }; - // Ours and the cache has it covered - // On Windows `symlink_file` hard-links so ours is not always a symlink - if file_type.is_symlink() || ours.contains(&name) { + if file_type.is_symlink() { + if points_into_clusters_dir(&path).await { + continue; + } + + remove_dir_or_file(&path, file_type).await; + continue; + } + + if ours.contains(&name) { remove_dir_or_file(&path, file_type).await; continue; } @@ -403,8 +1025,6 @@ async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { } } -/// Links stashed leftovers back so the game writes straight through into the -/// cluster folder even if we never get to run on exit async fn restore_stashed( stash: &Path, dir: &Path, @@ -420,7 +1040,7 @@ async fn restore_stashed( let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { continue; }; - if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { + if is_note(&name) || name.starts_with('.') || ours.contains(&name) { continue; } @@ -492,6 +1112,45 @@ async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { Ok(()) } +async fn drop_stale_notes(roots: &[&Path], mods_swapped: bool) { + let mut swept: Vec<&Path> = Vec::new(); + + for root in roots { + // The shared game directory *is* the global root for a cluster without + // a dedicated directory, and both are the cluster folder for one with + // it visiting a root twice would only walk the same folders again + if swept.contains(root) { + continue; + } + swept.push(*root); + + for content_type in GLOBAL_TYPES { + drop_note(&root.join(content_type.folder_name())).await; + } + + if !mods_swapped { + drop_note(&root.join(ContentType::Mod.folder_name())).await; + } + } +} + +async fn drop_note(dir: &Path) { + let note = dir.join(EMPTY_NOTE_NAME); + if polyio::symlink_metadata(¬e).await.is_err() { + return; + } + + match polyio::remove_file(¬e).await { + Ok(()) => tracing::debug!(dir = %dir.display(), "removed a stale empty-folder note"), + // Nothing downstream reads it the next launch tries again + Err(err) => tracing::debug!( + dir = %dir.display(), + error = %err, + "could not remove the stale empty-folder note" + ), + } +} + async fn ensure_note(dir: &Path, content_type: ContentType) { let note = dir.join(EMPTY_NOTE_NAME); @@ -894,6 +1553,222 @@ mod tests { names } + #[test] + fn mods_are_swapped_only_while_they_still_live_in_the_game_dir() { + assert!( + !swap_types(true).contains(&ContentType::Mod), + "redirected mods are not the game dir's to swap" + ); + assert!( + swap_types(false).contains(&ContentType::Mod), + "un-redirected mods still have to leave the shared dir on exit" + ); + } + + #[test] + fn global_content_is_never_swapped() { + for types in [swap_types(true), swap_types(false)] { + for content_type in GLOBAL_TYPES { + assert!( + !types.contains(&content_type), + "{content_type:?} is shared and must survive a session" + ); + } + } + + for content_type in GLOBAL_TYPES { + assert!(content_type.is_global()); + } + assert!(!ContentType::Mod.is_global()); + } + + #[test] + fn the_game_dir_manifest_stops_claiming_packs() { + let manifest = manifest_of( + 1, + &[ + "mods/sodium.jar", + "resourcepacks/faithful.zip", + "shaderpacks/bsl.zip", + ], + ); + + let stripped = without_global_entries(manifest); + let paths = stripped.paths(); + + assert!(paths.contains("mods/sodium.jar")); + assert!(!paths.contains("resourcepacks/faithful.zip")); + assert!(!paths.contains("shaderpacks/bsl.zip")); + } + + #[test] + fn the_global_notice_owns_up_to_its_reach() { + let (_, body) = removal_notice(&["bsl.zip".into()], None); + + assert!(body.contains("every cluster"), "{body}"); + assert!(!body.contains("'s folder"), "{body}"); + } + + async fn mods_scratch(name: &str, present: &[&str]) -> polyio::testing::ScratchDir { + let root = polyio::testing::ScratchDir::new(name); + polyio::create_dir_all(root.path()).await.unwrap(); + + for file in present { + polyio::write(root.join(file), b"jar".as_slice()).await.unwrap(); + } + + root + } + + fn mods_manifest(files: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + 1, + files + .iter() + .map(|name| ManifestEntry { + path: manifest::entry_path(ContentType::Mod.folder_name(), name), + hash: format!("hash-{name}"), + }) + .collect(), + ) + } + + #[tokio::test] + async fn a_jar_the_user_deleted_is_reported_with_its_hash() { + let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await; + let manifest = mods_manifest(&["kept.jar", "gone.jar", "also_kept.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed, vec![("gone.jar".into(), "hash-gone.jar".into())]); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn renaming_a_jar_out_of_the_way_counts_as_removing_it() { + let dir = mods_scratch("renamed_away", &["sodium.jar.disabled", "other.jar"]).await; + let manifest = mods_manifest(&["sodium.jar", "other.jar"]); + + let removed = hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await; + + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].0, "sodium.jar"); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn an_unreadable_folder_disables_nothing() { + let manifest = mods_manifest(&["a.jar", "b.jar"]); + let missing = Path::new("definitely-not-a-directory-ю"); + + assert!(hand_removed_content(missing, ContentType::Mod, Some(&manifest)).await.is_empty()); + } + + #[tokio::test] + async fn a_wholesale_disappearance_reads_as_a_folder_problem() { + let dir = mods_scratch("all_gone", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar", "c.jar", "d.jar"]); + + assert!( + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.is_empty(), + "an empty folder where everything was is not four deliberate deletions" + ); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn clearing_a_short_list_is_still_taken_at_face_value() { + let dir = mods_scratch("small_clear", &[]).await; + let manifest = mods_manifest(&["a.jar", "b.jar"]); + + assert_eq!(hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.len(), 2); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[tokio::test] + async fn a_first_launch_concludes_nothing() { + let dir = mods_scratch("no_manifest", &[]).await; + + assert!(hand_removed_content(dir.path(), ContentType::Mod, None).await.is_empty()); + + std::fs::remove_dir_all(dir.path()).ok(); + } + + #[test] + fn the_notice_names_a_few_and_counts_the_rest() { + let (title, body) = removal_notice(&["sodium.jar".into()], Some("26.2 Fabric")); + assert_eq!(title, "Mod disabled"); + assert!(body.contains("sodium.jar is gone"), "{body}"); + + let many: Vec = (0..6).map(|i| format!("mod{i}.jar")).collect(); + let (title, body) = removal_notice(&many, Some("26.2 Fabric")); + assert_eq!(title, "Mods disabled"); + assert!(body.contains("and 3 more"), "{body}"); + assert!(!body.contains("mod5.jar"), "{body}"); + } + + #[test] + fn notes_are_never_user_content() { + assert!(is_note(EMPTY_NOTE_NAME)); + assert!(is_note(LINKS_NOTE_NAME)); + assert!(!is_note("sodium.jar")); + } + + #[tokio::test] + async fn stale_notes_go_but_the_folder_is_left_alone() { + let root = polyio::testing::ScratchDir::new("stale_notes"); + let dir = root.path(); + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + polyio::create_dir_all(&sub).await.unwrap(); + polyio::write(sub.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(sub.join("keep.jar"), b"jar".as_slice()) + .await + .unwrap(); + } + + drop_stale_notes(&[dir], false).await; + + for folder in ["mods", "resourcepacks", "shaderpacks"] { + let sub = dir.join(folder); + assert!(!sub.join(EMPTY_NOTE_NAME).exists(), "{folder}"); + assert!(sub.join("keep.jar").exists(), "{folder}"); + } + + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn a_swapped_mods_folder_keeps_its_note() { + let root = polyio::testing::ScratchDir::new("swapped_note"); + let dir = root.path(); + + let mods = dir.join("mods"); + let packs = dir.join("resourcepacks"); + polyio::create_dir_all(&mods).await.unwrap(); + polyio::create_dir_all(&packs).await.unwrap(); + polyio::write(mods.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + polyio::write(packs.join(EMPTY_NOTE_NAME), b"stale".as_slice()) + .await + .unwrap(); + + drop_stale_notes(&[dir], true).await; + + assert!(mods.join(EMPTY_NOTE_NAME).exists()); + assert!(!packs.join(EMPTY_NOTE_NAME).exists()); + + std::fs::remove_dir_all(dir).ok(); + } + #[test] fn ownership_spans_the_manifest_and_the_database() { let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); @@ -902,8 +1777,6 @@ mod tests { let ours = ours_in_folder(ContentType::Mod, &linked, Some(&manifest)); assert!(ours.contains("from_manifest.jar")); - // Scoped to the folder a shaderpack entry must not make a mod of the - // same name look managed assert!(!ours.contains("bsl.zip")); } } diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 60ae7ee0..d382ebf2 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -249,6 +249,14 @@ async fn relink_cluster_files( for content_type in FILE_CONTENT_TYPES { let dir = cluster_root.join(content_type.folder_name()); + + if polyio::symlink_metadata(&dir) + .await + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + continue; + } + let files = match list_files(&dir).await { Ok(files) => files, Err(_) => continue, diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 9e5e48be..4d2d04a4 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -1,21 +1,25 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::LauncherResult; +use crate::clusters::Cluster; use crate::state::LauncherState; use oneclient_common::domain::ContentType; use oneclient_common::paths; +use oneclient_content::packages::store::manifest; use oneclient_content::packages::store::{ find_unreferenced_files, remove_unreferenced_files, }; +use oneclient_events::EventBus; -const LEGACY_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, -]; +const LEGACY_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; + +// stable id for the storage scan's progress +pub const STORAGE_SCAN_PROGRESS: Uuid = + Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageEntry { @@ -56,51 +60,97 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult LauncherResult { +struct ScanSteps<'a> { + events: &'a EventBus, + done: u64, + total: u64, +} + +impl ScanSteps<'_> { + fn begin(&mut self, label: &str) { + self.events.progress( + STORAGE_SCAN_PROGRESS, + format!("Measuring {label}"), + self.done, + self.total, + ); + self.done += 1; + } +} + +impl Drop for ScanSteps<'_> { + fn drop(&mut self) { + self.events + .progress(STORAGE_SCAN_PROGRESS, "Done", self.total, self.total); + } +} + +async fn legacy_cluster_content(clusters: &[Cluster]) -> ReclaimableEntry { let mut found = ReclaimableEntry::default(); - for cluster in state.clusters.list().await? { - // A dedicated cluster's folder *is* its game directory so content there belongs + for cluster in clusters { if cluster.uses_dedicated_dir() { continue; } @@ -108,7 +158,13 @@ async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult LauncherResult bool { @@ -152,18 +208,20 @@ fn is_content_file(content_type: ContentType, name: &str) -> bool { } } -async fn entry(label: &str, path: PathBuf) -> StorageEntry { +async fn entry(label: &str, path: PathBuf, seen: &mut SeenFiles) -> StorageEntry { StorageEntry { label: label.to_string(), - bytes: dir_size(&path).await, + bytes: dir_size_seen(&path, seen).await, path, files: None, } } -/// Links are not followed so a materialized game directory does not appear to -/// double the size of the package cache pub async fn dir_size(root: impl AsRef) -> u64 { + dir_size_seen(root, &mut SeenFiles::default()).await +} + +async fn dir_size_seen(root: impl AsRef, seen: &mut SeenFiles) -> u64 { let mut total = 0; let mut stack = vec![root.as_ref().to_path_buf()]; @@ -183,7 +241,9 @@ pub async fn dir_size(root: impl AsRef) -> u64 { if file_type.is_dir() { stack.push(entry.path()); - } else if let Ok(meta) = entry.metadata().await { + } else if let Ok(meta) = entry.metadata().await + && seen.first_sighting(&entry.path()).await + { total += meta.len(); } } @@ -192,8 +252,37 @@ pub async fn dir_size(root: impl AsRef) -> u64 { total } -/// Cleanup refuses to run while fixture numbers are shown the report bears no -/// relation to disk so the only thing it could delete is the user's real data +#[derive(Default)] +struct SeenFiles(HashSet<(u64, u64)>); + +impl SeenFiles { + async fn first_sighting(&mut self, path: &Path) -> bool { + match file_identity(path).await { + Some(id) => self.0.insert(id), + None => true, + } + } +} + +async fn file_identity(path: &Path) -> Option<(u64, u64)> { + if !can_be_materialized(path) { + return None; + } + + polyio::file_id(path).await.ok() +} + +fn can_be_materialized(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + lower.ends_with(".jar") || lower.ends_with(".zip") +} + fn showing_fixture() -> bool { #[cfg(debug_assertions)] { @@ -359,14 +448,14 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir.join("nested")).await.unwrap(); - polyio::write(dir.join("a.bin"), vec![0u8; 1000]).await.unwrap(); - polyio::write(dir.join("nested").join("b.bin"), vec![0u8; 500]) + polyio::write(dir.join("a.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(dir.join("nested").join("b.jar"), vec![0u8; 500]) .await .unwrap(); assert_eq!(dir_size(dir).await, 1500); - polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + polyio::symlink_file(dir.join("a.jar"), dir.join("link.jar")) .await .unwrap(); assert_eq!( @@ -377,4 +466,29 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + + #[tokio::test] + async fn a_file_in_two_folders_is_paid_for_once() { + let root = polyio::testing::ScratchDir::new("shared_size"); + let dir = root.path(); + let store = dir.join("store"); + let cluster = dir.join("cluster"); + polyio::create_dir_all(&store).await.unwrap(); + polyio::create_dir_all(&cluster).await.unwrap(); + + polyio::write(store.join("mod.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::symlink_file(store.join("mod.jar"), cluster.join("mod.jar")) + .await + .unwrap(); + + let mut seen = SeenFiles::default(); + assert_eq!(dir_size_seen(&store, &mut seen).await, 1000); + assert_eq!( + dir_size_seen(&cluster, &mut seen).await, + 0, + "the cache already paid for it" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index f8160670..65ccfbbd 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -70,6 +70,59 @@ pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result< Ok(true) } +use crate::models::GlobalArtifactRow; + +/// One row per hash across every cluster for content that is installed globally +/// +/// `enabled` is the OR over the clusters: one cluster still having a pack on is +/// enough to keep it in the folder, because there is one folder and it can only +/// have one answer +/// +/// Switching a pack off still reaches every cluster it goes through +/// [`set_enabled_for_hash`], which leaves no row for this to read as on +/// +/// Written with the runtime-checked builder rather than `query!` so it needs no +/// entry in the offline cache +pub async fn list_global_artifacts( + pool: &SqlitePool, + content_type: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, GlobalArtifactRow>( + r#" + SELECT + ca.hash AS hash, + MIN(ca.cluster_file_name) AS file_name, + MAX(ca.enabled) AS enabled + FROM cluster_artifacts ca + JOIN artifacts a ON a.hash = ca.hash + WHERE a.content_type = ? + GROUP BY ca.hash + "#, + ) + .bind(content_type) + .fetch_all(pool) + .await +} + +/// Sets the flag on every cluster that has this artifact +/// +/// Globally installed content has one folder so writing only the row of +/// whichever cluster the user happened to be looking at would leave the rest +/// disagreeing with what is on disk +pub async fn set_enabled_for_hash( + pool: &SqlitePool, + hash: &str, + enabled: i64, +) -> Result { + let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") + .bind(enabled) + .bind(hash) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as::<_, ArtifactRow>( r#" diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs index 7734f472..1ebe809f 100644 --- a/packages/oneclient_db/src/models/artifact.rs +++ b/packages/oneclient_db/src/models/artifact.rs @@ -29,3 +29,11 @@ pub struct ClusterArtifactRow { pub cluster_file_name: String, pub enabled: i64, } + +// used for resourcepacks and shaders +#[derive(Debug, Clone, FromRow)] +pub struct GlobalArtifactRow { + pub hash: String, + pub file_name: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index f34ef93b..eeac270a 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -8,7 +8,7 @@ mod java; mod package_metadata; mod setting_profile; -pub use artifact::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow}; +pub use artifact::{ArtifactRow, ClusterArtifactRow, GlobalArtifactRow, ProviderReleaseRow}; pub use browser_package_update::BrowserPackageUpdateRow; pub use package_metadata::PackageMetadataRow; pub use bundle::{BundleRow, NewBundle}; diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 3c8961c0..d13badf3 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -46,4 +46,5 @@ sha2.workspace = true [target.'cfg(windows)'.dependencies] junction.workspace = true +windows-sys.workspace = true diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 0797a20f..7c21f829 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -569,9 +569,70 @@ pub async fn symlink_file( }) } -/// Windows gets a junction which needs no elevated privilege unlike a real -/// directory symlink -/// Remove with [`remove_symlink_dir`] +// what a file is on disk rather than what it is called +#[cfg(windows)] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let path = path.as_ref().to_path_buf(); + let display = path.to_string_lossy().to_string(); + + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut info = BY_HANDLE_FILE_INFORMATION { + dwFileAttributes: 0, + ftCreationTime: unsafe { std::mem::zeroed() }, + ftLastAccessTime: unsafe { std::mem::zeroed() }, + ftLastWriteTime: unsafe { std::mem::zeroed() }, + dwVolumeSerialNumber: 0, + nFileSizeHigh: 0, + nFileSizeLow: 0, + nNumberOfLinks: 0, + nFileIndexHigh: 0, + nFileIndexLow: 0, + }; + + let filled = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) + }; + + if filled == 0 { + return Err(std::io::Error::last_os_error()); + } + + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { + source: e, + path: display, + }) +} + +#[cfg(not(windows))] +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + + let meta = stat(path).await?; + Ok((meta.dev(), meta.ino())) +} + #[tracing::instrument( level = "debug", skip(original, link), @@ -607,7 +668,34 @@ pub async fn symlink_dir( }) } -/// A Windows junction must be removed with `remove_dir` not `remove_file` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_link(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + let target = tokio::fs::read_link(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + + #[cfg(windows)] + { + let text = target.to_string_lossy().into_owned(); + for prefix in [r"\??\", r"\\?\"] { + if let Some(rest) = text.strip_prefix(prefix) { + return Ok(std::path::PathBuf::from(rest)); + } + } + } + + Ok(target) +} + #[tracing::instrument( level = "debug", skip(path),