diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index a3f4ad65..be22f77e 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -16,7 +16,7 @@ inputs: cache-namespace: description: > Cache namespace. Workflows that compile with different profile settings - MUST use different namespaces — cargo fingerprints every dependency by its + MUST use different namespaces - cargo fingerprints every dependency by its profile, so sharing one would mean each workflow evicts artifacts the other cannot use. required: false diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml index 7e77c5be..c73906df 100644 --- a/.github/workflows/oneclient_nightly.yml +++ b/.github/workflows/oneclient_nightly.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true # Nightlies are unsigned test builds, so they do not need the release profile's -# whole-program optimization — and that optimization is exactly what makes them +# whole-program optimization - and that optimization is exactly what makes them # slow. `lto = "fat"` + `codegen-units = 1` force a single-threaded re-optimization # of the entire dependency graph at link time, on every run, which no cache can # ever skip. Thin LTO keeps nearly all of the runtime benefit at a fraction of the @@ -21,7 +21,7 @@ concurrency: # output directory and every path below is unaffected. The shipped release binary # is untouched: oneclient_release.yml still builds the fat-LTO profile from # Cargo.toml. That difference is why the two workflows must not share a cache -# namespace — see `cache-namespace` on the Setup Rust step. +# namespace - see `cache-namespace` on the Setup Rust step. env: CARGO_PROFILE_RELEASE_LTO: thin CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' @@ -213,7 +213,7 @@ jobs: } foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { if (-not (Test-Path (Join-Path $dest $dll.Name))) { - Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work" + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" exit 1 } } diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 1ebcd22c..1ad5909d 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -18,7 +18,7 @@ on: # NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at # workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment # variable into its cache key, so a workflow-level declaration gave this workflow -# a cache namespace that the nightly workflow could never populate — every +# a cache namespace that the nightly workflow could never populate - every # release built the whole dependency graph from scratch. They are scoped to the # individual steps that actually need them instead. @@ -113,7 +113,7 @@ jobs: # already shipped. A *draft* is a previous failed/partial run of this # same workflow, so reuse it. if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag $TAG already exists on origin — OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." exit 1 fi @@ -123,7 +123,7 @@ jobs: RELEASE_EXISTS=1 fi if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then - echo "::error::Release $TAG already exists and is published — refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." exit 1 fi @@ -189,7 +189,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - # Pin to the commit `prepare` resolved — with `bump` that is the version + # Pin to the commit `prepare` resolved - with `bump` that is the version # commit it just pushed, which github.sha would not point at. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -465,7 +465,7 @@ jobs: } foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { if (-not (Test-Path (Join-Path $dest $dll.Name))) { - Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work" + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" exit 1 } } @@ -524,7 +524,7 @@ jobs: # Identify the single updater artifact for this platform (the file with a # sibling .sig): -setup.exe (nsis), *.app.tar.gz (app), *.AppImage (appimage). # NOTE: nullglob is on, so an unmatched glob expands to nothing. Use arrays - # rather than `ls | head` — the latter falls back to listing the cwd + # rather than `ls | head` - the latter falls back to listing the cwd # when nothing matches, silently picking an unrelated file. fmt=""; ext=""; matches=() case "$OS_KEY" in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d0b3af4..a5b328e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues #### Making Changes Locally This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a -recent Rust toolchain (`rustc` **1.85+**) installed before proceeding — the easiest way +recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way is via [rustup]. To make changes locally, follow these steps: 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 bbac889f..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); @@ -482,11 +483,13 @@ impl Actions { events.signal(oneclient_events::Signal::JavaChanged); invalidate_java_queries().await }, - Err(err) => events + Err(err) => { + events .notify("Java install failed") .body(err.to_string()) .error() - .send(), + .send() + } } }); } @@ -957,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/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index ede809b7..86ce9314 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -767,7 +767,7 @@ impl Component for CorruptionSimulator { .spacing(10.) .child( label() - .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.") + .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching - which is the point.") .font_size(13.) .color(colors::fg_secondary()), ) diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 6dcb253a..79765e6c 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -331,7 +331,7 @@ fn offline_dialog( .child(field_label("UUID")) .child( label() - .text(uuid_preview.unwrap_or_else(|| "—".to_string())) + .text(uuid_preview.unwrap_or_else(|| "-".to_string())) .font_size(12.) .color(colors::fg_secondary()), ), diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 7671ec55..84089146 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -1,8 +1,10 @@ +use std::path::Path; + use freya::prelude::*; -use oneclient_java::{JavaRuntime, JavaVendor}; +use oneclient_java::{JavaRuntime, JavaVendor, is_launcher_managed}; use super::settings_page; -use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; +use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup, ScrollArea}; use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; use crate::invalidate_java_queries; use crate::theme::colors; @@ -15,9 +17,11 @@ pub struct SettingsJava; impl Component for SettingsJava { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); + let removing_dispatch = dispatch.clone(); let runtimes_query = use_java_runtimes(); let runtimes = java_runtimes(&runtimes_query); let mut show_manager = use_state(|| false); + let pending_remove = use_state(|| None::); fn invalidate_runtimes(dispatch: Actions) { spawn(async move { @@ -35,25 +39,8 @@ impl Component for SettingsJava { let mut shell = settings_page() .child(section_header("ADD RUNTIME")) .child(AddRow { show_manager }.into_element()) - .child( - rect() - .width(Size::Fill) - .direction(Direction::Horizontal) - .main_align(Alignment::SpaceBetween) - .cross_align(Alignment::Center) - .child(section_header("INSTALLED RUNTIMES")) - .child( - Button::new() - .secondary() - .small() - .enabled(false) // disabled for now - .on_press(move |_| { - invalidate_runtimes(refresh_dispatch.clone()); - }) - .child(label().text("Refresh")) - ) - ) - .child(runtimes_table(runtimes)); + .child(section_header("INSTALLED RUNTIMES")) + .child(runtimes_table(runtimes, pending_remove)); if *show_manager.read() { shell = shell.child( @@ -67,6 +54,11 @@ impl Component for SettingsJava { ); } + let pending = pending_remove.read().clone(); + if let Some(target) = pending { + shell = shell.child(confirm_remove_modal(removing_dispatch, pending_remove, target)) + } + shell.into_element() } } @@ -115,7 +107,10 @@ impl Component for AddRow { } } -fn runtimes_table(runtimes: Vec) -> impl IntoElement { +fn runtimes_table( + runtimes: Vec, + pending_remove: State>, +) -> impl IntoElement { if runtimes.is_empty() { return rect() .width(Size::fill()) @@ -146,6 +141,7 @@ fn runtimes_table(runtimes: Vec) -> impl IntoElement { RuntimeRow { runtime, last: idx + 1 == count, + pending_remove } .into_element(), ); @@ -195,11 +191,11 @@ fn table_header() -> impl IntoElement { struct RuntimeRow { runtime: JavaRuntime, last: bool, + pending_remove: State> } impl Component for RuntimeRow { fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); let runtime = &self.runtime; let path = runtime.absolute_path.clone(); @@ -271,7 +267,7 @@ impl Component for RuntimeRow { ), ), ) - .child(remove_button(dispatch, path)) + .child(remove_button(self.pending_remove, path)) } } @@ -279,11 +275,108 @@ fn path_content_width(path: &str) -> f32 { (path.chars().count() as f32 * 7.0).max(1.0) } -fn remove_button(dispatch: Actions, path: String) -> impl IntoElement { +#[derive(Clone, PartialEq)] +struct PendingRemove { + path: String, + /// Whether the files live in OneClient's own java dir, which is the only + /// case where removal takes them off disk + managed: bool, +} + +fn confirm_remove_modal( + dispatch: Actions, + mut pending: State>, + target: PendingRemove, +) -> impl IntoElement { + let PendingRemove { path, managed } = target; + let remove_path = path.clone(); + + let consequence = if managed { + "This runtime is managed by OneClient. It's files WILL be deleted." + } else { + "This runtime is not managed by OneClient. It's files will not be deleted." + }; + + let confirm = if managed { + Button::new().danger().text("Remove and delete files") + } else { + Button::new().primary().text("Remove from list") + }; + + OverlayPopup::new() + .on_close(move |()| pending.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .child( + label() + .text("Remove Java Runtime?") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(consequence) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(path) + .font_size(12.) + .max_lines(3) + .width(Size::fill()) + .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(confirm.on_press(move |_| { + dispatch.remove_java_runtime(remove_path.clone()); + pending.set(None); + })), + ), + ) + ) + .into_element() +} + +fn remove_button( + mut pending_remove: State>, + path: String, +) -> impl IntoElement { Button::new() .ghost() .small() - .on_press(move |_| dispatch.remove_java_runtime(path.clone())) + .on_press(move |_| { + pending_remove.set(Some(PendingRemove { + managed: is_launcher_managed(Path::new(&path)), + path: path.clone(), + })); + }) .child( Icon::new(IconType::Trash01) .size(14.) 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 e3883dc2..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,26 +247,40 @@ 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) } fn unused_cache_description(entry: &ReclaimableEntry) -> String { if entry.is_empty() { - return "Nothing here — every cached file belongs to an installed package.".to_string(); + 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) + plural(entry.files as i64) ) } fn legacy_content_description(entry: &ReclaimableEntry) -> String { if entry.is_empty() { - return "Nothing here — no cluster folder is holding old content.".to_string(); + return "Nothing here - no cluster folder is holding old content.".to_string(); } format!( @@ -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/console.rs b/packages/oneclient_app/src/view/console.rs index 85bfcd41..21dcf3b4 100644 --- a/packages/oneclient_app/src/view/console.rs +++ b/packages/oneclient_app/src/view/console.rs @@ -13,7 +13,7 @@ use tracing::Level; use crate::components::{Button, Icon, IconType, LogViewer, Segment, SegmentedControl, TextInput}; use crate::theme::colors; -const WINDOW_TITLE: &str = "OneClient — Log Console"; +const WINDOW_TITLE: &str = "OneClient - Log Console"; /// Old lines are dropped from the front this window can be left open for a whole session const MAX_LINES: usize = 5_000; 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/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index 344f1601..d7b5920a 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -255,7 +255,7 @@ fn build_stats(view: StatsView) -> Option { "~{} left", format_duration_hms(remaining as i64) )), - None => parts.push("~— left".to_string()), + None => parts.push("~- left".to_string()), } if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { 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_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs index 84ea1ef4..af0a1d91 100644 --- a/packages/oneclient_auth/src/diagnostics.rs +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -306,7 +306,7 @@ fn xerr(code: u64) -> MinecraftAuthError { fn network_guidance(failure: NetworkFailure) -> AuthErrorGuidance { match failure { NetworkFailure::Certificate => AuthErrorGuidance::new( - "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection — usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", + "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection - usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", &[ "Turn off HTTPS/SSL scanning in your antivirus (often called \"encrypted connection scanning\", \"web shield\", or \"SSL interception\")", "Temporarily disable your VPN or proxy and try signing in again", 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 358a4ad6..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 { @@ -95,6 +95,11 @@ impl BundlesManager { .map(|entry| entry.remote_path) .collect(); + if remote_paths.is_empty() { + tracing::error!("bundle catalog listed no bundles; keeping the previous catalog"); + return Ok(false); + } + bundle_dao::hide_bundles_not_in(&ctx.db, &remote_paths).await?; let bundles_root = paths::bundles_dir()?; @@ -207,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/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 944fd547..1c08cbf2 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; +use oneclient_db::dao::bundle as bundle_catalog_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; @@ -86,6 +87,20 @@ async fn check_bundle_updates_inner( .filter(|name| !loaded_bundle_names.contains(name)) .collect(); + let delisted_bundles: HashSet = if unavailable_tracked.is_empty() { + HashSet::new() + } else { + bundle_catalog_dao::list_delisted_names_for_version_loader( + &ctx.db, + &cluster.mc_version, + loader as i64, + ) + .await? + .into_iter() + .filter(|name| unavailable_tracked.contains(name)) + .collect() + }; + let overrides_map: HashMap<(String, String), OverrideType> = overrides .iter() .filter_map(|o| { @@ -211,7 +226,7 @@ async fn check_bundle_updates_inner( let Some(bundle_name) = &bundle_pkg.bundle_name else { continue; }; - if unavailable_tracked.contains(bundle_name) { + if unavailable_tracked.contains(bundle_name) && !delisted_bundles.contains(bundle_name) { continue; } 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/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs index 5298637c..c58e7625 100644 --- a/packages/oneclient_core/src/game/diagnosis.rs +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -19,11 +19,11 @@ impl CrashDiagnosis { pub fn body(&self) -> String { match self { Self::CorruptArchive { file: Some(file) } => format!( - "The game could not read {file} — the file is damaged. \ + "The game could not read {file} - the file is damaged. \ Verifying will re-download anything that does not match." ), Self::CorruptArchive { file: None } => "The game could not read one of its \ - library or mod files — it is damaged. Verifying will re-download \ + library or mod files - it is damaged. Verifying will re-download \ anything that does not match." .to_string(), } 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/simulate.rs b/packages/oneclient_core/src/simulate.rs index 11ff2ffd..63c3f6d2 100644 --- a/packages/oneclient_core/src/simulate.rs +++ b/packages/oneclient_core/src/simulate.rs @@ -1,7 +1,3 @@ -//! Deliberately damaging an installation so repair paths can be exercised -//! Each mode breaks files differently because the launcher catches different -//! damage at different layers - use std::path::{Path, PathBuf}; use oneclient_content::packages::PackageStore; @@ -15,8 +11,6 @@ use crate::LauncherResult; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SimulationReport { pub affected: usize, - /// Capped sample not the full list 50 damaged assets must not become a - /// 50-line toast pub samples: Vec, } @@ -51,9 +45,7 @@ impl SimulationReport { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Damage { - /// Same length wrong bytes passes a size check fails a hash check Corrupt, - /// Half length as an interrupted write would leave it Truncate, Delete, } diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 8fb1df79..e0a9c594 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -108,9 +108,27 @@ impl LauncherState { } } +/// Java archives are the only downloads big enough for a leaked scratch file to +/// matter and they land flat in the java dir so this needs no recursion +async fn sweep_java_scratch_files() { + let dir = match paths::java_dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!("could not resolve the java dir to sweep: {err:#}"); + return; + } + }; + + if let Err(err) = polyio::sweep_temp_files(&dir).await { + tracing::warn!("java scratch file sweep failed: {err:#}"); + } +} + pub fn run_startup_tasks(state: &Arc) { let background = Arc::clone(state); tokio::spawn(async move { + sweep_java_scratch_files().await; + let recovery = match crate::recovery::reconstruct_from_disk(&background).await { Ok(report) => report, Err(err) => { 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 Result { if remote_paths.is_empty() { - let result = sqlx::query("UPDATE bundles SET hidden = 1 WHERE hidden = 0") - .execute(pool) - .await?; - return Ok(result.rows_affected()); + tracing::warn!("refusing to hide bundles for an empty catalog"); + return Ok(0); } let mut builder = QueryBuilder::new( @@ -73,6 +71,21 @@ pub async fn hide_bundles_not_in( Ok(result.rows_affected()) } +pub async fn list_delisted_names_for_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + "SELECT name FROM bundles \ + WHERE mc_version = ? AND mc_loader = ? AND hidden = 1 AND name IS NOT NULL", + ) + .bind(mc_version) + .bind(mc_loader) + .fetch_all(pool) + .await +} + pub async fn list_visible_for_version_loader( pool: &SqlitePool, mc_version: &str, @@ -150,3 +163,80 @@ pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> .fetch_all(pool) .await } + +#[cfg(test)] +mod tests { + use super::*; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("../oneclient_db/migrations") + .run(&pool) + .await + .expect("migrations run"); + pool + } + + async fn seed(pool: &SqlitePool, remote_path: &str) { + upsert_bundle( + pool, + NewBundle { + remote_path, + mc_version: "1.21.11", + mc_loader: 1, + file_name: "bundle.mrpack", + name: Some("SkyBlock"), + version_id: Some("1"), + category: Some("test"), + loader_version: Some("0.16.0"), + disk_path: remote_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await + .expect("seed bundle"); + } + + #[tokio::test] + async fn an_empty_catalog_hides_nothing() { + let pool = pool().await; + seed(&pool, "bundles/skyblock.mrpack").await; + + assert_eq!(hide_bundles_not_in(&pool, &[]).await.unwrap(), 0); + assert_eq!( + list_visible_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap() + .len(), + 1, + "an empty catalog must not delist a live bundle" + ); + } + + #[tokio::test] + async fn a_catalog_that_drops_one_bundle_hides_only_that_one() { + let pool = pool().await; + seed(&pool, "bundles/skyblock.mrpack").await; + seed(&pool, "bundles/qol.mrpack").await; + + hide_bundles_not_in(&pool, &["bundles/qol.mrpack".to_string()]) + .await + .unwrap(); + + let visible = list_visible_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].remote_path, "bundles/qol.mrpack"); + assert_eq!( + list_delisted_names_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap(), + vec!["SkyBlock".to_string()] + ); + } +} diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs index 31499ca3..f90b354b 100644 --- a/packages/oneclient_java/src/install.rs +++ b/packages/oneclient_java/src/install.rs @@ -92,6 +92,60 @@ pub async fn install_package( Ok(executable) } +/// The directory this crate extracted for `executable`, or `None` when the +/// runtime sits outside the launcher's java dir which is where a folder the +/// user added themselves points +#[tracing::instrument(level = "debug")] +fn managed_install_root(executable: &Path) -> JavaResult> { + let java_dir = paths::java_dir()?; + + let Ok(root) = polyio::canonicalize(&java_dir) else { + return Ok(None); + }; + + // Refuses to resolve once the executable is gone which is the safe answer + // there is no way left to prove what the path pointed at + let canon = match polyio::ensure_under(executable, [&root]) { + Ok(Some(canon)) => canon, + Ok(None) => return Ok(None), + Err(err) => { + tracing::debug!("could not resolve the java runtime path: {err}"); + return Ok(None); + } + }; + + let Ok(relative) = canon.strip_prefix(&root) else { + return Ok(None); + }; + + match relative.components().next() { + Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), + _ => Ok(None), + } +} + +/// Whether [`remove_installed_package`] would take this runtime's files with +/// it so the UI can say up front what removing it costs +#[must_use] +pub fn is_launcher_managed(executable: &Path) -> bool { + managed_install_root(executable).is_ok_and(|root| root.is_some()) +} + +/// Only ever touches OneClient's own java dir a runtime the user added from +/// their own folder is left on disk untouched +#[tracing::instrument(level = "debug")] +pub async fn remove_installed_package(executable: &Path) -> JavaResult { + let Some(install_root) = managed_install_root(executable)? else { + tracing::debug!("java runtime is not launcher-managed leaving its files alone"); + return Ok(false); + }; + + polyio::remove_dir_all(&install_root).await?; + + tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); + Ok(true) +} + #[tracing::instrument(level = "debug")] fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf { let mut base_path = extract_root.to_path_buf(); @@ -163,3 +217,105 @@ fn stem_without_archive(name: &str) -> String { stem.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so + /// the first caller wins and every case works under one java dir + fn java_dir() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), + ); + }); + + let dir = paths::java_dir().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { + static N: AtomicU32 = AtomicU32::new(0); + let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); + let bin = install.join("zulu21.0.12").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + + let executable = bin.join("java"); + std::fs::write(&executable, b"").unwrap(); + (install, executable) + } + + #[test] + fn resolves_the_extracted_directory_not_the_executable_parent() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); + + let resolved = managed_install_root(&executable).unwrap().unwrap(); + + assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); + } + + #[tokio::test] + async fn removing_a_managed_runtime_deletes_its_files() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); + + assert!(remove_installed_package(&executable).await.unwrap()); + + assert!(!install.exists()); + assert!(java_dir.exists(), "the java dir itself must survive"); + } + + #[tokio::test] + async fn a_runtime_outside_the_java_dir_keeps_its_files() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); + let (_, executable) = install_runtime(&elsewhere); + + assert_eq!(managed_install_root(&executable).unwrap(), None); + assert!(!remove_installed_package(&executable).await.unwrap()); + assert!(executable.exists(), "a folder the user added is not ours to delete"); + + std::fs::remove_dir_all(&elsewhere).unwrap(); + } + + #[test] + fn a_traversal_back_out_of_the_java_dir_is_not_managed() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); + let (_, executable) = install_runtime(&elsewhere); + + let sneaky = java_dir + .join("..") + .join("traversal-jdk") + .join(executable.strip_prefix(&elsewhere).unwrap()); + + assert_eq!(managed_install_root(&sneaky).unwrap(), None); + + std::fs::remove_dir_all(&elsewhere).unwrap(); + } + + #[test] + fn the_managed_flag_matches_what_removal_would_actually_delete() { + let java_dir = java_dir(); + let (_, ours) = install_runtime(&java_dir); + let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); + let (_, theirs) = install_runtime(&elsewhere); + + assert!(is_launcher_managed(&ours)); + assert!(!is_launcher_managed(&theirs)); + + std::fs::remove_dir_all(&elsewhere).unwrap(); + } + + #[test] + fn the_java_dir_itself_is_never_managed() { + let java_dir = java_dir(); + + assert_eq!(managed_install_root(&java_dir).unwrap(), None); + } +} diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs index 490ff736..847ea4cf 100644 --- a/packages/oneclient_java/src/lib.rs +++ b/packages/oneclient_java/src/lib.rs @@ -16,7 +16,7 @@ pub mod vendors; pub use checker::{JavaCheckInfo, check_java_runtime}; pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relative_path}; pub use error::{JavaError, JavaResult}; -pub use install::install_package; +pub use install::{install_package, is_launcher_managed}; pub use locate::{LocatedJava, best_for_major, locate_java}; pub use platform::{HostArch, HostOs, HostTarget}; pub use service::{ diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs index 6a70b6f2..1aa42d9a 100644 --- a/packages/oneclient_java/src/service.rs +++ b/packages/oneclient_java/src/service.rs @@ -155,7 +155,17 @@ impl JavaService { #[tracing::instrument(skip(self))] pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { self.store.delete_by_path(absolute_path).await?; - tracing::info!("removed Java runtime"); + + let removed_files = + match crate::install::remove_installed_package(Path::new(absolute_path)).await { + Ok(removed) => removed, + Err(err) => { + tracing::warn!("could not remove the installed Java files: {err:#}"); + false + } + }; + + tracing::info!(removed_files, "removed Java runtime"); Ok(()) } diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 0797a20f..09aed9b6 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -1,4 +1,5 @@ use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use std::{fs::Metadata, path::{Path, PathBuf}}; use async_tempfile::{TempDir, TempFile}; @@ -318,8 +319,94 @@ fn temp_sibling(path: &Path) -> PathBuf { } } -/// Readers see either the old contents or the complete new ones -/// The fsync before the rename stops a crash leaving a correctly-named zero-length file +// Time for a file to be flagged as abandoned +const STALE_TEMP_AGE: Duration = Duration::from_secs(15 * 60); + +fn temp_sibling_pid(name: &str) -> Option { + let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?; + let (head, counter) = inner.rsplit_once('.')?; + let (stem, pid) = head.rsplit_once('.')?; + + counter.parse::().ok()?; + if stem.is_empty() { + return None; + } + + pid.parse().ok() +} + +/// Returns the number of bytes reclaimed non-recursive and never an error for a +/// missing directory +#[tracing::instrument( + level = "debug", + skip(dir), + fields(dir = %dir.as_ref().display()) +)] +pub async fn sweep_temp_files(dir: impl AsRef) -> PolyIOResult { + let dir = dir.as_ref(); + let path_err = |source| IOError::PathIOError { + source, + path: dir.to_string_lossy().to_string(), + }; + + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + // Nothing has ever been written here so there is nothing to sweep + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(err) => return Err(path_err(err)), + }; + + let current_pid = std::process::id(); + let mut files = 0usize; + let mut bytes = 0u64; + + while let Some(entry) = entries.next_entry().await.map_err(path_err)? { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(temp_sibling_pid) else { + continue; + }; + + if pid == current_pid { + continue; + } + + let Ok(metadata) = entry.metadata().await else { + continue; + }; + if !metadata.is_file() { + continue; + } + + let stale = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= STALE_TEMP_AGE); + if !stale { + continue; + } + + let size = metadata.len(); + match tokio::fs::remove_file(entry.path()).await { + Ok(()) => { + files += 1; + bytes += size; + } + // Another process may have just renamed it out from under us + Err(err) => tracing::debug!( + path = %entry.path().display(), + "could not remove stale scratch file: {err}" + ), + } + } + + if files > 0 { + tracing::info!(files, bytes, dir = %dir.display(), "removed stale scratch files"); + } + + Ok(bytes) +} + #[tracing::instrument( level = "debug", skip(path, data), @@ -383,10 +470,6 @@ pub async fn write_json_atomic( write_atomic(path, bytes).await } -/// Traversal guard for externally supplied paths -/// `Ok(None)` when the path resolves outside every root -/// `Err` only when `path` cannot be canonicalised -/// Roots that cannot be canonicalised (e.g. not yet created) are skipped #[tracing::instrument( level = "debug", skip(path, roots), @@ -396,8 +479,6 @@ pub fn ensure_under>( path: impl AsRef, roots: impl IntoIterator, ) -> PolyIOResult> { - // Not `std::fs::canonicalize` its Windows `\\?\` UNC output would never - // `starts_with` the plain-path roots so everything would look like an escape let canon = crate::canonicalize(path)?; for root in roots { @@ -411,9 +492,6 @@ pub fn ensure_under>( Ok(None) } -/// `exclude_top` applies only at the top level a nested directory of the same -/// name is still copied -/// Symlinks are followed and copied as their contents #[tracing::instrument(level = "debug", skip(exclude_top))] pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { let mut stack: Vec<(PathBuf, PathBuf, bool)> = @@ -723,6 +801,69 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Ages a file past [`STALE_TEMP_AGE`] so the sweep treats it as abandoned + fn backdate(path: &Path) { + let stale = std::time::SystemTime::now() - (STALE_TEMP_AGE + Duration::from_secs(60)); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(stale)) + .unwrap(); + } + + #[test] + fn temp_sibling_names_round_trip() { + let name = temp_sibling(Path::new("/java/zulu21.zip")); + let name = name.file_name().unwrap().to_str().unwrap(); + + assert_eq!(temp_sibling_pid(name), Some(std::process::id())); + + // Files polyio did not write + assert_eq!(temp_sibling_pid("zulu21.zip"), None); + assert_eq!(temp_sibling_pid(".vimrc.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.notapid.7.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.4242.notacounter.tmp"), None); + } + + #[tokio::test] + async fn sweep_removes_only_abandoned_scratch_files() { + let dir = scratch("sweep"); + + let abandoned = dir.join(format!(".zulu21.zip.{}.0.tmp", std::process::id() + 1)); + std::fs::write(&abandoned, b"half a runtime").unwrap(); + backdate(&abandoned); + + // Same shape but young enough to still have a writer behind it + let in_flight = dir.join(format!(".zulu17.zip.{}.0.tmp", std::process::id() + 2)); + std::fs::write(&in_flight, b"downloading").unwrap(); + + // Ours, however old the launcher has been up + let ours = temp_sibling(&dir.join("zulu8.zip")); + std::fs::write(&ours, b"mine").unwrap(); + backdate(&ours); + + // Not a scratch file at all + let keep = dir.join("zulu21.zip"); + std::fs::write(&keep, b"a real archive").unwrap(); + backdate(&keep); + + let freed = sweep_temp_files(&dir).await.unwrap(); + + assert_eq!(freed, "half a runtime".len() as u64); + assert!(!abandoned.exists()); + assert!(in_flight.exists()); + assert!(ours.exists()); + assert!(keep.exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn sweep_ignores_a_missing_directory() { + let dir = scratch("sweep-missing"); + std::fs::remove_dir_all(&dir).unwrap(); + + assert_eq!(sweep_temp_files(&dir).await.unwrap(), 0); + } + fn failing_stream( chunks: Vec<&'static [u8]>, ) -> impl futures_lite::Stream> + Unpin + Send {