feat: storage manager

This commit is contained in:
LynithDev 2026-07-31 17:21:12 +02:00
parent 65869ca8c0
commit 3b436f0d87
29 changed files with 2334 additions and 374 deletions

View file

@ -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,

View file

@ -354,6 +354,12 @@ pub fn use_refresh_all_accounts() -> UseMutation<RefreshAllAccountsMutation> {
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<M: MutationCapability>(mutation: &UseMutation<M>) -> bool {
matches!(
&*mutation.read().state(),
@ -361,6 +367,11 @@ pub fn mutation_is_pending<M: MutationCapability>(mutation: &UseMutation<M>) ->
)
}
/// Whether the mutation is actually in flight right now.
pub fn mutation_is_running<M: MutationCapability>(mutation: &UseMutation<M>) -> bool {
mutation.read().state().is_loading()
}
pub fn mutation_error<M>(mutation: &UseMutation<M>) -> Option<M::Err>
where
M: MutationCapability,

View file

@ -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,
};

View file

@ -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

View file

@ -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<Self::Ok, Self::Err> {
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<StorageReportQuery> {
use_query(Query::new(StorageReportKeys, StorageReportQuery).stale_time(STORAGE_STALE_TIME))
}
pub fn try_storage_report(query: &UseQuery<StorageReportQuery>) -> Option<StorageReport> {
super::state::settled_or_loading(query)
}
pub async fn invalidate_storage_queries() {
QueriesStorage::<StorageReportQuery>::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<Self::Ok, Self::Err> {
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<Self::Ok, Self::Err>) {
if result.is_ok() {
invalidate_storage_queries().await;
}
}
}
pub type UseStorageAction = UseMutation<StorageActionMutation>;
pub fn use_storage_action() -> UseStorageAction {
use_mutation(Mutation::new(StorageActionMutation))
}

View file

@ -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 {}

View file

@ -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,

View file

@ -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(),

View file

@ -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},

View file

@ -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<u64> {
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" }
}

View file

@ -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<PathBuf> {
Ok(paths::clusters_dir()?.join(&self.folder_name))
Ok(paths::cluster_dir(&self.folder_name)?)
}
pub fn dedicated_marker(&self) -> ClusterResult<PathBuf> {
@ -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<PathBuf> {
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<'_> {

View file

@ -70,6 +70,31 @@ pub fn shared_minecraft_dir() -> PathsResult<PathBuf> {
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<PathBuf> {
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<PathBuf> {
if cluster_uses_dedicated_dir(folder_name) {
cluster_dir(folder_name)
} else {
shared_minecraft_dir()
}
}
pub fn packages_cache_dir() -> PathsResult<PathBuf> {
Ok(launcher_dir()?.join("metadata").join("packages"))
}

View file

@ -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<BundleFile> = 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<bool> {
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)) =

View file

@ -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(())

View file

@ -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<bool> {
// 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<GcReport> {
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<Vec<(PathBuf, u64)>> {
let cache_root = paths::packages_cache_dir()?;
if !polyio::try_exists(&cache_root).await.unwrap_or(false) {
return Ok(Vec::new());
}
let known: HashSet<String> = 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<GcReport> {
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 `<content>/<provider>/<project>/<version>/` 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(&current).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<PathBuf> {
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();
}
}

View file

@ -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 `<name>.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();
}
}

View file

@ -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<ManifestEntry>,
}
impl MaterializedManifest {
#[must_use]
pub fn new(cluster_id: i64, entries: Vec<ManifestEntry>) -> 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<MaterializedManifest> {
let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?;
match serde_json::from_str::<MaterializedManifest>(&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();
}
}

View file

@ -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<bool> {
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,

View file

@ -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.

View file

@ -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<SweepReport> {
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<CacheMatch> {
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,
}
}

View file

@ -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;

View file

@ -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,
};

View file

@ -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<String> = 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<Vec<Desired>> {
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<ManifestEntry> {
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<String>,
) {
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<String> {
let mut names: HashSet<String> = 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<String> {
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<String>) {
/// 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<String>) {
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<String>
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<String>
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<String>
/// 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<String>) {
async fn restore_stashed(
stash: &Path,
dir: &Path,
content_type: ContentType,
ours: &HashSet<String>,
) {
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<String>) {
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<String>) {
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<String> {
fn names(names: &[&str]) -> HashSet<String> {
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<String> = ["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<String> {
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"));
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -163,6 +163,22 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
} 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);
});
}

View file

@ -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<usize>,
}
/// 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<StorageEntry>,
/// Per-cluster usage, largest first.
pub clusters: Vec<StorageEntry>,
/// 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<StorageReport> {
#[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<ReclaimableEntry> {
let mut found = ReclaimableEntry::default();
for cluster in state.clusters.list().await? {
// A dedicated cluster's folder *is* its game directory, so content there
// 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<Path>) -> 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<u64> {
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<usize> {
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<StorageReport> {
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();
}
}

View file

@ -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<bool, sqlx::Error> {
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<Vec<ArtifactRow>, 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<Vec<String>, 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,

View file

@ -99,6 +99,26 @@ pub async fn remove_dir_all(path: impl AsRef<std::path::Path>) -> 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<std::path::Path>) -> 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",