feat: storage manager
This commit is contained in:
parent
65869ca8c0
commit
3b436f0d87
29 changed files with 2334 additions and 374 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
94
packages/oneclient_app/src/hooks/queries/storage.rs
Normal file
94
packages/oneclient_app/src/hooks/queries/storage.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
338
packages/oneclient_app/src/view/app/settings/storage.rs
Normal file
338
packages/oneclient_app/src/view/app/settings/storage.rs
Normal 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" }
|
||||
}
|
||||
|
|
@ -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<'_> {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) =
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
309
packages/oneclient_content/src/packages/store/gc.rs
Normal file
309
packages/oneclient_content/src/packages/store/gc.rs
Normal 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(¤t).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
dir = current.parent().map(Path::to_path_buf);
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_recent(path: &Path) -> bool {
|
||||
let Ok(meta) = polyio::stat(path).await else {
|
||||
// Cannot tell how old it is, so leave it be.
|
||||
return true;
|
||||
};
|
||||
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
|
||||
.is_none_or(|age| age < RECENT_GRACE)
|
||||
}
|
||||
|
||||
async fn file_len(path: &Path) -> u64 {
|
||||
polyio::stat(path).await.map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn walk_files(root: &Path) -> Vec<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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
167
packages/oneclient_content/src/packages/store/manifest.rs
Normal file
167
packages/oneclient_content/src/packages/store/manifest.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
193
packages/oneclient_core/src/clusters/unlink_legacy.rs
Normal file
193
packages/oneclient_core/src/clusters/unlink_legacy.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
418
packages/oneclient_core/src/storage.rs
Normal file
418
packages/oneclient_core/src/storage.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue