diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index f43120ec..2bc4ab1d 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -21,15 +21,18 @@ pub use queries::{ CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, - SetDefaultAccountKeys, TermsQuery, UploadLogKeys, UploadLogMutation, UseLogAction, - UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, + SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, + UploadLogKeys, UploadLogMutation, UseLogAction, + UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, + UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, + invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report, java_runtimes, latest_changelog_version, loaded_image, loader_versions, login_code_already_handled, migration_detection, - mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, + mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch, pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy, query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total, settled_or_loading, terms_document, terms_error, terms_is_loading, try_account, diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index 7e309ba5..28539dff 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -354,6 +354,12 @@ pub fn use_refresh_all_accounts() -> UseMutation { use_mutation(Mutation::new(RefreshAllAccountsMutation)) } +/// Whether the mutation has yet to produce a settled result. +/// +/// True *before* it has ever been run as well as while it is running — +/// `MutationStateData::Pending` means "has not loaded yet", not "in progress". +/// For driving a button's label or disabled state, use [`mutation_is_running`]; +/// this one reads as "no result to show yet". pub fn mutation_is_pending(mutation: &UseMutation) -> bool { matches!( &*mutation.read().state(), @@ -361,6 +367,11 @@ pub fn mutation_is_pending(mutation: &UseMutation) -> ) } +/// Whether the mutation is actually in flight right now. +pub fn mutation_is_running(mutation: &UseMutation) -> bool { + mutation.read().state().is_loading() +} + pub fn mutation_error(mutation: &UseMutation) -> Option where M: MutationCapability, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 63810c7c..a14c1d09 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -32,6 +32,7 @@ mod screenshots; mod settings_profiles; mod skin; mod state; +mod storage; mod tos; mod version_metadata; mod versions; @@ -44,7 +45,7 @@ pub use auth::{ CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, reset_login_code_dedup, try_account, try_accounts, try_default_account, + mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts, try_default_account, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, @@ -71,6 +72,10 @@ pub use logs::{ use_log_action, use_log_content, use_upload_log, }; pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; +pub use storage::{ + StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction, + invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report, +}; pub use mutations::{ ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, }; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index e9a297ab..5e222214 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -79,16 +79,13 @@ impl MutationCapability for ClusterMutation { let content = &state.services.content(); let result = match keys { ClusterAction::ToggleArtifact { cluster_id, hash } => { - // A live session holds the cluster's files open, so the toggle - // is recorded now and lands in the folder at the next launch. - oneclient_core::toggle_artifact_enabled( - *cluster_id, - hash, - state.games.is_active(*cluster_id), - content, - ) - .await - .map(|_| ()) + // Recorded in the database and applied to the game folder at the + // next launch, whether or not a session is live: Minecraft reads + // its mods once at startup, so there is nothing a mid-session + // write could achieve. + oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) + .await + .map(|_| ()) } ClusterAction::RemoveArtifact { cluster_id, hash } => { oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs new file mode 100644 index 00000000..c52462aa --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -0,0 +1,94 @@ +use std::time::Duration; + +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use oneclient_core::LauncherError; +use oneclient_core::storage::{StorageReport, storage_report}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportQuery; + +impl QueryCapability for StorageReportQuery { + type Ok = StorageReport; + type Err = LauncherError; + type Keys = StorageReportKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + storage_report(&state).await + } +} + +/// How long a scan stays good for. +/// +/// The report walks every file under the launcher directory, which on a +/// well-used install is tens of thousands of them. `stale_time` defaults to +/// zero, meaning the scan would re-run every single time the page is mounted — +/// so leaving the page and coming back would rescan the whole tree. Disk usage +/// does not move on its own, and the page has a refresh button for when the +/// user knows it has. +const STORAGE_STALE_TIME: Duration = Duration::from_secs(120); + +pub fn use_storage_report() -> UseQuery { + use_query(Query::new(StorageReportKeys, StorageReportQuery).stale_time(STORAGE_STALE_TIME)) +} + +pub fn try_storage_report(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_storage_queries() { + QueriesStorage::::try_invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StorageAction { + /// Delete cached package files no artifact refers to. + CleanUnreferencedCache, + /// Clear content links older launchers wrote into cluster folders. + CleanLegacyClusterContent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageActionMutation; + +impl MutationCapability for StorageActionMutation { + type Ok = (); + type Err = String; + type Keys = StorageAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + + match keys { + StorageAction::CleanUnreferencedCache => { + oneclient_core::storage::clean_unreferenced_cache(&state) + .await + .map(|_| ()) + } + StorageAction::CleanLegacyClusterContent => { + oneclient_core::storage::clean_legacy_cluster_content(&state) + .await + .map(|_| ()) + } + } + .map_err(|e| e.to_string()) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_storage_queries().await; + } + } +} + +pub type UseStorageAction = UseMutation; + +pub fn use_storage_action() -> UseStorageAction { + use_mutation(Mutation::new(StorageActionMutation)) +} diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs index b900250a..dbd3d0d9 100644 --- a/packages/oneclient_app/src/layout/animated_outlet.rs +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -21,6 +21,7 @@ fn is_sidebar_route(route: &Route) -> bool { | Route::SettingsMinecraft {} | Route::SettingsLauncher {} | Route::SettingsJava {} + | Route::SettingsStorage {} | Route::SettingsApis {} | Route::SettingsLanguage {} | Route::SettingsDeveloper {} diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 015705a0..8d22a856 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -193,6 +193,26 @@ const SEARCH_INDEX: &[SearchItem] = &[ keywords: &["java", "runtime", "custom", "folder", "path"], route: Route::SettingsJava {}, }, + // Storage + SearchItem { + id: "storage.usage", + icon: IconType::Database01, + title: "Disk usage", + description: "See what the launcher is storing and where.", + keywords: &["storage", "disk", "space", "size", "usage", "cache"], + route: Route::SettingsStorage {}, + }, + SearchItem { + id: "storage.cleanup", + icon: IconType::Trash01, + title: "Free up space", + description: "Remove cached packages and leftovers nothing is using.", + keywords: &[ + "storage", "clean", "cleanup", "free", "space", "cache", "unused", "orphan", + "leftover", + ], + route: Route::SettingsStorage {}, + }, // Language SearchItem { id: "language.select", @@ -263,6 +283,7 @@ pub enum SettingsTab { Accounts, LauncherSettings, Java, + Storage, Appearance, Apis, Language, @@ -277,6 +298,7 @@ impl SettingsTab { Self::Accounts => "Accounts", Self::LauncherSettings => "Launcher settings", Self::Java => "Java", + Self::Storage => "Storage", Self::Appearance => "Appearance", Self::Apis => "APIs", Self::Language => "Language", @@ -291,6 +313,7 @@ impl SettingsTab { Self::Accounts => IconType::Users01, Self::LauncherSettings => IconType::Rocket02, Self::Java => IconType::CodeSnippet02, + Self::Storage => IconType::Database01, Self::Appearance => IconType::Brush01, Self::Apis => IconType::Key01, Self::Language => IconType::Globe01, @@ -305,6 +328,7 @@ impl SettingsTab { Self::Accounts => Some(Route::Accounts {}), Self::LauncherSettings => Some(Route::SettingsLauncher {}), Self::Java => Some(Route::SettingsJava {}), + Self::Storage => Some(Route::SettingsStorage {}), Self::Appearance => Some(Route::SettingsAppearance {}), Self::Apis => Some(Route::SettingsApis {}), Self::Language => Some(Route::SettingsLanguage {}), @@ -325,6 +349,7 @@ const GROUPS: &[SettingsGroup] = &[ tabs: &[ SettingsTab::LauncherSettings, SettingsTab::Java, + SettingsTab::Storage, SettingsTab::Appearance, SettingsTab::Apis, SettingsTab::Language, @@ -348,6 +373,7 @@ fn route_tab(route: &Route) -> SettingsTab { Route::SettingsMinecraft {} => SettingsTab::MinecraftSettings, Route::SettingsLauncher {} => SettingsTab::LauncherSettings, Route::SettingsJava {} => SettingsTab::Java, + Route::SettingsStorage {} => SettingsTab::Storage, Route::SettingsApis {} => SettingsTab::Apis, Route::SettingsLanguage {} => SettingsTab::Language, Route::SettingsDeveloper {} => SettingsTab::DeveloperOptions, diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index 9dfbceb3..94f0545c 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -18,7 +18,7 @@ use crate::view::{ }, settings::{ SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, SettingsJava, - SettingsLanguage, SettingsLauncher, SettingsMinecraft, + SettingsLanguage, SettingsLauncher, SettingsMinecraft, SettingsStorage, }, }, onboarding::{ @@ -100,6 +100,8 @@ pub enum Route { SettingsLauncher {}, #[route("/app/settings/java")] SettingsJava {}, + #[route("/app/settings/storage")] + SettingsStorage {}, #[route("/app/settings/apis")] SettingsApis {}, #[route("/app/settings/language")] @@ -149,6 +151,7 @@ impl Route { Route::SettingsMinecraft { .. } => "Minecraft Settings".to_string(), Route::SettingsLauncher { .. } => "Launcher Settings".to_string(), Route::SettingsJava { .. } => "Java".to_string(), + Route::SettingsStorage { .. } => "Storage".to_string(), Route::SettingsApis { .. } => "APIs".to_string(), Route::SettingsLanguage { .. } => "Language".to_string(), Route::SettingsDeveloper { .. } => "Developer Options".to_string(), diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs index 0c982718..659c5770 100644 --- a/packages/oneclient_app/src/view/app/settings/mod.rs +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -8,6 +8,7 @@ mod java; mod language; mod launcher; mod minecraft; +mod storage; use freya::prelude::*; @@ -20,6 +21,7 @@ pub use java::SettingsJava; pub use language::SettingsLanguage; pub use launcher::SettingsLauncher; pub use minecraft::SettingsMinecraft; +pub use storage::SettingsStorage; use crate::{ components::{Icon, IconType}, diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs new file mode 100644 index 00000000..d47663e7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -0,0 +1,338 @@ +use freya::prelude::*; +use oneclient_core::storage::{ReclaimableEntry, StorageEntry, StorageReport, format_bytes}; + +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 crate::theme::colors; + +/// Rows narrower than this would render as a sliver, so they get a floor. +const MIN_BAR_FRACTION: f32 = 0.015; + +#[derive(PartialEq)] +pub struct SettingsStorage; + +impl Component for SettingsStorage { + fn render(&self) -> impl IntoElement { + // Every hook this component owns, before anything can return early. The + // report is absent on the first render while the scan runs, and a hook + // called only on the later render would change the hook order between + // renders. + let report_query = use_storage_report(); + + let Some(report) = try_storage_report(&report_query) else { + return settings_page() + .child(hero_placeholder()) + .into_element(); + }; + + let refresh = Button::new() + .secondary() + .small() + .on_press(move |_| { + report_query.invalidate(); + }) + .child(label().text("Refresh")); + + let mut page = settings_page() + .child(hero(&report, refresh.into_element())) + .child(section_header("FREE UP SPACE")) + .child( + ReclaimRow { + icon: IconType::FileX02, + title: "Unreferenced cache files", + description: unused_cache_description(&report.unreferenced_cache), + action: StorageAction::CleanUnreferencedCache, + empty: report.unreferenced_cache.is_empty(), + } + .into_element(), + ) + .child( + ReclaimRow { + icon: IconType::Trash01, + title: "Leftover cluster content", + description: legacy_content_description(&report.legacy_cluster_content), + action: StorageAction::CleanLegacyClusterContent, + empty: report.legacy_cluster_content.is_empty(), + } + .into_element(), + ); + + page = page.child(section_header("WHAT'S USING SPACE")); + page = match largest(&report.categories) { + Some(largest) => { + let mut list = page; + for entry in report.categories.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::brand())); + } + list + } + None => page.child(empty_note("Nothing stored yet.")), + }; + + page = page.child(section_header("CLUSTERS")); + page = match largest(&report.clusters) { + Some(largest) => { + let mut list = page; + for entry in report.clusters.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::fg_secondary())); + } + list + } + None => page.child(empty_note("No cluster is using any space.")), + }; + + page.child(footnote()).into_element() + } +} + +/// The total, sized to be read first. +fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { + let reclaimable = report.unreferenced_cache.bytes + report.legacy_cluster_content.bytes; + + let subtitle = if reclaimable > 0 { + format!("{} can be freed", format_bytes(reclaimable)) + } else { + "Nothing to free up".to_string() + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(format_bytes(report.total_bytes)) + .font_size(28.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .color(if reclaimable > 0 { + colors::brand() + } else { + colors::fg_secondary() + }), + ), + ) + .child(refresh) + .into_element() +} + +fn hero_placeholder() -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("Measuring disk usage…") + .font_size(16.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +/// A reclaimable line with its own cleanup button. +/// +/// A component rather than a plain function because it owns a hook. Each +/// instance gets its own hook scope, so the button tracks only its own progress +/// — cleaning the cache does not grey out the leftovers button — and the rows +/// can be built without constraining where the parent calls them. +#[derive(PartialEq)] +struct ReclaimRow { + icon: IconType, + title: &'static str, + description: String, + action: StorageAction, + empty: bool, +} + +impl Component for ReclaimRow { + fn render(&self) -> impl IntoElement { + let action = use_storage_action(); + let running = mutation_is_running(&action); + + let kind = self.action; + let button = Button::new() + .secondary() + .small() + .disabled(self.empty || running) + .on_press(move |_| { + action.mutate(kind); + }) + .child(label().text(if running { "Cleaning…" } else { "Clean up" })); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .maybe(self.empty, |el| el.opacity(0.55)) + .child(Icon::new(self.icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(self.title) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.description.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(button) + .into_element() + } +} + +/// One breakdown line: name, a bar showing its share of the largest item, size, +/// and a way to open it. +fn usage_row(entry: &StorageEntry, largest: u64, bar: Color) -> impl IntoElement { + let fraction = if largest == 0 { + 0.0 + } else { + (entry.bytes as f32 / largest as f32).max(MIN_BAR_FRACTION) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child( + label() + .text(entry.label.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(proportion_bar(fraction, bar)), + ) + .child( + label() + .text(format_bytes(entry.bytes)) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(open_folder_button(entry.path.clone())) + .into_element() +} + +fn proportion_bar(fraction: f32, fill: Color) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::percent(fraction.clamp(0.0, 1.0) * 100.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(2.)) + .background(fill), + ) + .into_element() +} + +fn largest(entries: &[StorageEntry]) -> Option { + entries.iter().map(|e| e.bytes).max().filter(|max| *max > 0) +} + +fn unused_cache_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — every cached file belongs to an installed package.".to_string(); + } + + format!( + "{} across {} file{} that no installed package points at.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn legacy_content_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — no cluster folder is holding old content.".to_string(); + } + + format!( + "{} across {} file{} left in cluster folders by an older version. Already ignored when \ + you play; this just reclaims the space.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn empty_note(text: &'static str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(14., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn footnote() -> impl IntoElement { + rect() + .padding(Gaps::new(16., 4., 8., 4.)) + .child( + label() + .text( + "Packages live in a shared cache and are copied into the game folder only \ + while you play. Removing a package frees its cache entry automatically.", + ) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 256632a3..4c6111bc 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -13,7 +13,7 @@ use crate::error::ClusterResult; use crate::error::ClusterError; use crate::stage::ClusterStage; -pub const DEDICATED_MARKER: &str = ".dedicated_directory"; +pub use oneclient_common::paths::DEDICATED_MARKER; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { @@ -75,7 +75,7 @@ impl Cluster { } pub fn dir(&self) -> ClusterResult { - Ok(paths::clusters_dir()?.join(&self.folder_name)) + Ok(paths::cluster_dir(&self.folder_name)?) } pub fn dedicated_marker(&self) -> ClusterResult { @@ -83,15 +83,11 @@ impl Cluster { } pub fn uses_dedicated_dir(&self) -> bool { - self.dedicated_marker().map(|p| p.exists()).unwrap_or(false) + paths::cluster_uses_dedicated_dir(&self.folder_name) } pub fn game_dir(&self) -> ClusterResult { - if self.uses_dedicated_dir() { - self.dir() - } else { - Ok(paths::shared_minecraft_dir()?) - } + Ok(paths::cluster_game_dir(&self.folder_name)?) } pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 410d1e83..e821897b 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -70,6 +70,31 @@ pub fn shared_minecraft_dir() -> PathsResult { Ok(launcher_dir()?.join(".minecraft")) } +/// Marks a cluster folder as being its own game directory rather than playing +/// out of the shared `.minecraft`. +pub const DEDICATED_MARKER: &str = ".dedicated_directory"; + +pub fn cluster_dir(folder_name: &str) -> PathsResult { + Ok(clusters_dir()?.join(folder_name)) +} + +pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { + cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) +} + +/// Where a cluster's game actually runs. +/// +/// Lives here rather than on `Cluster` because the content layer has to resolve +/// it from a bare `ClusterRow` too, and a second copy of the marker-file rule is +/// exactly the kind of drift that leaves files behind. +pub fn cluster_game_dir(folder_name: &str) -> PathsResult { + if cluster_uses_dedicated_dir(folder_name) { + cluster_dir(folder_name) + } else { + shared_minecraft_dir() + } +} + 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 b9be5fdb..76c53eb0 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -12,8 +12,7 @@ use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; use oneclient_common::domain::{ContentType, GameLoader}; -use crate::packages::error::PackageError; -use crate::packages::store::{PackageStore, unlink_cluster_file}; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; use crate::packages::types::ExternalFile; use crate::ctx::ContentCtx; @@ -188,22 +187,17 @@ pub async fn install_enabled_bundle_files( let bundle_name = archive.manifest.name.clone(); let mut installed = Vec::new(); - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let clusters_dir = oneclient_common::paths::clusters_dir()?; + // "Already installed" is what the database says, not what is on disk right + // now: content lives in the cache between sessions, so probing the folder + // would report every package as missing and reinstall the whole bundle. for info in &linked { - let path = clusters_dir - .join(&cluster.folder_name) - .join(info.content_type.folder_name()) - .join(&info.cluster_file_name); - if path.exists() { - if let Some(pid) = &info.project_id { - linked_projects.insert(pid.as_str()); - } - linked_hashes.insert(info.hash.as_str()); + if let Some(pid) = &info.project_id { + linked_projects.insert(pid.as_str()); } + linked_hashes.insert(info.hash.as_str()); } let to_install: Vec = archive @@ -465,16 +459,12 @@ pub async fn on_user_remove_artifact( /// storage half and knows nothing about bundles; this adds the bookkeeping that /// only makes sense once bundles exist. #[tracing::instrument(level = "debug", skip(ctx))] -/// -/// `live` says the cluster is currently being played; see -/// [`PackageStore::set_artifact_enabled`]. pub async fn toggle_artifact_enabled( cluster_id: i64, hash: &str, - live: bool, ctx: &ContentCtx, ) -> ContentResult { - let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, live, ctx).await?; + let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; if enabled { on_user_enable_artifact(cluster_id, hash, ctx).await?; @@ -542,26 +532,39 @@ pub async fn remove_artifact_from_cluster( ctx: &ContentCtx, ) -> ContentResult<()> { let bundle_data = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?; + + // Everything needed for the eager folder cleanup is looked up first, but + // none of it is allowed to block the removal itself. A package whose + // artifact row has gone missing still has to be removable. let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + let target = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? - .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)); - let content_type = ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { - ContentError::InvalidData { - reason: format!("unknown content type {}", artifact.content_type), - } - })?; - - if let Some(link) = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id) + let link = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id) .await? .into_iter() - .find(|l| l.hash == hash) - { - unlink_cluster_file(&cluster, content_type, &link.cluster_file_name).await?; + .find(|l| l.hash == hash); + + // The database first, and unconditionally. Removal used to delete the file + // before dropping the row, so a jar held open by a running game — on Windows + // that blocks deleting every hard link to it — failed the whole operation + // and left the package installed. Dropping the row always succeeds, and the + // game folder is rebuilt from the database at the next launch. + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + // Then a best-effort pass at the folder so it matches straight away when + // nothing is holding the file. Failure here is not an error. + if let (Some(content_type), Some(link)) = (target, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; } - artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + // The cache is where the package actually lives, so it goes too once the + // last cluster stops asking for it. Still installed elsewhere means still + // needed, which `evict_if_unused` checks for us. + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); + } if let Some(tracked) = bundle_data && let (Some(bundle_name), Some(package_id)) = diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index cf25db9b..3493b068 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -556,7 +556,7 @@ async fn reconcile_update( ) -> ContentResult<()> { let file_id = update.new_file.kind.package_id(); if should_be_disabled(&update.bundle_name, &file_id, overrides) { - toggle_artifact_enabled(update.cluster_id, hash, false, ctx).await?; + toggle_artifact_enabled(update.cluster_id, hash, ctx).await?; } if hash == update.installed_hash { @@ -575,7 +575,7 @@ async fn reconcile_addition( ) -> ContentResult<()> { let file_id = addition.new_file.kind.package_id(); if should_be_disabled(&addition.bundle_name, &file_id, overrides) { - toggle_artifact_enabled(addition.cluster_id, hash, false, ctx).await?; + toggle_artifact_enabled(addition.cluster_id, hash, ctx).await?; } Ok(()) diff --git a/packages/oneclient_content/src/packages/store/gc.rs b/packages/oneclient_content/src/packages/store/gc.rs new file mode 100644 index 00000000..c766b4b4 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/gc.rs @@ -0,0 +1,309 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use oneclient_common::paths; + +use super::paths::{artifact_absolute_path, relative_cache_path}; + +/// How recently a cache file must have been touched to be spared. +/// +/// A download lands in the cache *before* its row is inserted, so an in-flight +/// fetch looks exactly like an orphan. Orphans are permanent and cost nothing to +/// collect later, whereas deleting a half-written download costs the user the +/// transfer — so the sweep stays well clear of anything recent. +const RECENT_GRACE: Duration = Duration::from_secs(60 * 60); + +#[derive(Debug, Default, Clone, Copy)] +pub struct GcReport { + pub artifacts_removed: usize, + pub files_removed: usize, + pub bytes_freed: u64, +} + +/// Drops an artifact and its cached file if no cluster refers to it. +/// +/// Returns whether it went. A package still installed somewhere else is left +/// alone, which is what makes this safe to call on every removal. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn evict_if_unused(hash: &str, ctx: &ContentCtx) -> ContentResult { + // Read the row first: once it is deleted there is no way back to the file. + let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { + return Ok(false); + } + + if let Some(row) = row + && let Ok(path) = artifact_absolute_path(&row.path) + { + remove_cached_file(&path).await; + } + + Ok(true) +} + +/// Drops every artifact no cluster refers to any more, and its cached file. +/// +/// Safe to run unattended: the database is asked which artifacts are unused, so +/// there is no guessing from the filesystem. Runs at startup, which is what +/// reclaims the space freed by deleting a cluster or by a bundle update swapping +/// one version for the next. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn collect_unused_artifacts(ctx: &ContentCtx) -> ContentResult { + let mut report = GcReport::default(); + + for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { + let path = artifact_absolute_path(&row.path).ok(); + + // An install inserts the artifact row and then links it to its cluster. + // Between the two it looks unused, so anything freshly downloaded is left + // for the next sweep rather than pulled out from under an install. + if let Some(path) = &path + && is_recent(path).await + { + continue; + } + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { + continue; + } + report.artifacts_removed += 1; + + if let Some(path) = path { + report.bytes_freed += file_len(&path).await; + remove_cached_file(&path).await; + } + } + + if report.artifacts_removed > 0 { + tracing::info!( + artifacts = report.artifacts_removed, + mb_freed = report.bytes_freed / 1_000_000, + "reclaimed unused packages" + ); + } + + Ok(report) +} + +/// What [`remove_unreferenced_files`] would delete, without deleting it. +/// +/// The Storage page shows this so the user is agreeing to a number rather than +/// to a promise. +pub async fn find_unreferenced_files(ctx: &ContentCtx) -> ContentResult> { + let cache_root = paths::packages_cache_dir()?; + if !polyio::try_exists(&cache_root).await.unwrap_or(false) { + return Ok(Vec::new()); + } + + let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) + .await? + .into_iter() + .collect(); + + // A populated cache with nothing in the artifacts table is not a cache full + // of garbage — it is a database that has lost its index and has not been + // reconstructed yet. Reading it the other way would offer to wipe every + // package the user has. + if known.is_empty() { + tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); + return Ok(Vec::new()); + } + + let mut found = Vec::new(); + for file in walk_files(&cache_root).await { + let Ok(relative) = relative_cache_path(&file) else { + continue; + }; + if known.contains(&relative) || is_recent(&file).await { + continue; + } + + let size = file_len(&file).await; + found.push((file, size)); + } + + Ok(found) +} + +/// Deletes cached files that no artifact row points at. +/// +/// User-initiated only. Unlike [`collect_unused_artifacts`] this reasons from +/// the filesystem rather than from the database, so it is the half that can +/// misread an install whose database has been reset but not yet reconstructed. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn remove_unreferenced_files(ctx: &ContentCtx) -> ContentResult { + let cache_root = paths::packages_cache_dir()?; + let mut report = GcReport::default(); + + for (file, size) in find_unreferenced_files(ctx).await? { + if polyio::remove_file(&file).await.is_ok() { + report.files_removed += 1; + report.bytes_freed += size; + prune_empty_parents(&file, &cache_root).await; + } + } + + if report.files_removed > 0 { + tracing::info!( + files = report.files_removed, + mb_freed = report.bytes_freed / 1_000_000, + "removed unreferenced cache files" + ); + } + + Ok(report) +} + +async fn remove_cached_file(path: &Path) { + if polyio::remove_file(path).await.is_err() { + return; + } + + if let Ok(root) = paths::packages_cache_dir() { + prune_empty_parents(path, &root).await; + } +} + +/// Walks back up `////` removing the +/// directories a deleted file has left empty, stopping at the cache root. +async fn prune_empty_parents(file: &Path, root: &Path) { + let mut dir = file.parent().map(Path::to_path_buf); + + while let Some(current) = dir { + if current == root || !current.starts_with(root) { + return; + } + + // `remove_dir` refuses a non-empty directory, which is exactly the stop + // condition — no need to read the entries first. + if polyio::remove_dir(¤t).await.is_err() { + return; + } + + dir = current.parent().map(Path::to_path_buf); + } +} + +async fn is_recent(path: &Path) -> bool { + let Ok(meta) = polyio::stat(path).await else { + // Cannot tell how old it is, so leave it be. + return true; + }; + + meta.modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_none_or(|age| age < RECENT_GRACE) +} + +async fn file_len(path: &Path) -> u64 { + polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) +} + +async fn walk_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_dir() { + stack.push(entry.path()); + } else if file_type.is_file() { + files.push(entry.path()); + } + } + } + + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_parents_go_but_the_root_stays() { + let root = polyio::testing::ScratchDir::new("gc_prune"); + let cache = root.join("packages"); + let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&nested).await.unwrap(); + + let file = nested.join("a.jar"); + polyio::write(&file, b"jar".as_slice()).await.unwrap(); + polyio::remove_file(&file).await.unwrap(); + + prune_empty_parents(&file, &cache).await; + + assert!(!cache.join("mod").exists(), "empty chain should be gone"); + assert!(cache.exists(), "the cache root itself always stays"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn a_sibling_stops_the_prune() { + let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); + let cache = root.join("packages"); + let version = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&version).await.unwrap(); + + let gone = version.join("a.jar"); + polyio::write(&gone, b"jar".as_slice()).await.unwrap(); + polyio::write(version.join("b.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::remove_file(&gone).await.unwrap(); + + prune_empty_parents(&gone, &cache).await; + + assert!(version.exists(), "a directory with another file survives"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A download writes its file before inserting the row, so a fresh + /// unreferenced file is very likely one in flight. + #[tokio::test] + async fn fresh_files_are_spared() { + let root = polyio::testing::ScratchDir::new("gc_recent"); + polyio::create_dir_all(root.path()).await.unwrap(); + + let file = root.join("downloading.jar"); + polyio::write(&file, b"partial".as_slice()).await.unwrap(); + + assert!(is_recent(&file).await, "a just-written file is not garbage"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn walk_finds_files_at_every_depth() { + let root = polyio::testing::ScratchDir::new("gc_walk"); + polyio::create_dir_all(root.join("a").join("b")).await.unwrap(); + polyio::write(root.join("top.jar"), b"1".as_slice()).await.unwrap(); + polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()).await.unwrap(); + polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) + .await + .unwrap(); + + let found = walk_files(root.path()).await; + + assert_eq!(found.len(), 3, "found: {found:?}"); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 975e3f31..9af6c89a 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,59 +1,21 @@ use std::path::Path; -use oneclient_db::models::{ArtifactRow, ClusterRow}; +use oneclient_db::models::ClusterRow; -use super::paths::artifact_absolute_path; -use polyio::sha1_file; use oneclient_common::domain::ContentType; -use crate::packages::error::PackageError; use oneclient_common::paths; -use crate::error::{ContentError, ContentResult}; +use crate::error::ContentResult; -#[tracing::instrument(level = "debug", skip(artifact, cluster))] -pub async fn link_artifact_to_cluster( - artifact: &ArtifactRow, - cluster: &ClusterRow, - cluster_file_name: Option<&str>, -) -> ContentResult<()> { - let src = artifact_absolute_path(&artifact.path)?; - if !src.exists() { - return Err(PackageError::ArtifactMissing(src.display().to_string()).into()); - } - - let content_type = ContentType::from_repr(artifact.content_type as u8) - .ok_or_else(|| ContentError::InvalidData { - reason: format!("unknown content type {}", artifact.content_type), - })?; - - let cluster_root = paths::clusters_dir()?.join(&cluster.folder_name); - let dest_dir = cluster_root.join(content_type.folder_name()); - polyio::create_dir_all(&dest_dir).await?; - - let file_name = cluster_file_name.unwrap_or(&artifact.file_name); - let dest = dest_dir.join(file_name); - - link_or_copy(&src, &dest).await?; - - if dest.exists() { - let actual = sha1_file(&dest).await?; - if actual != artifact.hash { - return Err(PackageError::HashMismatch { - expected: artifact.hash.clone(), - actual, - } - .into()); - } - } - - Ok(()) -} +use super::manifest; #[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { - if dest.exists() { - polyio::remove_file(dest).await?; + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; } + remove_entry(dest).await?; + if polyio::symlink_file(src, dest).await.is_ok() { return Ok(()); } @@ -62,49 +24,12 @@ pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { Ok(()) } -/// Whether the cluster folder is currently holding `file_name`, following the -/// same symlink-tolerant rule as [`unlink_cluster_file`]. -pub async fn cluster_file_present( - cluster: &ClusterRow, - content_type: ContentType, - file_name: &str, -) -> bool { - let Ok(clusters) = paths::clusters_dir() else { - return false; - }; - - let path = clusters - .join(&cluster.folder_name) - .join(content_type.folder_name()) - .join(file_name); - - polyio::symlink_metadata(&path).await.is_ok() -} - -#[tracing::instrument(level = "debug", skip(cluster))] -pub async fn unlink_cluster_file( - cluster: &ClusterRow, - content_type: ContentType, - file_name: &str, -) -> ContentResult<()> { - let dir = paths::clusters_dir()? - .join(&cluster.folder_name) - .join(content_type.folder_name()); - - // Both spellings: the database always stores the bare name, but the folder - // can still be holding a `.disabled` left by an older launcher or by - // the user renaming it by hand. Removing a package has to take both. - remove_entry(&dir.join(file_name)).await?; - remove_entry(&dir.join(format!("{file_name}.disabled"))).await?; - Ok(()) -} - /// Deletes `path` if anything is there, symlink or not. /// /// `Path::exists` resolves symlinks, so a link whose artifact was evicted from /// the cache reads as absent and the dead link survives every unlink; the /// package then keeps showing up in the folder it was removed from. -async fn remove_entry(path: &Path) -> ContentResult<()> { +pub async fn remove_entry(path: &Path) -> ContentResult<()> { if polyio::symlink_metadata(path).await.is_err() { return Ok(()); } @@ -112,3 +37,91 @@ async fn remove_entry(path: &Path) -> ContentResult<()> { polyio::remove_file(path).await?; Ok(()) } + +/// Drops a package out of the game folder right now, if that is possible. +/// +/// Purely an optimisation for what the user sees: removal is a database +/// operation, and the folder is brought in line at the next launch regardless. +/// A running game holds its jars open — on Windows that blocks deleting *any* +/// hard link to them — so failure here is expected and is not an error. +/// +/// Returns whether anything was actually deleted. +#[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 { + return false; + }; + + let Some(mut loaded) = manifest::load(&game_dir).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 content, or a + // file the user placed by hand. + let relative = manifest::entry_path(content_type.folder_name(), file_name); + if !loaded.owns(cluster.id, &relative) { + return false; + } + + let path = game_dir.join(content_type.folder_name()).join(file_name); + if let Err(err) = remove_entry(&path).await { + tracing::debug!( + file = file_name, + error = %err, + "could not drop package from the game folder now; it goes at the next launch" + ); + return false; + } + + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(&game_dir, &loaded).await; + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A dead link — one whose target was deleted underneath it — has to go. + /// `Path::exists` follows the link and reports it absent, which is what let + /// these survive every unlink. + #[tokio::test] + async fn remove_entry_clears_a_dangling_link() { + let root = polyio::testing::ScratchDir::new("dangling_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 resolves to nothing"); + assert!( + polyio::symlink_metadata(&link).await.is_ok(), + "but the link itself is still there" + ); + + 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"); + polyio::create_dir_all(root.path()).await.unwrap(); + + remove_entry(&root.join("never_existed.jar")).await.unwrap(); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs new file mode 100644 index 00000000..67fdd490 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -0,0 +1,167 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; + +const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + /// Slash-separated, relative to the game directory, e.g. `mods/sodium.jar`. + pub path: String, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterializedManifest { + pub version: u32, + /// Which cluster last materialized into this directory. The shared + /// `.minecraft` is used by every non-dedicated cluster in turn, so a + /// manifest found there often belongs to somebody else. + pub cluster_id: i64, + pub entries: Vec, +} + +impl MaterializedManifest { + #[must_use] + pub fn new(cluster_id: i64, entries: Vec) -> Self { + Self { + version: MANIFEST_VERSION, + cluster_id, + entries, + } + } + + #[must_use] + pub fn paths(&self) -> HashSet<&str> { + self.entries.iter().map(|e| e.path.as_str()).collect() + } + + #[must_use] + pub fn contains(&self, relative_path: &str) -> bool { + self.entries.iter().any(|e| e.path == relative_path) + } + + /// Whether this manifest lists `relative_path` as belonging to `cluster_id`. + /// + /// The cluster check is what makes it safe to delete out of the shared game + /// directory: another cluster's live content is never listed under ours. + #[must_use] + pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { + self.cluster_id == cluster_id && self.contains(relative_path) + } +} + +#[must_use] +pub fn manifest_path(game_dir: &Path) -> PathBuf { + game_dir.join(MANIFEST_NAME) +} + +/// Reads the manifest, or `None` when there is none or it cannot be understood. +/// +/// A manifest we cannot parse is treated as absent rather than as an error: the +/// worst case is that one session's links get stashed as user content, which is +/// recoverable, whereas refusing to launch over it is not. +pub async fn load(game_dir: &Path) -> Option { + let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; + + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), + Ok(manifest) => { + tracing::warn!( + version = manifest.version, + "ignoring materialized manifest written by a different launcher version" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); + None + } + } +} + +pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { + let body = match serde_json::to_vec_pretty(manifest) { + Ok(body) => body, + Err(err) => { + tracing::warn!(error = %err, "failed to encode materialized manifest"); + return; + } + }; + + if let Err(err) = polyio::write(manifest_path(game_dir), 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(); +} + +/// Builds the relative path a content file is materialized at. +#[must_use] +pub fn entry_path(content_folder: &str, file_name: &str) -> String { + format!("{content_folder}/{file_name}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> MaterializedManifest { + MaterializedManifest::new( + 7, + vec![ManifestEntry { + path: "mods/sodium.jar".into(), + hash: "abc".into(), + }], + ) + } + + #[test] + fn ownership_is_scoped_to_the_cluster_that_wrote_it() { + let manifest = manifest(); + + assert!(manifest.owns(7, "mods/sodium.jar")); + // The shared game dir is reused; another cluster's launch must not treat + // this entry as its own to delete. + assert!(!manifest.owns(8, "mods/sodium.jar")); + assert!(!manifest.owns(7, "mods/iris.jar")); + } + + #[tokio::test] + async fn round_trips_through_the_game_dir() { + let root = polyio::testing::ScratchDir::new("manifest_round_trip"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + assert!(load(dir).await.is_none(), "no manifest yet"); + + save(dir, &manifest()).await; + let loaded = load(dir).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"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn garbage_manifest_reads_as_absent() { + 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()) + .await + .unwrap(); + + assert!(load(dir).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 56dc4964..4cec1256 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -1,9 +1,15 @@ mod download; +mod gc; mod link; +pub mod manifest; mod paths; pub use download::{download_external, download_version_file, ensure_artifact_file}; -pub use link::{link_artifact_to_cluster, link_or_copy, unlink_cluster_file}; +pub use gc::{ + GcReport, collect_unused_artifacts, evict_if_unused, find_unreferenced_files, + remove_unreferenced_files, +}; +pub use link::{link_or_copy, remove_entry, try_unlink_materialized}; pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; @@ -100,6 +106,13 @@ impl PackageStore { Ok(artifact) } + /// Records that a cluster wants this artifact. + /// + /// Nothing is written to the cluster or game folder here. The artifact stays + /// in the cache and is materialized into the game directory at launch, which + /// is the only moment the launcher can be sure no game is holding the files + /// open. See [`crate::packages::store::manifest`] for why the folder is not + /// kept in sync eagerly. #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] pub async fn link_artifact( artifact: &ArtifactRow, @@ -109,7 +122,6 @@ impl PackageStore { ) -> ContentResult<()> { let name = cluster_file_name.unwrap_or(&artifact.file_name); - link::link_artifact_to_cluster(artifact, cluster, Some(name)).await?; artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; Ok(()) @@ -170,14 +182,14 @@ impl PackageStore { } #[tracing::instrument(level = "debug", skip(ctx))] - /// Flips an artifact's enabled flag and relinks it on disk, returning the - /// new state. + /// Flips an artifact's enabled flag, returning the new state. /// - /// `live` says a session is currently playing this cluster. Minecraft reads - /// its mods once at startup and holds the jars open, so the on-disk half - /// cannot take effect and, on Windows, cannot even be performed. The flag is - /// recorded either way and [`Self::reconcile_cluster_links`] settles the - /// folder at the next launch, so the choice survives instead of failing. + /// The flag is all that is recorded. Minecraft reads its mods once at + /// startup and holds the jars open for the rest of the session, so there is + /// no useful moment to rewrite the folder other than the next launch — and + /// on Windows a running game makes it impossible anyway. Disabling makes a + /// best-effort attempt to drop the file now purely so the folder matches + /// what the user just did; whether it lands changes nothing. /// /// Storage only: bundle override bookkeeping is composed on top by /// [`crate::bundles::toggle_artifact_enabled`], because that is the layer that @@ -186,7 +198,6 @@ impl PackageStore { pub async fn set_artifact_enabled( cluster_id: i64, hash: &str, - live: bool, ctx: &ContentCtx, ) -> ContentResult { let cluster = Self::get_cluster(cluster_id, ctx).await?; @@ -209,8 +220,6 @@ impl PackageStore { .trim_end_matches(".disabled") .to_string(); - // The database is the source of truth, so it goes first: a folder that - // cannot be touched right now must not cost the user their choice. artifact_dao::update_cluster_artifact( &ctx.db, cluster_id, @@ -220,80 +229,16 @@ impl PackageStore { ) .await?; - let applied = apply_enabled_to_disk( - &artifact, - &cluster, - content_type, - &link.cluster_file_name, - &file_name, - enabled, - ) - .await; - - match applied { - Ok(()) => {} - Err(err) if live => tracing::info!( - cluster_id, - hash, - error = %err, - "cluster folder is in use by a running game; the change applies at the next launch" - ), - Err(err) => return Err(err), + if !enabled { + link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + if link.cluster_file_name != file_name { + link::try_unlink_materialized(&cluster, content_type, &file_name).await; + } } Ok(enabled) } - /// Makes the cluster folder match the database: every enabled artifact - /// linked in, every disabled one gone. - /// - /// Toggles made while the cluster was being played only reached the - /// database, so this is what lands them. Files already in the right state - /// are left alone, and failures are logged rather than returned: one - /// unwritable file must not stop a launch. - #[tracing::instrument(level = "debug", skip(ctx))] - pub async fn reconcile_cluster_links(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<()> { - let cluster = Self::get_cluster(cluster_id, ctx).await?; - - for link in artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await? { - let Some(artifact) = artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await? - else { - continue; - }; - let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { - continue; - }; - - let enabled = link.enabled != 0; - let present = - link::cluster_file_present(&cluster, content_type, &link.cluster_file_name).await; - if enabled == present { - continue; - } - - let result = apply_enabled_to_disk( - &artifact, - &cluster, - content_type, - &link.cluster_file_name, - &link.cluster_file_name, - enabled, - ) - .await; - - if let Err(err) = result { - tracing::warn!( - cluster_id, - file = %link.cluster_file_name, - error = %err, - "failed to reconcile cluster link" - ); - } - } - - Ok(()) - } - #[tracing::instrument(level = "debug", skip(ctx))] pub async fn import_local_file( path: &Path, @@ -392,30 +337,6 @@ async fn row_to_cached( }) } -/// Brings one artifact's presence in the cluster folder in line with `enabled`. -/// -/// `current_file_name` is what the folder may be holding, `file_name` the name -/// the link should carry; they differ only while a legacy `.disabled` spelling -/// is being cleaned up. -async fn apply_enabled_to_disk( - artifact: &ArtifactRow, - cluster: &ClusterRow, - content_type: ContentType, - current_file_name: &str, - file_name: &str, - enabled: bool, -) -> ContentResult<()> { - if enabled { - return link::link_artifact_to_cluster(artifact, cluster, Some(file_name)).await; - } - - link::unlink_cluster_file(cluster, content_type, current_file_name).await?; - if current_file_name != file_name { - link::unlink_cluster_file(cluster, content_type, file_name).await?; - } - Ok(()) -} - fn ensure_compatible( project: &ProjectDetail, version: &VersionDetail, diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index eb9000fb..baf838d2 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -9,10 +9,12 @@ mod migrate; mod prepare; mod provision; +mod unlink_legacy; pub use migrate::apply_remote_migrations; pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; pub use provision::{ensure_from_bundles, ensure_from_versions}; +pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; // Re-exported so the rest of the launcher keeps addressing these through // `clusters::`, as it did before the split. diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs new file mode 100644 index 00000000..47bfb6a0 --- /dev/null +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -0,0 +1,193 @@ +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::store::manifest::{ + self, ManifestEntry, MaterializedManifest, +}; +use oneclient_content::packages::store::artifact_absolute_path; + +const SWEPT_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Default, Clone, Copy)] +pub struct SweepReport { + pub removed: usize, + pub adopted: usize, + pub skipped: usize, +} + +/// Clears out the content links older launchers wrote into cluster folders. +/// +/// Deliberately **not** automatic. The rule it applies — "this file's hash +/// matches a cached artifact, so it is one of ours" — is true during the +/// transition and false afterwards, once the cluster folder becomes the stash +/// for the user's own content. Running it on a schedule would need a flag to +/// stop it running twice, and that flag would be one more thing to get out of +/// step with the disk it describes. Instead the Storage settings page reports +/// what is there and the user asks for it. +/// +/// Nothing depends on this having run: [`crate::game::materialize_content`] +/// ignores leftovers either way. This only reclaims the space. +#[tracing::instrument(skip(state))] +pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut report = SweepReport::default(); + + for cluster in state.clusters.list().await? { + let dedicated = cluster.uses_dedicated_dir(); + let Ok(cluster_root) = cluster.dir() else { + continue; + }; + + if dedicated { + adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + } else { + sweep_shared(state, &cluster_root, &mut report).await; + } + } + + if report.removed > 0 || report.adopted > 0 { + tracing::info!( + removed = report.removed, + adopted = report.adopted, + skipped = report.skipped, + "cleaned up legacy cluster content links" + ); + } + + Ok(report) +} + +/// Deletes launcher-owned files out of a shared cluster's content folders. +async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { + for content_type in SWEPT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + // A symlink here is unambiguously one of ours: this is where the old + // scheme put them, and nothing else writes links into these folders. + if file_type.is_symlink() { + polyio::remove_file(&path).await.ok(); + report.removed += 1; + continue; + } + + if !file_type.is_file() { + continue; + } + + match cached_artifact_for(state, &path).await { + Some(CacheMatch::Elsewhere) => { + if polyio::remove_file(&path).await.is_ok() { + report.removed += 1; + } + } + // The cache row points at this very file — an artifact adopted by + // an older recovery pass, which stored content in place instead of + // in the cache. Deleting it would destroy the only copy. + Some(CacheMatch::IsTheArtifact) => { + tracing::debug!( + path = %path.display(), + "leaving cluster file in place; it is the artifact's only copy" + ); + report.skipped += 1; + } + None => report.skipped += 1, + } + } + } +} + +/// Gives a dedicated cluster's already-materialized content a manifest, so later +/// launches can tell it apart from the user's own files. +async fn adopt_dedicated( + state: &LauncherState, + cluster: &crate::clusters::Cluster, + cluster_root: &Path, + report: &mut SweepReport, +) { + if manifest::load(cluster_root).await.is_some() { + return; + } + + let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( + cluster.id, + &state.services.content(), + ) + .await + { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); + return; + } + }; + + let mut entries = Vec::new(); + for link in linked { + if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + continue; + } + + let relative = + manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); + if polyio::symlink_metadata(cluster_root.join(&relative)) + .await + .is_err() + { + continue; + } + + entries.push(ManifestEntry { + path: relative, + hash: link.hash, + }); + } + + report.adopted += entries.len(); + manifest::save(cluster_root, &MaterializedManifest::new(cluster.id, entries)).await; +} + +enum CacheMatch { + /// The cache holds this content at its own path; the cluster copy is a link. + Elsewhere, + /// The artifact row points at this exact file. + IsTheArtifact, +} + +async fn cached_artifact_for(state: &LauncherState, path: &Path) -> Option { + let hash = polyio::sha1_file(path).await.ok()?; + let row = artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten()?; + + let canonical = artifact_absolute_path(&row.path).ok()?; + if same_file(&canonical, path) { + Some(CacheMatch::IsTheArtifact) + } else { + Some(CacheMatch::Elsewhere) + } +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 863d322e..49258132 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -213,23 +213,15 @@ pub async fn launch_cluster( tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); } - // Settle any enable/disable made while a previous session held these files - // open. Shared clusters get their game dir rebuilt from the database below - // anyway; this is what makes the cluster folder itself — and so a dedicated - // cluster's game dir — agree with what the UI has been showing. - if let Err(err) = oneclient_content::packages::PackageStore::reconcile_cluster_links( - cluster_id, - &state.services.content(), - ) - .await - { - tracing::warn!(cluster_id, error = %err, "failed to reconcile cluster content links"); + // The one moment nothing is holding the content open: put what the database + // says into the game dir, and clear out what it no longer says. This is + // where a package removed or disabled mid-session actually leaves the + // folder, for dedicated and shared clusters alike. + if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } if !dedicated { - if let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await { - tracing::warn!(cluster_id, error = %err, "failed to sync shared content"); - } // Redirect the shared dir's `logs`/`crash-reports` into this cluster's // own folder so its output is attributable while it plays; unlinked on // exit. Keeps the shared `.minecraft` (and the launcher's own logs dir) @@ -504,9 +496,14 @@ pub(crate) async fn finalize_session( run_hook(post_hook, cwd).await; - if !dedicated { + if dedicated { + // The folder stays materialized between sessions so it remains a real + // Minecraft directory for external tools; the next launch reconciles it. + // Adopting drop-ins now just means the UI is right the moment the game + // closes rather than at the next launch. crate::game::import_manual_content(&state.services, cluster, cwd).await; - if let Err(err) = crate::game::clear_shared_content(&state.services, cluster, cwd).await { + } else { + if let Err(err) = crate::game::dematerialize_content(&state.services, cluster, cwd).await { tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); } crate::game::unlink_cluster_logs(cwd).await; diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index fda23d5e..7b14b2be 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -31,6 +31,6 @@ pub use oneclient_mc::{ libraries_missing, plan_downloads, resolve_minecraft_version, validate_rules, }; pub use shared_dir::{ - clear_shared_content, import_manual_content, link_cluster_logs, sync_shared_content, + 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 f1068302..15064533 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -7,8 +7,11 @@ use oneclient_db::dao::artifact as artifact_dao; use crate::LauncherResult; use crate::clusters::Cluster; use oneclient_common::domain::ContentType; -use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy}; -use oneclient_content::packages::{LinkedArtifactInfo, PackageStore}; +use oneclient_content::packages::store::manifest::{ + self, ManifestEntry, MaterializedManifest, +}; +use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy, remove_entry}; +use oneclient_content::packages::PackageStore; use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; @@ -21,32 +24,117 @@ const SWAP_TYPES: [ContentType; 3] = [ const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; +/// One file the database wants in the game directory. +struct Desired { + content_type: ContentType, + file_name: String, + hash: String, + /// Where it lives in the artifact cache. + src: std::path::PathBuf, +} + +impl Desired { + fn relative_path(&self) -> String { + manifest::entry_path(self.content_type.folder_name(), &self.file_name) + } +} + +/// Brings the game directory in line with the database, ready for a launch. +/// +/// Safe to run over a directory left behind by a crashed session, by a +/// different cluster, or by a launcher version that predates the manifest. #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] -pub async fn sync_shared_content( +pub async fn materialize_content( services: &LauncherServices, cluster: &Cluster, game_dir: &Path, ) -> LauncherResult<()> { + let dedicated = cluster.uses_dedicated_dir(); polyio::create_dir_all(game_dir).await.ok(); + // What we put here last time. In the shared directory this often belongs to + // another cluster, which is why every use of it checks the id. + let previous = manifest::load(game_dir).await; + import_manual_content(services, cluster, game_dir).await; - let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; + let desired = desired_content(services, cluster).await?; + let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); + + // Anything we materialized before and no longer want goes now, while the + // game is still closed. This is what lands a package the user removed (or + // disabled) during a session, and what clears another cluster's content out + // of the shared directory. + prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + + if !dedicated { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + for content_type in SWAP_TYPES { + let dir = game_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 last (or crashed + // last), so take it into this cluster rather than deleting it. + 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; + } + } + + let entries = link_desired(game_dir, &desired).await; + manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + + sync_fabric_dep_overrides(cluster, game_dir).await?; + + Ok(()) +} + +/// Empties the shared game directory after a session. +/// +/// Only for shared directories: a dedicated cluster's content stays put between +/// sessions, and [`materialize_content`] reconciles it at the next launch. +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn dematerialize_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + // Runs first so anything the user dropped in during the session is a tracked + // artifact 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 linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); let stash = cluster.dir()?.join(content_type.folder_name()); polyio::create_dir_all(&dir).await.ok(); - let managed = managed_names(&linked, content_type); - - // Anything still here belongs to whoever played last (or crashed last), so - // take it into this cluster rather than deleting it. - stash_content_files(&dir, &stash, &managed).await; + let ours = ours_in_folder(content_type, &linked, current.as_ref()); + stash_content_files(&dir, &stash, &ours).await; ensure_note(&dir, content_type).await; - restore_stashed(&stash, &dir, &managed).await; } + manifest::clear(game_dir).await; + Ok(()) +} + +/// The enabled, cached content the database wants materialized. +async fn desired_content( + services: &LauncherServices, + cluster: &Cluster, +) -> LauncherResult> { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; + let mut desired = Vec::with_capacity(linked.len()); + for link in linked { if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { continue; @@ -59,26 +147,107 @@ pub async fn sync_shared_content( let src = artifact_absolute_path(&artifact.path)?; if !polyio::try_exists(&src).await.unwrap_or(false) { - tracing::warn!(hash = %link.hash, "cached artifact missing; skipping shared link"); + tracing::warn!(hash = %link.hash, "cached artifact missing; skipping"); continue; } - let dest = game_dir - .join(link.content_type.folder_name()) - .join(&link.cluster_file_name); + desired.push(Desired { + content_type: link.content_type, + file_name: link.cluster_file_name, + hash: link.hash, + src, + }); + } - if let Err(err) = link_or_copy(&src, &dest).await { - tracing::warn!( - file = %link.cluster_file_name, + Ok(desired) +} + +/// Links the wanted content in, returning what actually landed. +/// +/// Only files that made it are recorded, so a link that failed is never later +/// mistaken for ours and deleted out from under the user. +async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { + let mut entries = Vec::with_capacity(desired.len()); + + for item in desired { + let dest = game_dir + .join(item.content_type.folder_name()) + .join(&item.file_name); + + match link_or_copy(&item.src, &dest).await { + Ok(()) => entries.push(ManifestEntry { + path: item.relative_path(), + hash: item.hash.clone(), + }), + Err(err) => tracing::warn!( + file = %item.file_name, error = %err, - "failed to link content into shared directory" - ); + "failed to materialize content into the game directory" + ), } } - sync_fabric_dep_overrides(cluster, game_dir).await?; + entries +} - Ok(()) +/// Deletes everything the previous manifest listed that is not wanted now. +/// +/// Entries are keyed by path, so a package whose file name is unchanged is left +/// in place rather than being deleted and relinked on every launch. +async fn prune_previous( + game_dir: &Path, + previous: Option<&MaterializedManifest>, + keep: &HashSet, +) { + let Some(previous) = previous else { + return; + }; + + for entry in &previous.entries { + if keep.contains(&entry.path) { + continue; + } + + let path = game_dir.join(&entry.path); + if let Err(err) = remove_entry(&path).await { + tracing::warn!( + file = %entry.path, + error = %err, + "failed to clear stale materialized content" + ); + } + } +} + +/// The names in one content folder that are the launcher's rather than the +/// user's: what we materialized last, plus everything the database currently +/// tracks for this cluster. +/// +/// The database half covers files just adopted by [`import_manual_content`], +/// which are the user's in origin but the launcher's to manage from now on. +fn ours_in_folder( + content_type: ContentType, + linked: &[oneclient_content::packages::LinkedArtifactInfo], + manifest: Option<&MaterializedManifest>, +) -> HashSet { + let mut names: HashSet = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect(); + + let folder = content_type.folder_name(); + if let Some(manifest) = manifest { + names.extend( + manifest + .entries + .iter() + .filter_map(|entry| entry.path.strip_prefix(&format!("{folder}/"))) + .map(str::to_owned), + ); + } + + names } #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] @@ -95,6 +264,8 @@ pub async fn import_manual_content( } }; + let manifest = manifest::load(game_dir).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 { @@ -126,29 +297,34 @@ pub async fn import_manual_content( continue; } - // The shared dir is rebuilt from the database on every launch, so - // anything here that the launcher already has in its artifact cache - // is a leftover from a session whose cleanup never ran — a crash, or - // a bundle update that dropped the package in between. Importing it - // would re-link the package the update just removed, and the next - // launch would put it back in the game. Drop it instead; the cache - // still holds the file if it is ever wanted again. - if is_cached_artifact(services, &path).await { - tracing::debug!(file = name, "discarding stale launcher content in shared dir"); + // Ours, from a previous launch. Either it is still wanted and gets + // relinked, or it was removed and `prune_previous` clears it. Taking + // it as a user drop-in here would reinstall the package the user + // just deleted. + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { + continue; + } + + // No manifest to consult — a directory written by a launcher version + // that predates it. Fall back to asking whether the cache already + // holds this exact file, which means 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 shared content"); + 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 shared content") + tracing::debug!(file = name, "registered manually-added content") } Err(err) => tracing::warn!( file = name, error = %err, - "failed to register manually-added shared content" + "failed to register manually-added content" ), } } @@ -156,7 +332,7 @@ pub async fn import_manual_content( } /// Whether `path` is a copy of something already in the launcher's artifact -/// cache, i.e. content the launcher itself put in the shared dir rather than +/// cache, i.e. content the launcher itself put in the game dir rather than /// something the user dropped there. async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { @@ -195,26 +371,16 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; -/// The file names the package store owns inside a content folder. Everything -/// else in there was put there by the game or the user. -fn managed_names(linked: &[LinkedArtifactInfo], content_type: ContentType) -> HashSet { - linked - .iter() - .filter(|link| link.content_type == content_type) - .map(|link| link.cluster_file_name.clone()) - .collect() -} - /// Empty a shared content folder without losing anything the game wrote into /// it. /// -/// Managed content is dropped, since the cluster folder already holds an -/// link into the artifact cache. Everything else (a shaderpack's settings -/// sidecar, an unzipped pack, a stray config) is *moved* into the cluster's own -/// copy of the folder, and [`restore_stashed`] links it back on the next -/// launch. That is what keeps shader configs attached to the cluster they were -/// tuned in instead of being wiped by the next launch of any cluster. -async fn stash_content_files(dir: &Path, stash: &Path, managed: &HashSet) { +/// Content the launcher owns is dropped, since the cache already holds it. +/// Everything else (a shaderpack's settings sidecar, an unzipped pack, a stray +/// config) is *moved* into the cluster's own copy of the folder, and +/// [`restore_stashed`] links it back on the next launch. That is what keeps +/// shader configs attached to the cluster they were tuned in instead of being +/// wiped by the next launch of any cluster. +async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { let Ok(mut entries) = polyio::read_dir(dir).await else { return; }; @@ -232,11 +398,11 @@ async fn stash_content_files(dir: &Path, stash: &Path, managed: &HashSet continue; }; - // Ours: either a link we made this launch, or a name the store tracks - // (on Windows `symlink_file` hard-links, so ours is not always a - // symlink). Either way the cluster folder has it covered. - if file_type.is_symlink() || managed.contains(&name) { - remove_entry(&path, file_type).await; + // Ours: either a link we made this launch, or a name the manifest or the + // database accounts for (on Windows `symlink_file` hard-links, so ours + // is not always a symlink). Either way the cache has it covered. + if file_type.is_symlink() || ours.contains(&name) { + remove_dir_or_file(&path, file_type).await; continue; } @@ -245,7 +411,7 @@ async fn stash_content_files(dir: &Path, stash: &Path, managed: &HashSet tracing::warn!( file = %name, error = %err, - "failed to stash shared content into cluster; leaving it in place" + "failed to stash content into cluster; leaving it in place" ); } } @@ -255,7 +421,12 @@ async fn stash_content_files(dir: &Path, stash: &Path, managed: &HashSet /// game finds them where it left them. The game writes straight through the /// link, so edits land in the cluster folder even if we never get to run on /// exit. -async fn restore_stashed(stash: &Path, dir: &Path, managed: &HashSet) { +async fn restore_stashed( + stash: &Path, + dir: &Path, + content_type: ContentType, + ours: &HashSet, +) { let Ok(mut entries) = polyio::read_dir(stash).await else { return; }; @@ -265,19 +436,37 @@ async fn restore_stashed(stash: &Path, dir: &Path, managed: &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('.') || managed.contains(&name) { + if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { continue; } let Ok(file_type) = entry.file_type().await else { continue; }; - // A link into the artifact cache. Managed content is linked from the - // database further down, not from here. + // A link into the artifact cache. Managed content is materialized from + // the database further down, not from here. if file_type.is_symlink() { continue; } + // A bare jar or pack in the stash is not something the stash should ever + // hold: the launcher's own content comes from the cache, and a file the + // user drops into the game folder is adopted into the cache before it + // could land here. What is left is a link written by a launcher version + // that put content in cluster folders — often one whose package has since + // been removed. Restoring it would put a deleted mod back in the game on + // every launch, which is the exact bug this scheme exists to end. + // + // Left on disk rather than deleted; the Storage settings page reports + // them and can clear them out. + if file_type.is_file() && has_content_extension(content_type, &name) { + tracing::debug!( + file = %name, + "ignoring stray content in cluster folder; use the Storage settings page to clear it" + ); + continue; + } + let dest = dir.join(&name); let result = if file_type.is_dir() { polyio::symlink_dir(&path, &dest).await.map_err(Into::into) @@ -289,13 +478,13 @@ async fn restore_stashed(stash: &Path, dir: &Path, managed: &HashSet) { tracing::warn!( file = %name, error = %err, - "failed to restore stashed content into shared dir" + "failed to restore stashed content into game dir" ); } } } -async fn remove_entry(path: &Path, file_type: FileType) { +async fn remove_dir_or_file(path: &Path, file_type: FileType) { if file_type.is_dir() { polyio::remove_dir_all(path).await.ok(); } else if polyio::remove_file(path).await.is_err() { @@ -312,7 +501,7 @@ async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { // A stash left from an earlier session is always older than what the game // just wrote, so it loses. if let Ok(meta) = polyio::symlink_metadata(dest).await { - remove_entry(dest, meta.file_type()).await; + remove_dir_or_file(dest, meta.file_type()).await; } if polyio::rename(src, dest).await.is_ok() { @@ -349,29 +538,6 @@ async fn ensure_note(dir: &Path, content_type: ContentType) { .ok(); } -#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] -pub async fn clear_shared_content( - services: &LauncherServices, - cluster: &Cluster, - game_dir: &Path, -) -> LauncherResult<()> { - // Run after `import_manual_content`, so anything the user dropped in is - // already a tracked artifact by now and gets dropped rather than stashed. - let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) - .await - .unwrap_or_default(); - - for content_type in SWAP_TYPES { - let dir = game_dir.join(content_type.folder_name()); - let stash = cluster.dir()?.join(content_type.folder_name()); - polyio::create_dir_all(&dir).await.ok(); - - stash_content_files(&dir, &stash, &managed_names(&linked, content_type)).await; - ensure_note(&dir, content_type).await; - } - Ok(()) -} - #[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) { let cluster_dir = match cluster.dir() { @@ -473,6 +639,10 @@ async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> Launch let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); let dest = game_dir.join(FABRIC_DEP_OVERRIDES); + if src == dest { + return Ok(()); + } + if polyio::try_exists(&src).await.unwrap_or(false) { if let Some(parent) = dest.parent() { polyio::create_dir_all(parent).await.ok(); @@ -490,10 +660,23 @@ mod tests { use super::*; - fn managed(names: &[&str]) -> HashSet { + fn names(names: &[&str]) -> HashSet { names.iter().map(|n| (*n).to_string()).collect() } + fn manifest_of(cluster_id: i64, paths: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + cluster_id, + paths + .iter() + .map(|p| ManifestEntry { + path: (*p).to_string(), + hash: "hash".into(), + }) + .collect(), + ) + } + /// A shaderpack's settings sidecar has to survive a launch/exit cycle, and /// end up in the cluster rather than the shared dir. #[tokio::test] @@ -503,7 +686,7 @@ mod tests { let stash = root.join("cluster").join("shaderpacks"); polyio::create_dir_all(&shared).await.unwrap(); - let names = managed(&["bsl.zip"]); + let ours = names(&["bsl.zip"]); // Play: the pack is linked in, the game writes its settings next to it. polyio::write(shared.join("bsl.zip"), b"pack".as_slice()) @@ -514,7 +697,7 @@ mod tests { .unwrap(); // Exit. - stash_content_files(&shared, &stash, &names).await; + stash_content_files(&shared, &stash, &ours).await; assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); assert_eq!( @@ -523,7 +706,7 @@ mod tests { ); // Next launch. - restore_stashed(&stash, &shared, &names).await; + restore_stashed(&stash, &shared, ContentType::Shader, &ours).await; assert_eq!( polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(), "BLOOM=off" @@ -556,7 +739,7 @@ mod tests { assert!(!shared.join("Loose").exists()); assert!(stash.join("Loose/shaders/final.fsh").exists()); - restore_stashed(&stash, &shared, &HashSet::new()).await; + restore_stashed(&stash, &shared, ContentType::Shader, &HashSet::new()).await; assert!(shared.join("Loose/shaders/final.fsh").exists()); // Only the link goes, never the stashed original. @@ -584,4 +767,186 @@ mod tests { std::fs::remove_dir_all(root.path()).ok(); } + + /// The reported bug. A package removed while the game held it open stays in + /// the folder; the next launch has to clear it. `remove_entry` is used + /// rather than a link so the file is an ordinary one — which is what a + /// managed file looks like on Windows, where `symlink_file` hard-links. + #[tokio::test] + async fn removed_package_is_pruned_at_the_next_launch() { + let root = polyio::testing::ScratchDir::new("prune_removed"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("removed.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + let previous = manifest_of(1, &["mods/removed.jar", "mods/kept.jar"]); + let keep: HashSet = ["mods/kept.jar".to_string()].into_iter().collect(); + + prune_previous(game_dir, Some(&previous), &keep).await; + + assert!( + polyio::symlink_metadata(&jar).await.is_err(), + "a removed package must not survive into the next session" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// The shared game dir is reused by every cluster. Whatever the previous + /// cluster materialized is ours to clear, not the user's to keep. + #[tokio::test] + async fn another_clusters_content_is_cleared_not_stashed() { + let root = polyio::testing::ScratchDir::new("cross_cluster"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("theirs.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + // Cluster 2 launches; cluster 1's manifest is what is on disk, and none + // of it is wanted now. + prune_previous(game_dir, Some(&manifest_of(1, &["mods/theirs.jar"])), &HashSet::new()).await; + + assert!(polyio::symlink_metadata(&jar).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A file the user dropped in by hand is not in any manifest, so it is + /// stashed into the cluster rather than deleted. + #[tokio::test] + async fn user_files_are_never_pruned() { + let root = polyio::testing::ScratchDir::new("user_file"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let mine = game_dir.join("mods").join("handmade.jar"); + polyio::write(&mine, b"jar".as_slice()).await.unwrap(); + + prune_previous(game_dir, Some(&manifest_of(1, &["mods/ours.jar"])), &HashSet::new()).await; + + assert!(mine.exists(), "a file we never materialized is not ours to delete"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// The reported bug, in its stuck form. A jar left in a cluster folder by an + /// older launcher, whose package has since been removed, must not be linked + /// back into the game. It is left on disk — the Storage page clears it — but + /// it never reaches the session again. + #[tokio::test] + async fn stray_content_in_the_stash_is_not_restored() { + let root = polyio::testing::ScratchDir::new("stray_content"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + // A leftover from the old scheme: an ordinary file (which is what a + // Windows hard link looks like) with no database row to explain it. + polyio::write(stash.join("removed.jar"), b"jar".as_slice()) + .await + .unwrap(); + // A genuine sidecar, which still has to come back. + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + + restore_stashed(&stash, &shared, ContentType::Mod, &HashSet::new()).await; + + assert!( + !shared.join("removed.jar").exists(), + "a removed package must not be restored into the game" + ); + assert!( + stash.join("removed.jar").exists(), + "and it must not be silently deleted either" + ); + assert!(shared.join("options.txt").exists(), "sidecars still restore"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// The cluster folder is the user's. A full launch/exit cycle may *add* to + /// it, never take from it. + /// + /// Pinned because the two halves are easy to get backwards: `stash` reads + /// the game dir and writes the cluster, `restore` reads the cluster and + /// writes the game dir. A swapped argument in either would quietly delete a + /// user's files, and both take two same-typed `&Path`s. + #[tokio::test] + async fn a_session_never_removes_anything_from_the_cluster_folder() { + let root = polyio::testing::ScratchDir::new("stash_is_sacred"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + // Everything a cluster folder might legitimately be holding. + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + polyio::write(stash.join("leftover.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::create_dir_all(stash.join("unpacked")).await.unwrap(); + polyio::write(stash.join("unpacked").join("inner.txt"), b"x".as_slice()) + .await + .unwrap(); + + // And something in the game dir that is ours to clear. + polyio::write(shared.join("managed.jar"), b"jar".as_slice()) + .await + .unwrap(); + + let ours = names(&["managed.jar"]); + + let before = dir_entries(&stash).await; + + // Launch, then exit, then launch again. + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + + let after = dir_entries(&stash).await; + + assert_eq!( + before, after, + "a session must not add to or remove from the cluster folder" + ); + assert!( + stash.join("unpacked").join("inner.txt").exists(), + "the contents of a stashed directory survive too" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + async fn dir_entries(dir: &Path) -> Vec { + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return names; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + names.sort(); + names + } + + #[test] + fn ownership_spans_the_manifest_and_the_database() { + let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); + let linked = Vec::new(); + + 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/lib.rs b/packages/oneclient_core/src/lib.rs index b9fb2f4b..8e2bdbca 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -14,6 +14,7 @@ pub mod migration; pub mod recovery; pub mod reporting; pub mod settings; +pub mod storage; pub mod tos; mod state; pub mod versions; diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 431a635e..c4db85a3 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -8,6 +8,7 @@ use oneclient_db::DbPool; use strum::IntoEnumIterator; use crate::clusters::ClusterStage; +use oneclient_content::packages::PackageStore; use polyio::sha1_file; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; @@ -234,6 +235,12 @@ async fn adopt_cluster( Ok(()) } +/// Adopts the content sitting in a rediscovered cluster's folders. +/// +/// Files are taken *into the artifact cache* rather than indexed where they lie. +/// An artifact's `path` has to resolve to the cache, because that is the only +/// place the launcher materializes content from; a row pointing back into a +/// cluster folder would break the moment that folder was tidied. #[tracing::instrument(level = "debug", skip(state, report))] async fn relink_cluster_files( state: &LauncherState, @@ -242,8 +249,8 @@ async fn relink_cluster_files( report: &mut RecoveryReport, ) -> LauncherResult<()> { let pool = &state.services.db; - let launcher_dir = paths::launcher_dir()?; - let cluster_root = paths::clusters_dir()?.join(folder_name); + let content = state.services.content(); + let cluster_root = paths::cluster_dir(folder_name)?; for content_type in FILE_CONTENT_TYPES { let dir = cluster_root.join(content_type.folder_name()); @@ -260,32 +267,28 @@ async fn relink_cluster_files( let enabled = !file_name.ends_with(".disabled"); let base_name = file_name.trim_end_matches(".disabled").to_string(); - let hash = match sha1_file(&file).await { - Ok(hash) => hash, + let row = match PackageStore::import_local_file( + &file, + content_type, + cluster_id, + &content, + ) + .await + { + Ok(row) => row, Err(err) => { - tracing::warn!(path = %file.display(), "failed to hash cluster file: {err:#}"); + tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); continue; } }; - if artifact_dao::get_artifact_by_hash(pool, &hash).await?.is_none() { - let stored_path = relative_launcher_path(launcher_dir, &file); - let size = polyio::stat(&file).await.ok().map(|m| m.len() as i64); - artifact_dao::insert_artifact( - pool, - &hash, - content_type as i64, - &stored_path, - &base_name, - size, - ) - .await?; + // `import_local_file` links under the artifact's own name, which is + // the `.disabled` spelling for a file the user had switched off. + if !enabled { + artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) + .await?; } - artifact_dao::link_cluster_artifact(pool, cluster_id, &hash, &base_name).await?; - if !enabled { - artifact_dao::update_cluster_artifact(pool, cluster_id, &hash, &base_name, 0).await?; - } report.relinked_files += 1; } } diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 4d36dab6..ee34e9f9 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -163,6 +163,22 @@ pub fn run_startup_tasks(state: &Arc) { } else { background.services.events.signal(oneclient_events::Signal::ClustersChanged); } + + // Last, so it runs against a database that recovery, bundle tracking + // and provisioning have all finished settling. Judging an artifact + // unused before the rows that use it have been restored would evict + // content that is very much still wanted. + // + // Only the row-driven half runs unattended. Deciding from the + // filesystem which cached files nothing points at is the half that can + // misread a half-reconstructed install, so it lives behind the Storage + // settings page where the user sees what it proposes to delete. + if let Err(err) = + oneclient_content::packages::store::collect_unused_artifacts(&content).await + { + tracing::warn!("package cache cleanup failed: {err:#}"); + } + background.services.events.signal(oneclient_events::Signal::SyncComplete); }); } diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs new file mode 100644 index 00000000..323561de --- /dev/null +++ b/packages/oneclient_core/src/storage.rs @@ -0,0 +1,418 @@ +//! What the launcher is using the disk for, and what of it can be reclaimed. +//! +//! Backs the Storage settings page. Everything here is read-only except the two +//! cleanup entry points at the bottom, which exist so the destructive work is +//! something the user asks for after seeing a number, rather than something that +//! happens quietly at startup and has to be gated against running twice. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_common::paths; +use oneclient_content::packages::store::{ + find_unreferenced_files, remove_unreferenced_files, +}; + +/// Content folders an older launcher would have written links into. +const LEGACY_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +/// One line on the Storage page. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageEntry { + pub label: String, + pub bytes: u64, + pub path: PathBuf, + /// How many files it covers, where a count is more meaningful than a size. + pub files: Option, +} + +/// Something the user can reclaim, with what it would cost them to do so. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReclaimableEntry { + pub bytes: u64, + pub files: usize, +} + +impl ReclaimableEntry { + #[must_use] + pub fn is_empty(&self) -> bool { + self.files == 0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageReport { + pub total_bytes: u64, + /// Minecraft versions, libraries, assets, Java runtimes, logs, image cache. + pub categories: Vec, + /// Per-cluster usage, largest first. + pub clusters: Vec, + /// Cached package files no artifact row points at. + pub unreferenced_cache: ReclaimableEntry, + /// Content links an older launcher wrote into cluster folders. + pub legacy_cluster_content: ReclaimableEntry, +} + +#[tracing::instrument(skip(state))] +pub async fn storage_report(state: &LauncherState) -> LauncherResult { + #[cfg(debug_assertions)] + if let Some(report) = fixture_report() { + return Ok(report); + } + + let launcher = paths::launcher_dir()?; + + let mut categories = vec![ + entry("Packages", paths::packages_cache_dir()?).await, + entry("Minecraft versions", paths::versions_dir()?).await, + entry("Libraries", paths::libraries_dir()?).await, + entry("Assets", paths::assets_dir()?).await, + entry("Natives", paths::natives_dir()?).await, + entry("Java runtimes", paths::java_dir()?).await, + entry("Shared game directory", paths::shared_minecraft_dir()?).await, + entry("Logs", paths::logs_dir()?).await, + entry("Image cache", paths::images_cache_dir()?).await, + entry("Bundles", paths::bundles_dir()?).await, + ]; + categories.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let mut clusters = Vec::new(); + for cluster in state.clusters.list().await? { + let Ok(dir) = cluster.dir() else { + continue; + }; + clusters.push(entry(&cluster.name, dir).await); + } + clusters.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let unreferenced = find_unreferenced_files(&state.services.content()).await?; + let unreferenced_cache = ReclaimableEntry { + bytes: unreferenced.iter().map(|(_, size)| size).sum(), + files: unreferenced.len(), + }; + + Ok(StorageReport { + total_bytes: dir_size(launcher).await, + categories, + clusters, + unreferenced_cache, + legacy_cluster_content: legacy_cluster_content(state).await?, + }) +} + +/// Measures the content files sitting in cluster folders. +/// +/// Everything here is a leftover: content is materialized into the game +/// directory from the cache now, so a jar or pack in a cluster's own folder was +/// put there by a launcher version that worked differently. They are inert — +/// `restore_stashed` ignores them — so this is space, not correctness. +async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut found = ReclaimableEntry::default(); + + for cluster in state.clusters.list().await? { + // A dedicated cluster's folder *is* its game directory, so content there + // is exactly where it belongs. + if cluster.uses_dedicated_dir() { + continue; + } + let Ok(root) = cluster.dir() else { + continue; + }; + + for content_type in LEGACY_TYPES { + let dir = root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if file_type.is_symlink() { + found.files += 1; + continue; + } + + if file_type.is_file() && is_content_file(content_type, name) { + found.files += 1; + found.bytes += polyio::stat(&path).await.map(|m| m.len()).unwrap_or(0); + } + } + } + } + + Ok(found) +} + +fn is_content_file(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader | ContentType::DataPack => { + lower.ends_with(".zip") + } + _ => false, + } +} + +async fn entry(label: &str, path: PathBuf) -> StorageEntry { + StorageEntry { + label: label.to_string(), + bytes: dir_size(&path).await, + path, + files: None, + } +} + +/// Total size of everything under `root`, following nothing. +/// +/// Links are counted as the few bytes the link itself takes rather than the size +/// of what they point at, 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 { + let mut total = 0; + let mut stack = vec![root.as_ref().to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + 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; + } + + if file_type.is_dir() { + stack.push(entry.path()); + } else if let Ok(meta) = entry.metadata().await { + total += meta.len(); + } + } + } + + total +} + +/// Whether the page is showing made-up numbers. +/// +/// The cleanup actions refuse to run while it is: the buttons would be acting +/// on a report that has nothing to do with what is actually on disk, and the +/// only thing they could delete is the user's real data. +fn showing_fixture() -> bool { + #[cfg(debug_assertions)] + { + std::env::var("ONECLIENT_FAKE_STORAGE").is_ok() + } + + #[cfg(not(debug_assertions))] + false +} + +/// Deletes cached package files that no artifact refers to. +pub async fn clean_unreferenced_cache(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping cache cleanup"); + return Ok(0); + } + + let report = remove_unreferenced_files(&state.services.content()).await?; + Ok(report.bytes_freed) +} + +/// Clears the content links older launchers wrote into cluster folders. +pub async fn clean_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping leftover cleanup"); + return Ok(0); + } + + let report = crate::clusters::unlink_legacy_cluster_content(state).await?; + Ok(report.removed) +} + +/// Stand-in reports for working on the Storage page. +/// +/// Set `ONECLIENT_FAKE_STORAGE` to one of `empty`, `clean` or `full` to make +/// [`storage_report`] return a fixture instead of scanning the disk. Debug +/// builds only, and read on every refresh so the scenario can be changed +/// without a rebuild. +/// +/// The three cover the states worth looking at: a fresh install with nothing +/// anywhere, a well-used install with nothing to reclaim, and one with plenty +/// to reclaim so the cleanup buttons are live. +#[cfg(debug_assertions)] +fn fixture_report() -> Option { + let scenario = std::env::var("ONECLIENT_FAKE_STORAGE").ok()?; + + let fake = |label: &str, bytes: u64| StorageEntry { + label: label.to_string(), + bytes, + path: PathBuf::from("/tmp/oneclient-fake").join(label.to_lowercase().replace(' ', "-")), + files: None, + }; + + let report = match scenario.trim().to_lowercase().as_str() { + "empty" => StorageReport { + total_bytes: 0, + categories: Vec::new(), + clusters: Vec::new(), + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "clean" => StorageReport { + total_bytes: 1_284_000_000, + categories: vec![ + fake("Minecraft versions", 512_000_000), + fake("Packages", 368_000_000), + fake("Libraries", 240_000_000), + fake("Assets", 96_000_000), + fake("Java runtimes", 61_000_000), + fake("Shared game directory", 5_400_000), + fake("Logs", 1_200_000), + fake("Image cache", 400_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 42_000_000), + fake("26.2 Fabric", 18_500_000), + fake("1.21.11 Fabric", 9_100_000), + ], + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "full" => StorageReport { + total_bytes: 4_930_000_000, + categories: vec![ + fake("Packages", 2_100_000_000), + fake("Minecraft versions", 1_400_000_000), + fake("Libraries", 890_000_000), + fake("Assets", 412_000_000), + fake("Java runtimes", 118_000_000), + fake("Natives", 6_800_000), + fake("Shared game directory", 2_100_000), + fake("Logs", 940_000), + fake("Bundles", 120_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 310_000_000), + fake("26.2 Fabric", 148_000_000), + fake("1.21.11 Fabric", 96_000_000), + fake("1.21.10 Fabric", 12_000_000), + fake("1.21.1 Fabric", 0), + ], + unreferenced_cache: ReclaimableEntry { + bytes: 743_000_000, + files: 214, + }, + legacy_cluster_content: ReclaimableEntry { + bytes: 261_000_000, + files: 439, + }, + }, + other => { + tracing::warn!( + scenario = other, + "unknown ONECLIENT_FAKE_STORAGE value; expected empty, clean or full" + ); + return None; + } + }; + + tracing::info!(scenario = %scenario, "serving fixture storage report"); + Some(report) +} + +/// Renders a byte count the way the settings page shows it. +#[must_use] +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; + + if bytes < 1000 { + return format!("{bytes} B"); + } + + let mut value = bytes as f64 / 1000.0; + let mut unit = 0; + + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + + if value >= 100.0 { + format!("{value:.0} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_read_the_way_a_person_would_say_them() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(999), "999 B"); + assert_eq!(format_bytes(1_500), "1.5 KB"); + assert_eq!(format_bytes(628_000_000), "628 MB"); + assert_eq!(format_bytes(2_400_000_000), "2.4 GB"); + } + + #[test] + fn disabled_content_still_counts_as_content() { + assert!(is_content_file(ContentType::Mod, "sodium.jar")); + assert!(is_content_file(ContentType::Mod, "sodium.jar.disabled")); + assert!(is_content_file(ContentType::Shader, "BSL.zip")); + // A shaderpack's settings sidecar is the user's, not a leftover link. + assert!(!is_content_file(ContentType::Shader, "BSL.zip.txt")); + assert!(!is_content_file(ContentType::Mod, "options.txt")); + } + + #[tokio::test] + async fn size_ignores_what_links_point_at() { + let root = polyio::testing::ScratchDir::new("dir_size"); + 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]) + .await + .unwrap(); + + assert_eq!(dir_size(dir).await, 1500); + + // A link to a big file must not be counted as that file again. + polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + .await + .unwrap(); + assert_eq!( + dir_size(dir).await, + 1500, + "a link is not another copy of its target" + ); + + 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 75c41455..8760b319 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -49,6 +49,10 @@ pub async fn insert_artifact( .await } +/// Drops an artifact's row when no cluster refers to it any more. +/// +/// `provider_releases` cascades, so the metadata goes with it. The cached file +/// is the caller's to delete — this layer does not touch the disk. pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { let linked: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?", @@ -68,6 +72,33 @@ pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result< Ok(true) } +/// Every artifact no cluster refers to any more. +/// +/// Clusters are deleted with an `ON DELETE CASCADE` on `cluster_artifacts`, and +/// bundle updates swap one version for another, so artifacts are orphaned in +/// bulk and in places too far from the store to evict them one at a time. +pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as::<_, ArtifactRow>( + r#" + SELECT hash, content_type, path, file_name, size_bytes + FROM artifacts + WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) + "#, + ) + .fetch_all(pool) + .await +} + +/// The stored path of every artifact, used to spot cached files that no row +/// accounts for. +pub async fn list_artifact_paths(pool: &SqlitePool) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(path,)| path).collect()) +} + #[allow(clippy::too_many_arguments)] pub async fn upsert_provider_release( pool: &SqlitePool, diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index f9097c7b..2253c3c1 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -99,6 +99,26 @@ pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<( }) } +/// Removes an empty directory, failing if anything is still in it. +/// +/// The refusal is the point: it makes "remove this if it is empty" a single +/// atomic step, with no read-then-delete window for something to appear in. +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + /// Checks if a path exists #[tracing::instrument( level = "debug",