diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 0c28ff47..43dbddde 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -308,7 +308,7 @@ pub async fn start_launcher( oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); oneclient_core::run_startup_tasks(&state); - let data_dir = oneclient_common::paths::launcher_dir() + let data_dir = oneclient_common::paths::data_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); @@ -325,6 +325,7 @@ pub async fn start_launcher( error: None, snapshots: 0, data_dir, + needs_location: false, }; } { @@ -346,7 +347,7 @@ pub fn report_startup_failure( let message = err.to_string(); tracing::error!("launcher init failed: {err:#}"); - let data_dir = oneclient_common::paths::launcher_dir() + let data_dir = oneclient_common::paths::data_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index d295d9ec..4399c2ee 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -13,6 +13,7 @@ use oneclient_cluster::{ ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, }; use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_core::relocate::RelocationPlan; use oneclient_core::settings::LauncherSettings; use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; use oneclient_db::models::ClusterId; @@ -24,7 +25,7 @@ use crate::{invalidate_java_queries, launcher}; use crate::notifications::{ ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt, }; -use crate::state::{AppChannel, AppState, AsyncStatus}; +use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; /// Over-disabling is the cheaper mistake a dead button beats a second game const LAUNCH_HOLD: Duration = Duration::from_secs(2); @@ -959,7 +960,68 @@ impl Actions { }); } - /// `syncing_bundles` gates every launch button so the readiness check must + /// App-scoped on purpose: the copy has to outlive the settings page, which + /// unmounts as soon as the router swaps to the move screen + pub fn relocate(&self, plan: RelocationPlan) { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("move skipped, launcher not ready: {err:#}"); + self.write_relocation(RelocationState { + plan: Some(plan), + result: Some(Err(format!("OneClient is not ready yet: {err}"))), + ..RelocationState::default() + }); + return; + } + }; + + self.write_relocation(RelocationState { + plan: Some(plan.clone()), + ..RelocationState::default() + }); + + let mut station = self.station; + spawn_forever(async move { + // The copy is on this thread's executor, so it can write the count + // straight into the channel the move screen reads + let result = oneclient_core::relocate::relocate(&state, &plan, |copied, total| { + let mut guard = station.write_channel(AppChannel::Relocation); + guard.relocation.copied = copied; + guard.relocation.total = total; + }) + .await; + + if let Err(message) = &result { + tracing::error!("moving the data folder failed: {message}"); + } + + let moved = result.is_ok(); + station + .write_channel(AppChannel::Relocation) + .relocation + .result = Some(result); + + if moved { + super::invalidate_leftovers_queries().await; + } + }); + } + + /// Leaves the move screen, which only the user can do and only once the + /// copy has settled + pub fn end_relocation(&self) { + self.write_relocation(RelocationState::default()); + } + + fn write_relocation(&self, relocation: RelocationState) { + self.station + .clone() + .write_channel(AppChannel::Relocation) + .relocation = relocation; + } + + /// `syncing_bundles` gates every launch button so the readiness check must /// happen before the flag is raised not inside the task pub fn sync_bundles(&self) { let state = match launcher::state() { diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index a106dd94..2787967e 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -31,6 +31,9 @@ pub use queries::{ 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, + DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, + UseDiscardLeftovers, invalidate_leftovers_queries, mutation_ok, try_leftovers, + use_discard_leftovers, use_leftovers, java_runtimes, latest_changelog_version, loaded_image, loader_versions, login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch, @@ -58,7 +61,8 @@ pub use queries::{ use crate::notifications::NotificationSnapshot; use crate::state::{ - AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, + SettingsState, }; use freya::prelude::*; use freya::radio::use_radio; @@ -78,6 +82,10 @@ pub fn use_launcher() -> LauncherInit { use_radio(AppChannel::Launcher).read().launcher.clone() } +pub fn use_relocation() -> RelocationState { + use_radio(AppChannel::Relocation).read().relocation.clone() +} + pub fn use_settings_snapshot() -> SettingsState { use_radio(AppChannel::Settings).read().settings.clone() } diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index 487f0fc2..fb5dccf6 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -364,6 +364,18 @@ pub fn mutation_is_running(mutation: &UseMutation) -> mutation.read().state().is_loading() } +pub fn mutation_ok(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Ok: Clone, +{ + let reader = mutation.read(); + match &*reader.state() { + MutationStateData::Settled { res: Ok(ok), .. } => Some(ok.clone()), + _ => None, + } +} + pub fn mutation_error(mutation: &UseMutation) -> Option where M: MutationCapability, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 9bf7949e..d6efb2f9 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -40,7 +40,7 @@ pub use auth::{ CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts, try_default_account, + mutation_is_pending, mutation_is_running, mutation_ok, 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, @@ -68,8 +68,11 @@ pub use logs::{ }; 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, + DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, + StorageAction, StorageActionMutation, StorageReportQuery, UseDiscardLeftovers, + UseStorageAction, invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers, + try_storage_report, use_discard_leftovers, use_leftovers, use_storage_action, + use_storage_report, }; pub use mutations::{ ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs index 4db9e814..f26cbc35 100644 --- a/packages/oneclient_app/src/hooks/queries/storage.rs +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -5,6 +5,7 @@ use freya::query::{ use_mutation, use_query, }; use oneclient_core::LauncherError; +use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers}; use oneclient_core::storage::{StorageReport, storage_report}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -82,3 +83,62 @@ pub type UseStorageAction = UseMutation; pub fn use_storage_action() -> UseStorageAction { use_mutation(Mutation::new(StorageActionMutation)) } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LeftoversKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LeftoversQuery; + +impl QueryCapability for LeftoversQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = LeftoversKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(leftovers(&state).await) + } +} + +pub fn use_leftovers() -> UseQuery { + use_query(Query::new(LeftoversKeys, LeftoversQuery).stale_time(STORAGE_STALE_TIME)) +} + +pub fn try_leftovers(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub async fn invalidate_leftovers_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DiscardLeftoversKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DiscardLeftoversMutation; + +impl MutationCapability for DiscardLeftoversMutation { + type Ok = u64; + type Err = String; + type Keys = DiscardLeftoversKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|err| err.to_string())?; + discard_leftovers(&state).await + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_leftovers_queries().await; + invalidate_storage_queries().await; + } + } +} + +pub type UseDiscardLeftovers = UseMutation; + +pub fn use_discard_leftovers() -> UseDiscardLeftovers { + use_mutation(Mutation::new(DiscardLeftoversMutation)) +} diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 0700c104..d3c726da 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -65,8 +65,11 @@ const SEARCH_INDEX: &[SearchItem] = &[ id: "launcher.folder", icon: IconType::Folder, title: "Launcher Folder", - description: "Open the launcher data directory.", - keywords: &["data dir", "directory", "folder", "open"], + description: "Open the launcher data directory, or move it to another drive.", + keywords: &[ + "data dir", "directory", "folder", "open", "move", "change", "relocate", "location", + "drive", "disk", + ], route: Route::SettingsLauncher {}, }, SearchItem { diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 57ff635b..96bf2ea1 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -6,19 +6,29 @@ use freya::prelude::*; use freya::radio::use_init_radio_station; -use oneclient_app::state::{AppChannel, AppState}; +use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, use_provide_actions, use_provide_link_confirm, }; use tokio::runtime::Builder; -struct OneClientApp; +struct OneClientApp { + needs_location: bool, +} impl App for OneClientApp { fn render(&self) -> impl IntoElement { + let needs_location = self.needs_location; + // Radio state is `!Send` so every writer runs on the UI thread `spawn_forever` - let station = use_init_radio_station::(AppState::default); + let station = use_init_radio_station::(move || AppState { + launcher: LauncherInit { + needs_location, + ..LauncherInit::default() + }, + ..AppState::default() + }); let actions = use_hook(move || { let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -35,19 +45,21 @@ impl App for OneClientApp { .run(), ); - let startup = actions.clone(); - let rescue_bus = events_bus.clone(); - spawn_forever(async move { - match events::start_launcher(station, events_bus).await { - // Must follow startup `sync_bundles` needs the launcher handle and firing - // it early leaves `syncing_bundles` stuck disabling every launch button - Ok(()) => startup.sync_bundles(), - Err(err) => { - events::report_startup_failure(&station, &err); - oneclient_app::updater::spawn_update_check(false, rescue_bus); + if !needs_location { + let startup = actions.clone(); + let rescue_bus = events_bus.clone(); + spawn_forever(async move { + match events::start_launcher(station, events_bus).await { + // Must follow startup `sync_bundles` needs the launcher handle and firing + // it early leaves `syncing_bundles` stuck disabling every launch button + Ok(()) => startup.sync_bundles(), + Err(err) => { + events::report_startup_failure(&station, &err); + oneclient_app::updater::spawn_update_check(false, rescue_bus); + } } - } - }); + }); + } actions }); @@ -76,10 +88,33 @@ fn main() { let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); + // no settings file is the sign of a fresh install, but not proof of one + let never_set_up = oneclient_common::paths::settings_file() + .map(|path| !path.exists()) + .unwrap_or(false); + let has_database = oneclient_common::paths::database_file() + .map(|path| path.exists()) + .unwrap_or(false); + let was_damaged = oneclient_common::paths::damaged_settings_file() + .map(|path| path.exists()) + .unwrap_or(false); + let needs_location = never_set_up && !has_database && !was_damaged; + let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); oneclient_common::consent::init(settings.declined_tos); + let abandoned_move = settings + .data_dir + .clone() + .filter(|dir| oneclient_core::relocate::incomplete(dir)); + + if let Some(dir) = settings.data_dir.clone() + && abandoned_move.is_none() + { + oneclient_common::paths::set_data_dir(dir); + } + if settings.log_debug { oneclient_core::logger::init_debug() } else { @@ -87,13 +122,20 @@ fn main() { } .expect("Failed to initialize logger"); + if let Some(dir) = abandoned_move { + tracing::error!( + path = %dir.display(), + "a move of the game data folder never finished; staying on the old folder" + ); + } + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); #[cfg(target_os = "macos")] oneclient_app::platform::macos::loop_memory_collector(); - let window_config = WindowConfig::new_app(OneClientApp) + let window_config = WindowConfig::new_app(OneClientApp { needs_location }) .with_title(constants::WINDOW_TITLE) .with_app_id(constants::WINDOW_APP_ID) .with_icon(LaunchConfig::window_icon(include_bytes!( @@ -102,7 +144,17 @@ fn main() { .with_size(1200., 800.) .with_min_size(800., 600.) .with_transparency(true) - .with_background(Color::TRANSPARENT); + .with_background(Color::TRANSPARENT) + // A half-copied data folder is unrecoverable, so the window refuses to + // close while one is being moved; the move screen says as much + .with_on_close(|_, _| { + if oneclient_core::relocate::in_progress() { + tracing::warn!("close request ignored, the data folder is still being moved"); + CloseDecision::KeepOpen + } else { + CloseDecision::Close + } + }); #[cfg(target_os = "macos")] let window_config = window_config diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs index 8d480573..c442399f 100644 --- a/packages/oneclient_app/src/recovery.rs +++ b/packages/oneclient_app/src/recovery.rs @@ -15,7 +15,7 @@ pub fn snapshots() -> Vec { } pub fn open_data_folder() { - let Ok(dir) = oneclient_common::paths::launcher_dir() else { + let Ok(dir) = oneclient_common::paths::data_dir() else { return; }; diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index de586dc8..70862fe7 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -8,7 +8,7 @@ use crate::layout::RootLayout; use crate::layout::SettingsShell; use crate::view::{ - NotFound, Startup, + NotFound, Relocating, SetupLocation, Startup, app::{ AccountSkins, Clusters, Debug, Home, Stats, browser::{Browser, BrowserPackage}, @@ -34,6 +34,10 @@ pub enum Route { #[layout(RootLayout)] #[route("/")] Startup {}, + #[route("/setup/location")] + SetupLocation {}, + #[route("/moving")] + Relocating {}, #[layout(OnboardingShell)] #[route("/onboarding")] @@ -124,6 +128,8 @@ impl Route { pub fn title(&self) -> String { match self { Route::Startup { .. } => "Startup".to_string(), + Route::SetupLocation { .. } => "Setup".to_string(), + Route::Relocating { .. } => "Moving".to_string(), Route::OnboardingWelcome { .. } => "Welcome".to_string(), Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), Route::OnboardingMigration { .. } => "Migration".to_string(), diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index eae4ac29..8482102a 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use freya::radio::RadioChannel; use oneclient_common::domain::ProviderId; +use oneclient_core::relocate::{RelocationOutcome, RelocationPlan}; use oneclient_core::settings::LauncherSettings; use oneclient_events::LaunchStage; @@ -21,6 +22,7 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + Relocation, } impl RadioChannel for AppChannel {} @@ -40,6 +42,21 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub relocation: RelocationState, +} + +/// A move of the data folder owns the whole window while it runs: the router +/// swaps to [`crate::routes::Route::Relocating`] so nothing else can be touched +/// until the copy settles +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RelocationState { + /// What is being moved where; cleared when the user leaves the move screen + pub plan: Option, + /// Bytes written so far and the total to write, reported by the copy itself + pub copied: u64, + pub total: u64, + /// Set once the copy settles, which turns the screen into its result + pub result: Option>, } /// In-flight installs so the button that started one stays disabled until it lands @@ -69,12 +86,11 @@ impl InstallState { pub struct LauncherInit { pub ready: bool, pub fetching: bool, - /// Launch is disabled until this clears pub syncing_bundles: bool, pub error: Option, pub data_dir: String, - /// Restorable database snapshots, for the startup failure screen pub snapshots: usize, + pub needs_location: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 0e075fe3..eec4c621 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -24,6 +24,58 @@ pub fn border_all_color(width: f32, color: Color) -> Border { }) } +/// A tinted strip of explanation under a row; `accent` washes the background +pub fn note(message: String, accent: Color) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(accent.with_a(30)) + .child( + label() + .text(message) + .font_size(12.) + .max_lines(5) + .width(Size::flex(1.0)) + .color(theme::colors::fg_primary()), + ) + .into_element() +} + +/// A captioned, read-only path in a boxed field +pub fn path_block(caption: &'static str, path: &std::path::Path) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child( + label() + .text(caption) + .font_size(11.) + .color(theme::colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme::colors::component_bg()) + .border(border_all_color(1., theme::colors::component_border())) + .child( + label() + .text(path.display().to_string()) + .font_size(12.) + .max_lines(3) + .width(Size::fill()) + .color(theme::colors::fg_primary()), + ), + ) + .into_element() +} + pub fn fmt_date(ts: chrono::DateTime) -> String { ts.format("%Y-%m-%d %H:%M").to_string() } diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index 5eec8d3d..7bd60bca 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -1,12 +1,19 @@ +use std::path::{Path, PathBuf}; + use freya::prelude::*; use freya::router::RouterContext; +use oneclient_core::relocate::RelocationPlan; +use oneclient_core::storage::format_bytes; -use super::settings_page; -use crate::components::{Button, IconType, link_button, toggle}; -use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; -use crate::platform; -use crate::routes::Route; -use crate::view::app::settings::{section_header, settings_row}; +use super::{section_header, settings_page, settings_row}; +use crate::Route; +use crate::components::{Button, Icon, IconType, OverlayPopup, open_folder_button, toggle}; +use crate::hooks::{ + Actions, DiscardLeftoversKeys, mutation_error, mutation_is_running, try_leftovers, + use_discard_leftovers, use_dispatch, use_launcher, use_leftovers, use_settings_snapshot, +}; +use crate::theme::colors; +use crate::ui::{border_all_color, note, path_block}; #[derive(PartialEq)] pub struct SettingsLauncher; @@ -14,7 +21,6 @@ pub struct SettingsLauncher; impl Component for SettingsLauncher { fn render(&self) -> impl IntoElement { let settings = use_settings_snapshot().settings; - let data_dir = use_launcher().data_dir; let dispatch = use_dispatch(); let discord_rpc = use_state({ @@ -44,9 +50,6 @@ impl Component for SettingsLauncher { }); } - let folder = data_dir.clone(); - let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); - // The only way back for someone who declined during onboarding let consent_summary = if settings.declined_tos { "Declined. Poly+ and crash reporting stay off until you accept and restart OneClient." @@ -82,12 +85,230 @@ impl Component for SettingsLauncher { review_terms, )) .child(section_header("FOLDERS AND FILES")) - .child(settings_row( - IconType::Folder, - "Launcher Folder", - data_dir, - open_folder, - )) + .child(DataFolder.into_element()) .into_element() } } + +#[derive(PartialEq)] +struct DataFolder; + +impl Component for DataFolder { + fn render(&self) -> impl IntoElement { + let discard = use_discard_leftovers(); + let leftovers_query = use_leftovers(); + let data_dir = use_launcher().data_dir; + let actions = use_dispatch(); + + let pending = use_state(|| None::); + let error = use_state(|| None::); + let checking = use_state(|| false); + + let checking_now = *checking.read(); + + let change = Button::new() + .secondary() + .small() + .disabled(checking_now) + .on_press(move |_| browse(pending, error, checking)) + .text(if checking_now { "Checking…" } else { "Change…" }); + + let mut section = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child(settings_row( + IconType::Folder, + "Launcher Folder", + data_dir.clone(), + row_actions(PathBuf::from(data_dir), change), + )); + + if let Some(message) = error.read().clone() { + section = section.child(note(message, colors::danger())); + } + + if let Some(left) = try_leftovers(&leftovers_query) { + let clearing = mutation_is_running(&discard); + + let remove = Button::new() + .danger() + .small() + .disabled(clearing) + .on_press(move |_| { + discard.mutate(DiscardLeftoversKeys); + }) + .text(if clearing { "Removing…" } else { "Remove" }); + + section = section.child(settings_row( + IconType::Database01, + "Old location", + format!( + "{} still sitting in {}. Nothing uses it any more.", + format_bytes(left.bytes), + left.path.display() + ), + row_actions(left.path, remove), + )); + } + + if let Some(message) = mutation_error(&discard) { + section = section.child(note(message, colors::danger())); + } + + if let Some(planned) = pending.read().clone() { + section = section.child(confirm_move(planned, pending, actions)); + } + + section.into_element() + } +} + +fn browse( + mut pending: State>, + mut error: State>, + mut checking: State, +) { + if *checking.peek() { + return; + } + + spawn(async move { + let mut dialog = + rfd::AsyncFileDialog::new().set_title("Choose where OneClient should store game data"); + + if let Some(start) = oneclient_common::paths::picker_start_dir() { + dialog = dialog.set_directory(start); + } + + let Some(handle) = dialog.pick_folder().await else { + return; + }; + + checking.set(true); + error.set(None); + + match plan_move(handle.path()).await { + Ok(planned) => pending.set(Some(planned)), + Err(message) => error.set(Some(message)), + } + + checking.set(false); + }); +} + +async fn plan_move(picked: &Path) -> Result { + let state = crate::launcher::state().map_err(|err| err.to_string())?; + oneclient_core::relocate::plan(&state, picked).await +} + +fn row_actions(folder: PathBuf, action: Button) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(open_folder_button(folder)) + .child(action) +} + +fn confirm_move( + planned: RelocationPlan, + mut pending: State>, + actions: Actions, +) -> Element { + let start = planned.clone(); + + let free_after = planned + .available + .map(|available| available.saturating_sub(planned.bytes)); + + let mut card = rect() + .vertical() + .width(Size::px(460.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(Icon::new(IconType::FolderDownload).size(20.)) + .child( + label() + .text("Move game data?") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(path_block("From", &planned.from)) + .child(path_block("To", &planned.to)) + .child( + label() + .text(match free_after { + Some(free) => format!( + "{} to copy, leaving {} free on the new drive.", + format_bytes(planned.bytes), + format_bytes(free) + ), + None => format!("{} to copy.", format_bytes(planned.bytes)), + }) + .font_size(12.) + .color(colors::fg_secondary()), + ); + + if let Some(warning) = planned.warning.clone() { + card = card.child(note(warning, colors::code_warn())); + } + + card = card + .child( + label() + .text( + "Your settings and sign-in stay where they are. The old copy is kept until \ + you remove it, and OneClient has to restart before it uses the new folder. \ + The launcher shows the move on its own screen until it is done.", + ) + .font_size(12.) + .max_lines(4) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| pending.set(None)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + actions.relocate(start.clone()); + pending.set(None); + let _ = RouterContext::get().replace(Route::Relocating {}); + }) + .text("Move"), + ), + ); + + OverlayPopup::new() + .on_close(move |_| pending.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(card), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index 8ab5b2e8..de5acd8d 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -7,6 +7,7 @@ use crate::hooks::{ StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, }; use crate::theme::colors; +use crate::utils::plural; /// Rows narrower than this would render as a sliver so they get a floor const MIN_BAR_FRACTION: f32 = 0.015; @@ -20,9 +21,7 @@ impl Component for SettingsStorage { let report_query = use_storage_report(); let Some(report) = try_storage_report(&report_query) else { - return settings_page() - .child(hero_placeholder()) - .into_element(); + return settings_page().child(hero_placeholder()).into_element(); }; let refresh = Button::new() @@ -33,7 +32,7 @@ impl Component for SettingsStorage { }) .child(label().text("Refresh")); - let mut page = settings_page() + settings_page() .child(hero(&report, refresh.into_element())) .child(section_header("FREE UP SPACE")) .child( @@ -55,33 +54,21 @@ impl Component for SettingsStorage { 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() + ) + .child(section_header("WHAT'S USING SPACE")) + .child(usage_list( + &report.categories, + colors::brand(), + "Nothing stored yet.", + )) + .child(section_header("CLUSTERS")) + .child(usage_list( + &report.clusters, + colors::fg_secondary(), + "No cluster is using any space.", + )) + .child(footnote()) + .into_element() } } @@ -145,7 +132,6 @@ fn hero_placeholder() -> impl IntoElement { .into_element() } -/// A component rather than a function because it owns a hook each instance gets its own scope so a button tracks only its own progress #[derive(PartialEq)] struct ReclaimRow { icon: IconType, @@ -261,6 +247,20 @@ fn proportion_bar(fraction: f32, fill: Color) -> impl IntoElement { .into_element() } +/// One row per entry that holds anything, bars scaled against the biggest of them +fn usage_list(entries: &[StorageEntry], bar: Color, empty: &'static str) -> Element { + let Some(largest) = largest(entries) else { + return empty_note(empty).into_element(); + }; + + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for entry in entries.iter().filter(|entry| entry.bytes > 0) { + list = list.child(usage_row(entry, largest, bar)); + } + + list.into_element() +} + fn largest(entries: &[StorageEntry]) -> Option { entries.iter().map(|e| e.bytes).max().filter(|max| *max > 0) } @@ -274,7 +274,7 @@ fn unused_cache_description(entry: &ReclaimableEntry) -> String { "{} across {} file{} that no installed package points at.", format_bytes(entry.bytes), entry.files, - plural(entry.files) + plural(entry.files as i64) ) } @@ -288,7 +288,7 @@ fn legacy_content_description(entry: &ReclaimableEntry) -> String { you play; this just reclaims the space.", format_bytes(entry.bytes), entry.files, - plural(entry.files) + plural(entry.files as i64) ) } @@ -321,7 +321,3 @@ fn footnote() -> impl IntoElement { ) .into_element() } - -fn plural(count: usize) -> &'static str { - if count == 1 { "" } else { "s" } -} diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs index e063a4c9..3eb93696 100644 --- a/packages/oneclient_app/src/view/mod.rs +++ b/packages/oneclient_app/src/view/mod.rs @@ -1,4 +1,6 @@ mod not_found; +mod relocating; +mod setup_location; mod startup; pub mod app; @@ -6,4 +8,6 @@ pub mod console; pub mod onboarding; pub use not_found::NotFound; +pub use relocating::Relocating; +pub use setup_location::SetupLocation; pub use startup::Startup; diff --git a/packages/oneclient_app/src/view/relocating.rs b/packages/oneclient_app/src/view/relocating.rs new file mode 100644 index 00000000..7e382b37 --- /dev/null +++ b/packages/oneclient_app/src/view/relocating.rs @@ -0,0 +1,177 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::relocate::{RelocationOutcome, RelocationPlan}; +use oneclient_core::storage::format_bytes; + +use crate::Route; +use crate::components::{Button, Icon, IconType, progress_track}; +use crate::hooks::{Actions, use_dispatch, use_relocation}; +use crate::theme::colors; +use crate::ui::{note, path_block}; +use crate::utils::plural; + +#[derive(PartialEq)] +pub struct Relocating; + +impl Component for Relocating { + fn render(&self) -> impl IntoElement { + let relocation = use_relocation(); + let actions = use_dispatch(); + + let Some(plan) = relocation.plan.clone() else { + // Landed here without a move in flight + let _ = RouterContext::get().replace(Route::SettingsLauncher {}); + return rect().into_element(); + }; + + let content = match &relocation.result { + None => copying(&plan, relocation.copied, relocation.total), + Some(Ok(outcome)) => moved(outcome, actions), + Some(Err(message)) => failed(message.clone(), actions), + }; + + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .background(colors::page()) + .window_drag() + .child(content) + .into_element() + } +} + +fn copying(plan: &RelocationPlan, copied: u64, total: u64) -> Element { + // The plan's estimate stands in until the copy has counted the tree itself + let total = if total > 0 { total } else { plan.bytes }.max(1); + + screen() + .child(heading(IconType::FolderDownload, "Moving your data folder", colors::fg_primary())) + .child(path_block("From", &plan.from)) + .child(path_block("To", &plan.to)) + .child(progress_track( + copied as f32 / total as f32 * 100., + 6., + colors::brand(), + colors::component_bg(), + )) + .child( + label() + .text(format!( + "{} of {}", + format_bytes(copied), + format_bytes(total) + )) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(note( + "Leave OneClient open until this finishes. Nothing is removed from the old folder, \ + and the launcher stays closed to everything else while files are copied." + .to_string(), + colors::brand(), + )) + .into_element() +} + +fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element { + let mut body = format!( + "{} copied to {}. OneClient keeps running from the old folder until you restart it.", + format_bytes(outcome.bytes), + outcome.to.display() + ); + + if outcome.skipped_links > 0 { + body.push_str(&format!( + " {} shortcut{} pointing outside the folder were left behind.", + outcome.skipped_links, + plural(outcome.skipped_links as i64) + )); + } + + screen() + .child(heading(IconType::FolderCheck, "Game data moved", colors::brand())) + .child(note(body, colors::brand())) + .child( + label() + .text( + "The old copy is still there. Once you have restarted, Launcher Settings can \ + remove it.", + ) + .font_size(12.) + .max_lines(3) + .color(colors::fg_secondary()), + ) + .child( + buttons() + .child(back_button(actions)) + .child( + Button::new() + .primary() + .on_press(|_| { + let platform = Platform::get(); + Platform::get().with_window(None, move |window| { + platform.close_window(window.id()); + }); + }) + .text("Quit OneClient"), + ), + ) + .into_element() +} + +fn failed(message: String, actions: Actions) -> Element { + screen() + .child(heading(IconType::AlertTriangle, "The move didn't finish", colors::danger())) + .child(note(message, colors::danger())) + .child( + label() + .text("Nothing was changed. OneClient is still running from its current folder.") + .font_size(12.) + .max_lines(2) + .color(colors::fg_secondary()), + ) + .child(buttons().child(back_button(actions))) + .into_element() +} + +fn back_button(actions: Actions) -> Button { + Button::new() + .secondary() + .on_press(move |_| { + actions.end_relocation(); + let _ = RouterContext::get().replace(Route::SettingsLauncher {}); + }) + .text("Back to settings") +} + +fn screen() -> Rect { + rect() + .vertical() + .width(Size::px(460.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) +} + +fn heading(icon: IconType, title: &'static str, tint: Color) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(Icon::new(icon).size(20.).color(tint)) + .child( + label() + .text(title) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) +} + +fn buttons() -> Rect { + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) +} diff --git a/packages/oneclient_app/src/view/setup_location.rs b/packages/oneclient_app/src/view/setup_location.rs new file mode 100644 index 00000000..5a902c32 --- /dev/null +++ b/packages/oneclient_app/src/view/setup_location.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use freya::router::RouterContext; + +use crate::components::Button; +use crate::hooks::{use_dispatch, use_launcher}; +use crate::routes::Route; +use crate::state::AppChannel; +use crate::theme::colors; +use crate::ui::note; +use crate::view::onboarding::choice_row; + +#[derive(PartialEq)] +pub struct SetupLocation; + +impl Component for SetupLocation { + fn render(&self) -> impl IntoElement { + let actions = use_dispatch(); + let launcher = use_launcher(); + + let mut picked = use_state(|| None::); + let mut warning = use_state(|| None::); + let mut error = use_state(|| None::); + let mut busy = use_state(|| false); + + if !launcher.needs_location { + let _ = RouterContext::get().replace(Route::Startup {}); + return rect().into_element(); + } + + let default_dir = oneclient_common::paths::config_dir() + .map(|dir| dir.display().to_string()) + .unwrap_or_default(); + + let confirm = move |_| { + if *busy.peek() { + return; + } + + let chosen = picked.peek().clone(); + let actions = actions.clone(); + let station = actions.station(); + let events = actions.events(); + + busy.set(true); + error.set(None); + + spawn_forever(async move { + if let Err(message) = + oneclient_core::settings::data_dir::apply(chosen).await + { + error.set(Some(message)); + busy.set(false); + return; + } + + { + let mut station = station; + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher.needs_location = false; + } + + match crate::events::start_launcher(station, events).await { + Ok(()) => actions.sync_bundles(), + Err(err) => crate::events::report_startup_failure(&station, &err), + } + }); + }; + + let chosen = picked.read().clone(); + let running = *busy.read(); + + let mut content = rect() + .vertical() + .width(Size::px(520.)) + .spacing(20.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text("Choose a folder location for OneClient") + .font_size(26.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Any content downloaded via OneClient (such as Minecraft versions, mods, your worlds) will go in this folder. Make sure you have sufficient storage in your chosen disk.", + ) + .font_size(14.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child(choice_row( + "This PC", + &default_dir, + chosen.is_none(), + move |()| { + picked.set(None); + warning.set(None); + error.set(None); + }, + )) + .child(choice_row( + "Another drive", + &chosen + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "Click to pick a folder…".to_string()), + chosen.is_some(), + move |()| browse(picked, warning, error), + )), + ); + + if let Some(message) = warning.read().clone() { + content = content.child(note(message, colors::code_warn())); + } + + if let Some(message) = error.read().clone() { + content = content.child(note(message, colors::danger())); + } + + content = content.child( + Button::new() + .primary() + .width(Size::px(160.)) + .enabled(!running) + .on_press(confirm) + .text(if running { "Setting up…" } else { "Continue" }), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .background(colors::page()) + .window_drag() + .child(content) + .into_element() + } +} + +fn browse( + mut picked: State>, + mut warning: State>, + mut error: State>, +) { + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Choose where OneClient stores game data") + .pick_folder() + .await + else { + return; + }; + + match oneclient_core::settings::data_dir::check(handle.path()).await { + Ok(checked) => { + error.set(None); + warning.set(checked.warning); + picked.set(Some(checked.path)); + } + Err(message) => { + picked.set(None); + warning.set(None); + error.set(Some(message)); + } + } + }); +} diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index f0be84b5..063dc1ed 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -89,6 +89,11 @@ impl Component for Startup { let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); + if launcher.needs_location { + let _ = RouterContext::get().replace(Route::SetupLocation {}); + return rect().into_element(); + } + if launcher.ready && !terms_is_loading(&terms) { let document = terms_document(&terms); let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 143b81f6..c16140d6 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -1,4 +1,4 @@ -use directories::ProjectDirs; +use directories::{BaseDirs, ProjectDirs}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -10,63 +10,112 @@ const ORGANIZATION: &str = "Polyfrost"; #[cfg(not(debug_assertions))] const APPLICATION: &str = "OneClient"; -// Separate dir so a running dev environment never touches prod data + #[cfg(debug_assertions)] const APPLICATION: &str = "OneClient-dev"; -static PROJECT_DIRS: OnceLock = OnceLock::new(); -static LAUNCHER_DIR_OVERRIDE: OnceLock = OnceLock::new(); +const SETTINGS_FILE: &str = "settings.json"; + +static DEFAULT_DIR: OnceLock = OnceLock::new(); +static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); +static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); pub fn set_launcher_dir(dir: PathBuf) { - let _ = LAUNCHER_DIR_OVERRIDE.set(dir); + let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); + let _ = DATA_DIR_OVERRIDE.set(dir); } -fn project_dirs() -> PathsResult<&'static ProjectDirs> { - if let Some(dirs) = PROJECT_DIRS.get() { - return Ok(dirs); +pub fn set_data_dir(dir: PathBuf) { + let _ = DATA_DIR_OVERRIDE.set(dir); +} + +fn organization_dir() -> PathsResult { + BaseDirs::new() + .map(|dirs| dirs.data_dir().join(ORGANIZATION)) + .ok_or(PathsError::DataDirUnavailable) +} + +fn legacy_dir() -> Option { + ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) + .map(|dirs| dirs.data_local_dir().to_path_buf()) +} + +fn resolve_default_dir() -> PathsResult { + if let Some(legacy) = legacy_dir() + && legacy.join(SETTINGS_FILE).is_file() + { + return Ok(legacy); } - let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) - .ok_or(PathsError::DataDirUnavailable)?; - - let _ = PROJECT_DIRS.set(dirs); - PROJECT_DIRS.get().ok_or(PathsError::DataDirUnavailable) + Ok(organization_dir()?.join(APPLICATION)) } -pub fn launcher_dir() -> PathsResult<&'static Path> { - if let Some(dir) = LAUNCHER_DIR_OVERRIDE.get() { +fn default_dir() -> PathsResult<&'static Path> { + if let Some(dir) = DEFAULT_DIR.get() { return Ok(dir); } - Ok(project_dirs()?.data_local_dir()) + let _ = DEFAULT_DIR.set(resolve_default_dir()?); + DEFAULT_DIR + .get() + .map(PathBuf::as_path) + .ok_or(PathsError::DataDirUnavailable) +} + +pub fn config_dir() -> PathsResult<&'static Path> { + if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { + return Ok(dir); + } + + default_dir() +} + +pub fn data_dir() -> PathsResult<&'static Path> { + if let Some(dir) = DATA_DIR_OVERRIDE.get() { + return Ok(dir); + } + + config_dir() +} + +#[must_use] +pub fn picker_start_dir() -> Option { + organization_dir() + .ok() + .filter(|dir| dir.is_dir()) + .or_else(|| data_dir().ok().map(Path::to_path_buf)) } pub fn database_file() -> PathsResult { - Ok(launcher_dir()?.join("user_data.db")) + Ok(data_dir()?.join("user_data.db")) } pub fn settings_file() -> PathsResult { - Ok(launcher_dir()?.join("settings.json")) + Ok(config_dir()?.join(SETTINGS_FILE)) +} + +pub fn damaged_settings_file() -> PathsResult { + Ok(config_dir()?.join("settings.json.corrupt")) } pub fn auth_file() -> PathsResult { - Ok(launcher_dir()?.join("auth.json")) + Ok(config_dir()?.join("auth.json")) } pub fn logs_dir() -> PathsResult { - Ok(launcher_dir()?.join("logs")) + Ok(config_dir()?.join("logs")) } pub fn java_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("java")) + Ok(data_dir()?.join("metadata").join("java")) } pub fn clusters_dir() -> PathsResult { - Ok(launcher_dir()?.join("clusters")) + Ok(data_dir()?.join("clusters")) } pub fn shared_minecraft_dir() -> PathsResult { - Ok(launcher_dir()?.join(".minecraft")) + Ok(data_dir()?.join(".minecraft")) } /// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` @@ -91,11 +140,11 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult { } pub fn packages_cache_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("packages")) + Ok(data_dir()?.join("metadata").join("packages")) } pub fn caches_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("caches")) + Ok(data_dir()?.join("metadata").join("caches")) } pub fn bundles_dir() -> PathsResult { @@ -111,19 +160,19 @@ pub fn profiles_cache_dir() -> PathsResult { } pub fn versions_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("versions")) + Ok(data_dir()?.join("metadata").join("versions")) } pub fn libraries_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("libraries")) + Ok(data_dir()?.join("metadata").join("libraries")) } pub fn natives_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("natives")) + Ok(data_dir()?.join("metadata").join("natives")) } pub fn assets_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("assets")) + Ok(data_dir()?.join("metadata").join("assets")) } pub fn assets_index_dir() -> PathsResult { @@ -135,7 +184,7 @@ pub fn assets_object_dir() -> PathsResult { } pub fn legacy_assets_dir() -> PathsResult { - Ok(launcher_dir()?.join("metadata").join("resources")) + Ok(data_dir()?.join("metadata").join("resources")) } pub fn package_version_dir( @@ -150,3 +199,44 @@ pub fn package_version_dir( .join(project_id) .join(version_id)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + + #[test] + fn the_launcher_sits_one_level_under_the_organization_folder() { + let organization = organization_dir().expect("a home directory"); + assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); + + let app = organization.join(APPLICATION); + let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); + + assert_eq!( + tail, + vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], + "every Polyfrost product shares one folder and takes a single name inside it" + ); + } + + #[cfg(windows)] + #[test] + fn a_windows_install_roams_with_the_user() { + let organization = organization_dir().expect("a home directory"); + + assert!( + organization.iter().any(|part| part == OsStr::new("Roaming")), + "settings and saves follow the user between machines; Local would strand them" + ); + } + + #[test] + fn a_debug_build_never_shares_a_folder_with_a_release_one() { + assert_eq!(APPLICATION, if cfg!(debug_assertions) { + "OneClient-dev" + } else { + "OneClient" + }); + } +} diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs index 8fc2315e..2159057a 100644 --- a/packages/oneclient_content/src/bundles/manager.rs +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -37,7 +37,7 @@ impl Bundle { let loader = GameLoader::from_repr(row.mc_loader as u8) .ok_or(BundleError::InvalidLoader(row.mc_loader))?; - let launcher_dir = paths::launcher_dir()?; + let launcher_dir = paths::data_dir()?; let path = launcher_dir.join(&row.disk_path); Ok(Self { @@ -212,7 +212,7 @@ impl BundlesManager { let meta = polymrpack::read_meta_from_archive(&disk_path).await?; let relative_disk_path = disk_path - .strip_prefix(paths::launcher_dir()?) + .strip_prefix(paths::data_dir()?) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| disk_path.to_string_lossy().into_owned()); diff --git a/packages/oneclient_content/src/packages/store/paths.rs b/packages/oneclient_content/src/packages/store/paths.rs index a6ae50a2..b7613bf9 100644 --- a/packages/oneclient_content/src/packages/store/paths.rs +++ b/packages/oneclient_content/src/packages/store/paths.rs @@ -15,12 +15,12 @@ pub fn cache_file_path( } pub fn artifact_absolute_path(stored_path: &str) -> ContentResult { - let root = paths::launcher_dir()?; + let root = paths::data_dir()?; Ok(root.join(stored_path)) } pub fn relative_cache_path(abs: &Path) -> ContentResult { - let root = paths::launcher_dir()?; + let root = paths::data_dir()?; let rel = abs .strip_prefix(root) diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index e2cf62e3..7001dc75 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -312,7 +312,7 @@ pub async fn seed_bundle_archive( ) .await?; - let path = oneclient_common::paths::launcher_dir()?.join(&disk_path); + let path = oneclient_common::paths::data_dir()?.join(&disk_path); state.bundles.cache_archive_manifest(path, manifest).await; Ok(()) diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 211e1935..40abb82f 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -157,6 +157,10 @@ impl GameProcessManager { self.inner.lock().unwrap().get(&cluster_id).map(|p| p.stage) } + pub fn active_ids(&self) -> Vec { + self.inner.lock().unwrap().keys().copied().collect() + } + pub fn running_ids(&self) -> Vec { self.inner .lock() diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index eb837337..f53bdb5f 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -352,7 +352,7 @@ const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; #[tracing::instrument(level = "debug")] pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { - let root = oneclient_common::paths::launcher_dir()?; + let root = oneclient_common::paths::data_dir()?; let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); let sep = std::path::MAIN_SEPARATOR; diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 2cc161d2..987e306b 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -12,6 +12,7 @@ mod java_store; pub mod logger; pub mod migration; pub mod recovery; +pub mod relocate; pub mod reporting; pub mod settings; pub mod storage; diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 60ae7ee0..699552a3 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -102,7 +102,7 @@ async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { return Ok(0); } - let launcher_dir = paths::launcher_dir()?; + let launcher_dir = paths::data_dir()?; let mut indexed = 0; for content_name in list_dir_names(&cache_root).await? { diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs new file mode 100644 index 00000000..567a5537 --- /dev/null +++ b/packages/oneclient_core/src/relocate.rs @@ -0,0 +1,754 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use oneclient_content::packages::store::{manifest, remove_entry}; +use oneclient_db::DbPool; +use sqlx::Acquire; + +use oneclient_common::paths; + +use crate::LauncherState; +use crate::settings::data_dir; +use crate::settings::store::save_settings; +use crate::storage::{dir_size, format_bytes}; + +pub const IN_PROGRESS_MARKER: &str = ".oneclient_migrating"; + +static IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +#[must_use] +pub fn in_progress() -> bool { + IN_PROGRESS.load(Ordering::SeqCst) +} + +struct InProgress; + +impl InProgress { + fn begin() -> Self { + IN_PROGRESS.store(true, Ordering::SeqCst); + Self + } +} + +impl Drop for InProgress { + fn drop(&mut self) { + IN_PROGRESS.store(false, Ordering::SeqCst); + } +} + +const HEADROOM: f64 = 1.05; + +const REPORT_EVERY: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RelocationPlan { + pub from: PathBuf, + pub to: PathBuf, + pub bytes: u64, + pub available: Option, + pub warning: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelocationOutcome { + pub to: PathBuf, + pub bytes: u64, + pub skipped_links: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Leftovers { + pub path: PathBuf, + pub bytes: u64, +} + +pub async fn plan(state: &LauncherState, picked: &Path) -> Result { + let from = current_dir()?; + + if let Some(reason) = busy_reason(state) { + return Err(reason); + } + + if let Some(reason) = restart_pending(state, &from) { + return Err(reason); + } + + let checked = data_dir::check(picked).await?; + let to = checked.path; + + if to == from { + return Err("Your game data is already there.".to_string()); + } + + if to.starts_with(&from) || from.starts_with(&to) { + return Err(format!( + "Pick a folder outside {} — one cannot hold the other.", + from.display() + )); + } + + if incomplete(&to) { + return Err(format!( + "{} holds a move that was interrupted. Delete that folder and try again.", + to.display() + )); + } + + if polyio::dir_has_content(&to).await { + return Err(format!("{} is not empty. Pick an empty folder.", to.display())); + } + + if let Some(old) = leftovers(state).await { + return Err(format!( + "Clear the {} still sitting in {} first — OneClient keeps track of one old folder at \ + a time.", + format_bytes(old.bytes), + old.path.display() + )); + } + + let bytes = collect(&from, &skipped_names(&from)).await?.total; + let available = data_dir::available_space(&to); + + if let Some(available) = available + && (available as f64) < bytes as f64 * HEADROOM + { + return Err(format!( + "{} needs {} and only {} is free there.", + to.display(), + format_bytes(bytes), + format_bytes(available) + )); + } + + Ok(RelocationPlan { + from, + to, + bytes, + available, + warning: checked.warning, + }) +} + +/// `on_copied` is handed the bytes written and the total to write, throttled to +/// one call per [`REPORT_EVERY`] bytes plus a final one when the tree lands +#[tracing::instrument(skip(state, plan, on_copied), fields(from = %plan.from.display(), to = %plan.to.display()))] +pub async fn relocate( + state: &LauncherState, + plan: &RelocationPlan, + mut on_copied: impl FnMut(u64, u64), +) -> Result { + if let Some(reason) = busy_reason(state) { + return Err(reason); + } + + if plan.from != current_dir()? { + return Err("The data folder changed since this move was planned.".to_string()); + } + + if let Some(reason) = restart_pending(state, &plan.from) { + return Err(reason); + } + + let _in_progress = InProgress::begin(); + + drop_materialized_content(state).await; + + let skip = skipped_names(&plan.from); + let found = collect(&plan.from, &skip).await?; + + polyio::create_dir_all(&plan.to) + .await + .map_err(|err| format!("Couldn't create {}: {err}", plan.to.display()))?; + + let marker = plan.to.join(IN_PROGRESS_MARKER); + polyio::write(&marker, b"".as_slice()) + .await + .map_err(|err| format!("Couldn't write to {}: {err}", plan.to.display()))?; + + let copied = match write_everything(state, plan, &found, &mut on_copied).await { + Ok(copied) => copied, + Err(err) => { + abandon(&plan.to).await; + return Err(err); + } + }; + + polyio::remove_file(&marker) + .await + .map_err(|err| format!("Couldn't finish the move at {}: {err}", plan.to.display()))?; + + if let Err(err) = commit(state, &plan.from, &plan.to).await { + discard_unclaimed(&plan.to).await; + return Err(err); + } + + tracing::info!( + bytes = copied, + path = %plan.to.display(), + "the game data moved; a restart picks up the new folder" + ); + + Ok(RelocationOutcome { + to: plan.to.clone(), + bytes: copied, + skipped_links: found.links.len(), + }) +} + +pub async fn leftovers(state: &LauncherState) -> Option { + let path = state.settings.read().previous_data_dir.clone()?; + + if current_dir().is_ok_and(|current| current == path) { + return None; + } + + if !polyio::try_exists(&path).await.unwrap_or(false) { + return None; + } + + Some(Leftovers { + bytes: dir_size(&path).await, + path, + }) +} + +#[tracing::instrument(skip(state))] +pub async fn discard_leftovers(state: &LauncherState) -> Result { + let Some(old) = state.settings.read().previous_data_dir.clone() else { + return Ok(0); + }; + + let current = current_dir()?; + if old == current { + return Err("That is the folder you are using now.".to_string()); + } + + let freed = dir_size(&old).await; + let keep = config_owned_names(&old); + + if keep.is_empty() { + polyio::remove_dir_all(&old) + .await + .map_err(|err| format!("Couldn't remove {}: {err}", old.display()))?; + } else { + remove_children_except(&old, &keep).await?; + } + + { + let mut settings = state.settings.write(); + settings.previous_data_dir = None; + } + + let snapshot = state.settings.read().clone(); + save_settings(&snapshot) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; + + Ok(freed) +} + +#[must_use] +pub fn incomplete(dir: &Path) -> bool { + dir.join(IN_PROGRESS_MARKER).exists() +} + +fn current_dir() -> Result { + paths::data_dir() + .map(Path::to_path_buf) + .map_err(|err| format!("Couldn't work out where your game data lives: {err}")) +} + +pub fn restart_pending(state: &LauncherState, current: &Path) -> Option { + let settled = state.settings.read().data_dir.clone()?; + + (settled != current).then(|| { + format!( + "A move to {} is already waiting. Restart OneClient to finish it.", + settled.display() + ) + }) +} + +fn busy_reason(state: &LauncherState) -> Option { + let active = state.games.active_ids().len(); + if active == 0 { + return None; + } + + Some(format!( + "Close Minecraft first — {active} instance{} still open.", + if active == 1 { " is" } else { "s are" } + )) +} + +fn skipped_names(from: &Path) -> Vec { + let mut names = database_names(); + names.extend(config_owned_names(from)); + names +} + +fn database_names() -> Vec { + let Ok(db) = paths::database_file() else { + return Vec::new(); + }; + let Some(name) = db.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; + + ["", "-wal", "-shm", "-journal"] + .iter() + .map(|suffix| OsString::from(format!("{name}{suffix}"))) + .collect() +} + +fn config_owned_names(dir: &Path) -> Vec { + if paths::config_dir().is_ok_and(|config| config == dir) { + [ + paths::settings_file(), + paths::damaged_settings_file(), + paths::auth_file(), + paths::logs_dir(), + ] + .into_iter() + .flatten() + .filter_map(|path| path.file_name().map(std::ffi::OsStr::to_os_string)) + .collect() + } else { + Vec::new() + } +} + +async fn drop_materialized_content(state: &LauncherState) { + let mut dirs = Vec::new(); + + if let Ok(shared) = paths::shared_minecraft_dir() { + dirs.push(shared); + } + + match state.clusters.list().await { + Ok(clusters) => dirs.extend( + clusters + .iter() + .filter(|cluster| cluster.uses_dedicated_dir()) + .filter_map(|cluster| cluster.game_dir().ok()), + ), + Err(err) => tracing::warn!(%err, "could not list clusters; only clearing the shared folder"), + } + + for dir in dirs { + let Some(loaded) = manifest::load(&dir).await else { + continue; + }; + + for entry in &loaded.entries { + let mut path = dir.clone(); + path.extend(entry.path.split('/')); + + if let Err(err) = remove_entry(&path).await { + tracing::warn!(path = %path.display(), %err, "could not clear linked package"); + } + } + + manifest::clear(&dir).await; + } + + if let Ok(shared) = paths::shared_minecraft_dir() { + crate::game::unlink_cluster_logs(&shared).await; + } +} + +#[derive(Debug, Default)] +struct Found { + dirs: Vec, + files: Vec<(PathBuf, u64)>, + links: Vec, + total: u64, +} + +async fn collect(root: &Path, skip_top: &[OsString]) -> Result { + let mut found = Found::default(); + let mut stack = vec![(root.to_path_buf(), true)]; + + while let Some((dir, is_top)) = stack.pop() { + let mut entries = match polyio::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) => { + return Err(format!("Couldn't read {}: {err}", dir.display())); + } + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name(); + + if is_top && skip_top.contains(&name) { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + let Ok(relative) = path.strip_prefix(root).map(Path::to_path_buf) else { + continue; + }; + + if file_type.is_symlink() { + found.links.push(relative); + continue; + } + + if file_type.is_dir() { + found.dirs.push(relative); + stack.push((path, false)); + } else if let Ok(meta) = entry.metadata().await { + found.total += meta.len(); + found.files.push((relative, meta.len())); + } + } + } + + Ok(found) +} + +async fn copy_tree( + from: &Path, + to: &Path, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), +) -> Result { + for relative in &found.dirs { + let dir = to.join(relative); + polyio::create_dir_all(&dir) + .await + .map_err(|err| format!("Couldn't create {}: {err}", dir.display()))?; + } + + for link in &found.links { + tracing::warn!(path = %link.display(), "leaving a link behind; it points outside our tree"); + } + + let mut copied = 0u64; + let mut reported = 0u64; + + for (relative, size) in &found.files { + let src = from.join(relative); + let dest = to.join(relative); + + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + let written = polyio::copy(&src, &dest) + .await + .map_err(|err| format!("Couldn't copy {}: {err}", relative.display()))?; + + if written != *size { + return Err(format!( + "{} arrived short. Nothing was removed from the old folder.", + relative.display() + )); + } + + copied += written; + + if copied - reported >= REPORT_EVERY { + reported = copied; + on_copied(copied, found.total); + } + } + + on_copied(copied, found.total.max(copied)); + Ok(copied) +} + +async fn write_everything( + state: &LauncherState, + plan: &RelocationPlan, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), +) -> Result { + let copied = copy_tree(&plan.from, &plan.to, found, on_copied).await?; + + if copied != found.total { + return Err(format!( + "Only {} of {} arrived at {}.", + format_bytes(copied), + format_bytes(found.total), + plan.to.display() + )); + } + + let database = snapshot_database(&state.services.db, &plan.to).await?; + + let rewritten = rewrite_java_paths(&database, &plan.from, &plan.to).await?; + if rewritten > 0 { + tracing::info!(rows = rewritten, "repointed Java runtimes at the new folder"); + } + + Ok(copied) +} + +async fn snapshot_database(pool: &DbPool, to: &Path) -> Result { + let Ok(name) = paths::database_file() else { + return Err("Couldn't work out where the database lives.".to_string()); + }; + let Some(name) = name.file_name() else { + return Err("Couldn't work out what the database is called.".to_string()); + }; + + let target = to.join(name); + + sqlx::query("VACUUM INTO ?") + .bind(target.to_string_lossy().as_ref()) + .execute(pool) + .await + .map_err(|err| format!("Couldn't copy the database to {}: {err}", target.display()))?; + + Ok(target) +} + +async fn rewrite_java_paths(database: &Path, from: &Path, to: &Path) -> Result { + let old = java_root(from); + let new = java_root(to); + + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(database) + .create_if_missing(false) + .foreign_keys(false); + + let mut conn = ::connect_with(&options) + .await + .map_err(|err| format!("Couldn't open the copied database: {err}"))?; + + let result = swap_java_prefix(&mut conn, &old, &new).await; + + if let Err(err) = sqlx::Connection::close(conn).await { + tracing::warn!(%err, "failed to close the copied database cleanly"); + } + + result +} + +fn java_root(dir: &Path) -> String { + dir.join("metadata") + .join("java") + .to_string_lossy() + .into_owned() +} + +async fn swap_java_prefix( + conn: &mut sqlx::SqliteConnection, + old: &str, + new: &str, +) -> Result { + let len = i64::try_from(old.chars().count()).unwrap_or(i64::MAX); + + let mut tx = conn + .begin() + .await + .map_err(|err| format!("Couldn't start a database transaction: {err}"))?; + + let profiles = sqlx::query( + "UPDATE setting_profiles + SET java_path = ? || substr(java_path, ?) + WHERE java_path IS NOT NULL AND substr(java_path, 1, ?) = ?", + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java settings: {err}"))?; + + let runtimes = sqlx::query( + "UPDATE java_versions + SET absolute_path = ? || substr(absolute_path, ?) + WHERE substr(absolute_path, 1, ?) = ?", + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java runtimes: {err}"))?; + + let orphans: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM setting_profiles p + WHERE p.java_path IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM java_versions j WHERE j.absolute_path = p.java_path)", + ) + .fetch_one(&mut *tx) + .await + .map_err(|err| format!("Couldn't check your Java settings: {err}"))?; + + if orphans > 0 { + tx.rollback().await.ok(); + return Err(format!( + "{orphans} of your profiles would lose their Java runtime. Nothing was changed." + )); + } + + tx.commit() + .await + .map_err(|err| format!("Couldn't save the database changes: {err}"))?; + + Ok(profiles.rows_affected() + runtimes.rows_affected()) +} + +async fn commit(state: &LauncherState, from: &Path, to: &Path) -> Result<(), String> { + { + let mut settings = state.settings.write(); + settings.data_dir = Some(to.to_path_buf()); + settings.previous_data_dir = Some(from.to_path_buf()); + } + + let snapshot = state.settings.read().clone(); + + save_settings(&snapshot).await.map_err(|err| { + format!("Everything was copied but your settings could not be saved: {err}") + }) +} + +async fn abandon(to: &Path) { + if !incomplete(to) { + tracing::error!( + path = %to.display(), + "leaving the destination alone; it is not carrying our marker" + ); + return; + } + + discard_unclaimed(to).await; +} + +async fn discard_unclaimed(to: &Path) { + match polyio::remove_dir_all(to).await { + Ok(()) => tracing::info!(path = %to.display(), "cleared the abandoned folder"), + Err(err) => tracing::error!( + path = %to.display(), + %err, + "could not clear the abandoned folder; delete it before trying again" + ), + } +} + +async fn remove_children_except(dir: &Path, keep: &[OsString]) -> Result<(), String> { + let mut entries = polyio::read_dir(dir) + .await + .map_err(|err| format!("Couldn't read {}: {err}", dir.display()))?; + + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if keep.contains(&name) { + continue; + } + + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + let removed = if file_type.is_dir() { + polyio::remove_dir_all(&path).await + } else { + polyio::remove_file(&path).await + }; + + if let Err(err) = removed { + tracing::warn!(path = %path.display(), %err, "could not remove leftover"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_managed_runtime_sits_under_the_prefix_the_swap_looks_for() { + let root = PathBuf::from("D:").join("OneClient"); + let prefix = java_root(&root); + + let runtime = paths::java_dir() + .map(|dir| dir.join("21").join("bin").join("java")) + .expect("java dir resolves"); + + let managed = root.join("metadata").join("java").join("21"); + assert!(managed.to_string_lossy().starts_with(&prefix)); + assert!(runtime.to_string_lossy().contains("java")); + } + + #[test] + fn the_write_ahead_sidecars_are_never_copied_by_hand() { + let names = database_names(); + + assert!( + names.iter().any(|name| name == "user_data.db-wal"), + "a copy that took the database but not its log would arrive stale" + ); + assert!(names.iter().any(|name| name == "user_data.db-shm")); + } + + #[test] + fn a_folder_that_is_not_the_config_folder_gives_nothing_up() { + let names = config_owned_names(Path::new("D:").join("OneClient").as_path()); + + assert!( + names.is_empty(), + "settings only stay behind when the source folder is the one holding them" + ); + } + + #[tokio::test] + async fn the_walk_skips_what_it_is_told_to_and_counts_the_rest() { + let root = polyio::testing::ScratchDir::new("relocate_collect"); + let dir = root.path(); + polyio::create_dir_all(dir.join("metadata")).await.unwrap(); + + polyio::write(dir.join("user_data.db"), b"skipped".as_slice()) + .await + .unwrap(); + polyio::write(dir.join("metadata").join("kept.bin"), b"1234".as_slice()) + .await + .unwrap(); + + let found = collect(dir, &[OsString::from("user_data.db")]).await.unwrap(); + + assert_eq!(found.total, 4, "only the file we did not skip counts"); + assert_eq!(found.files.len(), 1); + assert_eq!(found.dirs.len(), 1, "the folder itself still has to be made"); + + std::fs::remove_dir_all(dir).ok(); + } + + #[tokio::test] + async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() { + let root = polyio::testing::ScratchDir::new("relocate_skip_dir"); + let dir = root.path(); + polyio::create_dir_all(dir.join("logs")).await.unwrap(); + + polyio::write(dir.join("logs").join("latest.log"), vec![0u8; 900]) + .await + .unwrap(); + polyio::write(dir.join("kept.bin"), vec![0u8; 100]) + .await + .unwrap(); + + let found = collect(dir, &[OsString::from("logs")]).await.unwrap(); + + assert_eq!(found.total, 100, "the skipped folder is not counted"); + assert!( + found.dirs.is_empty(), + "nor is it recreated at the destination" + ); + + std::fs::remove_dir_all(dir).ok(); + } +} diff --git a/packages/oneclient_core/src/settings/data_dir.rs b/packages/oneclient_core/src/settings/data_dir.rs new file mode 100644 index 00000000..a63f9e35 --- /dev/null +++ b/packages/oneclient_core/src/settings/data_dir.rs @@ -0,0 +1,136 @@ +use std::path::{Path, PathBuf}; + +use oneclient_common::paths; + +use super::launcher::LauncherSettings; +use super::store::save_settings; + +pub const FOLDER_NAME: &str = "OneClient"; +const LOW_SPACE_BYTES: u64 = 5 * 1000 * 1000 * 1000; + +const PROBE_NAME: &str = ".oneclient_write_test"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataDirCheck { + pub path: PathBuf, + pub warning: Option, +} + +#[must_use] +pub fn resolve(picked: &Path) -> PathBuf { + if picked.file_name().is_some_and(|name| name == FOLDER_NAME) { + picked.to_path_buf() + } else { + picked.join(FOLDER_NAME) + } +} + +pub async fn check(picked: &Path) -> Result { + let path = resolve(picked); + + let existed = polyio::try_exists(&path).await.unwrap_or(false); + polyio::create_dir_all(&path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; + + let probe = path.join(PROBE_NAME); + let writable = polyio::write(&probe, b"".as_slice()).await; + polyio::remove_file(&probe).await.ok(); + + if let Err(err) = writable { + if !existed { + polyio::remove_dir(&path).await.ok(); + } + return Err(format!("Can't write to {}: {err}", path.display())); + } + + if !existed { + polyio::remove_dir(&path).await.ok(); + } + + Ok(DataDirCheck { + warning: low_space_warning(&path), + path, + }) +} + +pub async fn apply(picked: Option) -> Result<(), String> { + let mut settings = LauncherSettings::default(); + let mut chosen = None; + + if let Some(picked) = picked { + let checked = check(&picked).await?; + + polyio::create_dir_all(&checked.path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", checked.path.display()))?; + + settings.data_dir = Some(checked.path.clone()); + chosen = Some(checked.path); + } + + save_settings(&settings) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; + + if let Some(chosen) = chosen { + paths::set_data_dir(chosen); + } + + Ok(()) +} + +fn low_space_warning(path: &Path) -> Option { + let available = available_space(path)?; + if available >= LOW_SPACE_BYTES { + return None; + } + + Some(format!( + "Only {} free here. Minecraft, its libraries and a Java runtime need a few GB before you \ + install anything.", + crate::storage::format_bytes(available) + )) +} + +pub(crate) fn available_space(path: &Path) -> Option { + let target = polyio::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + + sysinfo::Disks::new_with_refreshed_list() + .iter() + .filter(|disk| target.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().as_os_str().len()) + .map(sysinfo::Disk::available_space) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drive_root_gets_a_folder_of_its_own() { + let resolved = resolve(Path::new(std::path::MAIN_SEPARATOR_STR)); + assert_eq!(resolved.file_name().unwrap(), FOLDER_NAME); + } + + #[test] + fn resolving_twice_does_not_nest() { + let once = resolve(Path::new("D:/Games")); + assert_eq!(resolve(&once), once); + } + + #[tokio::test] + async fn a_writable_folder_passes_and_is_not_left_behind() { + let root = polyio::testing::ScratchDir::new("data_dir_check"); + polyio::create_dir_all(root.path()).await.unwrap(); + + let checked = check(root.path()).await.unwrap(); + assert_eq!(checked.path, root.join(FOLDER_NAME)); + assert!( + !checked.path.exists(), + "a folder the user has not confirmed yet is litter" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index e09e932d..1b7d62bb 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::path::PathBuf; use serde::{Deserialize, Serialize}; @@ -45,6 +46,8 @@ pub struct LauncherSettings { pub curseforge_api_key: Option, pub custom_api_endpoint: Option, pub custom_meta_url_base: Option, + pub data_dir: Option, + pub previous_data_dir: Option, } impl LauncherSettings { @@ -82,6 +85,8 @@ impl Default for LauncherSettings { curseforge_api_key: None, custom_api_endpoint: None, custom_meta_url_base: None, + data_dir: None, + previous_data_dir: None, } } } diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs index c6383136..2c54d3af 100644 --- a/packages/oneclient_core/src/settings/mod.rs +++ b/packages/oneclient_core/src/settings/mod.rs @@ -1,5 +1,7 @@ mod launcher; +pub mod data_dir; + pub use launcher::{LauncherSettings, ViewLayout, ViewState}; pub use oneclient_cluster::{ GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index 4ca91f32..e7e28a88 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -30,13 +30,62 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { notify.notify("Settings").body("Failed to load settings").error().send(); } - LauncherSettings::default() + recover_after_failed_load().await } } } -/// Prefer [`save_settings_and_apply`] this leaves the HTTP client on its old -/// endpoints/keys so those changes only take effect on the next launch +async fn recover_after_failed_load() -> LauncherSettings { + let mut settings = LauncherSettings::default(); + + let Ok(path) = paths::settings_file() else { + return settings; + }; + + let Ok(data) = polyio::read(&path).await else { + return settings; + }; + + match serde_json::from_slice::(&data) { + Ok(value) => { + settings.data_dir = salvaged_data_dir(&value); + + if let Some(dir) = &settings.data_dir { + tracing::warn!( + data_dir = %dir.display(), + "settings did not parse; kept the data folder they named" + ); + } + } + Err(err) => { + tracing::warn!("settings file is not valid JSON: {err}"); + + let Ok(aside) = paths::damaged_settings_file() else { + return settings; + }; + + match polyio::rename(&path, &aside).await { + Ok(()) => { + tracing::warn!(path = %aside.display(), "kept the damaged settings file") + } + Err(err) => { + tracing::warn!("could not set the damaged settings file aside: {err}") + } + } + } + } + + settings +} + +fn salvaged_data_dir(value: &serde_json::Value) -> Option { + value + .get("data_dir") + .and_then(serde_json::Value::as_str) + .filter(|raw| !raw.trim().is_empty()) + .map(std::path::PathBuf::from) +} + #[tracing::instrument(level = "debug", skip(settings))] pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { let path = paths::settings_file()?; @@ -69,3 +118,31 @@ pub async fn save_global_profile( let snapshot = settings.read().clone(); save_settings(&snapshot).await } + +#[cfg(test)] +mod tests { + use super::*; + + fn salvage(raw: &str) -> Option { + salvaged_data_dir(&serde_json::from_str(raw).expect("valid json")) + } + + #[test] + fn one_bad_field_does_not_cost_the_user_their_library() { + let raw = r#"{"max_concurrent_requests":"25","data_dir":"D:\\OneClient"}"#; + + assert_eq!( + salvage(raw), + Some(std::path::PathBuf::from("D:\\OneClient")), + "the location has to survive whatever else is wrong with the file" + ); + } + + #[test] + fn the_default_location_stays_the_default() { + assert_eq!(salvage(r#"{"log_debug":true}"#), None); + assert_eq!(salvage(r#"{"data_dir":null}"#), None); + assert_eq!(salvage(r#"{"data_dir":" "}"#), None); + assert_eq!(salvage(r#"{"data_dir":42}"#), None); + } +} diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index 9e5e48be..39830878 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -54,7 +54,8 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult LauncherResult