Merge branch 'main' of https://github.com/Polyfrost/OneLauncher
This commit is contained in:
commit
0251456b11
52 changed files with 2734 additions and 201 deletions
2
.github/actions/setup-rust/action.yml
vendored
2
.github/actions/setup-rust/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/oneclient_nightly.yml
vendored
6
.github/workflows/oneclient_nightly.yml
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
.github/workflows/oneclient_release.yml
vendored
12
.github/workflows/oneclient_release.yml
vendored
|
|
@ -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 <glob> | head` — the latter falls back to listing the cwd
|
||||
# rather than `ls <glob> | head` - the latter falls back to listing the cwd
|
||||
# when nothing matches, silently picking an unrelated file.
|
||||
fmt=""; ext=""; matches=()
|
||||
case "$OS_KEY" in
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,6 +364,18 @@ pub fn mutation_is_running<M: MutationCapability>(mutation: &UseMutation<M>) ->
|
|||
mutation.read().state().is_loading()
|
||||
}
|
||||
|
||||
pub fn mutation_ok<M>(mutation: &UseMutation<M>) -> Option<M::Ok>
|
||||
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<M>(mutation: &UseMutation<M>) -> Option<M::Err>
|
||||
where
|
||||
M: MutationCapability,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<StorageActionMutation>;
|
|||
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<Leftovers>;
|
||||
type Err = LauncherError;
|
||||
type Keys = LeftoversKeys;
|
||||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
Ok(leftovers(&state).await)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_leftovers() -> UseQuery<LeftoversQuery> {
|
||||
use_query(Query::new(LeftoversKeys, LeftoversQuery).stale_time(STORAGE_STALE_TIME))
|
||||
}
|
||||
|
||||
pub fn try_leftovers(query: &UseQuery<LeftoversQuery>) -> Option<Leftovers> {
|
||||
super::state::settled_or_loading(query).flatten()
|
||||
}
|
||||
|
||||
pub async fn invalidate_leftovers_queries() {
|
||||
QueriesStorage::<LeftoversQuery>::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<Self::Ok, Self::Err> {
|
||||
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<Self::Ok, Self::Err>) {
|
||||
if result.is_ok() {
|
||||
invalidate_leftovers_queries().await;
|
||||
invalidate_storage_queries().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type UseDiscardLeftovers = UseMutation<DiscardLeftoversMutation>;
|
||||
|
||||
pub fn use_discard_leftovers() -> UseDiscardLeftovers {
|
||||
use_mutation(Mutation::new(DiscardLeftoversMutation))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, AppChannel>(AppState::default);
|
||||
let station = use_init_radio_station::<AppState, AppChannel>(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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub fn snapshots() -> Vec<PathBuf> {
|
|||
}
|
||||
|
||||
pub fn open_data_folder() {
|
||||
let Ok(dir) = oneclient_common::paths::launcher_dir() else {
|
||||
let Ok(dir) = oneclient_common::paths::data_dir() else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<AppState> for AppChannel {}
|
||||
|
|
@ -40,6 +42,21 @@ pub struct AppState {
|
|||
pub account_switcher_open: bool,
|
||||
pub microsoft_login: Option<LoginProgress>,
|
||||
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<RelocationPlan>,
|
||||
/// 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<Result<RelocationOutcome, String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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<chrono::Utc>) -> String {
|
||||
ts.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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::<PendingRemove>);
|
||||
|
||||
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<JavaRuntime>) -> impl IntoElement {
|
||||
fn runtimes_table(
|
||||
runtimes: Vec<JavaRuntime>,
|
||||
pending_remove: State<Option<PendingRemove>>,
|
||||
) -> impl IntoElement {
|
||||
if runtimes.is_empty() {
|
||||
return rect()
|
||||
.width(Size::fill())
|
||||
|
|
@ -146,6 +141,7 @@ fn runtimes_table(runtimes: Vec<JavaRuntime>) -> 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<Option<PendingRemove>>
|
||||
}
|
||||
|
||||
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<Option<PendingRemove>>,
|
||||
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<Option<PendingRemove>>,
|
||||
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.)
|
||||
|
|
|
|||
|
|
@ -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::<RelocationPlan>);
|
||||
let error = use_state(|| None::<String>);
|
||||
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<Option<RelocationPlan>>,
|
||||
mut error: State<Option<String>>,
|
||||
mut checking: State<bool>,
|
||||
) {
|
||||
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<RelocationPlan, String> {
|
||||
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<Option<RelocationPlan>>,
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u64> {
|
||||
entries.iter().map(|e| e.bytes).max().filter(|max| *max > 0)
|
||||
}
|
||||
|
||||
fn unused_cache_description(entry: &ReclaimableEntry) -> String {
|
||||
if entry.is_empty() {
|
||||
return "Nothing here — every cached file belongs to an installed package.".to_string();
|
||||
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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ fn build_stats(view: StatsView) -> Option<Element> {
|
|||
"~{} 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) {
|
||||
|
|
|
|||
177
packages/oneclient_app/src/view/relocating.rs
Normal file
177
packages/oneclient_app/src/view/relocating.rs
Normal file
|
|
@ -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.)
|
||||
}
|
||||
180
packages/oneclient_app/src/view/setup_location.rs
Normal file
180
packages/oneclient_app/src/view/setup_location.rs
Normal file
|
|
@ -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::<PathBuf>);
|
||||
let mut warning = use_state(|| None::<String>);
|
||||
let mut error = use_state(|| None::<String>);
|
||||
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<Option<PathBuf>>,
|
||||
mut warning: State<Option<String>>,
|
||||
mut error: State<Option<String>>,
|
||||
) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<ProjectDirs> = OnceLock::new();
|
||||
static LAUNCHER_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
static DEFAULT_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CONFIG_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
static DATA_DIR_OVERRIDE: OnceLock<PathBuf> = 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<PathBuf> {
|
||||
BaseDirs::new()
|
||||
.map(|dirs| dirs.data_dir().join(ORGANIZATION))
|
||||
.ok_or(PathsError::DataDirUnavailable)
|
||||
}
|
||||
|
||||
fn legacy_dir() -> Option<PathBuf> {
|
||||
ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
|
||||
.map(|dirs| dirs.data_local_dir().to_path_buf())
|
||||
}
|
||||
|
||||
fn resolve_default_dir() -> PathsResult<PathBuf> {
|
||||
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<PathBuf> {
|
||||
organization_dir()
|
||||
.ok()
|
||||
.filter(|dir| dir.is_dir())
|
||||
.or_else(|| data_dir().ok().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
pub fn database_file() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("user_data.db"))
|
||||
Ok(data_dir()?.join("user_data.db"))
|
||||
}
|
||||
|
||||
pub fn settings_file() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("settings.json"))
|
||||
Ok(config_dir()?.join(SETTINGS_FILE))
|
||||
}
|
||||
|
||||
pub fn damaged_settings_file() -> PathsResult<PathBuf> {
|
||||
Ok(config_dir()?.join("settings.json.corrupt"))
|
||||
}
|
||||
|
||||
pub fn auth_file() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("auth.json"))
|
||||
Ok(config_dir()?.join("auth.json"))
|
||||
}
|
||||
|
||||
pub fn logs_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("logs"))
|
||||
Ok(config_dir()?.join("logs"))
|
||||
}
|
||||
|
||||
pub fn java_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("java"))
|
||||
Ok(data_dir()?.join("metadata").join("java"))
|
||||
}
|
||||
|
||||
pub fn clusters_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("clusters"))
|
||||
Ok(data_dir()?.join("clusters"))
|
||||
}
|
||||
|
||||
pub fn shared_minecraft_dir() -> PathsResult<PathBuf> {
|
||||
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<PathBuf> {
|
|||
}
|
||||
|
||||
pub fn packages_cache_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("packages"))
|
||||
Ok(data_dir()?.join("metadata").join("packages"))
|
||||
}
|
||||
|
||||
pub fn caches_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("caches"))
|
||||
Ok(data_dir()?.join("metadata").join("caches"))
|
||||
}
|
||||
|
||||
pub fn bundles_dir() -> PathsResult<PathBuf> {
|
||||
|
|
@ -111,19 +160,19 @@ pub fn profiles_cache_dir() -> PathsResult<PathBuf> {
|
|||
}
|
||||
|
||||
pub fn versions_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("versions"))
|
||||
Ok(data_dir()?.join("metadata").join("versions"))
|
||||
}
|
||||
|
||||
pub fn libraries_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("libraries"))
|
||||
Ok(data_dir()?.join("metadata").join("libraries"))
|
||||
}
|
||||
|
||||
pub fn natives_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("natives"))
|
||||
Ok(data_dir()?.join("metadata").join("natives"))
|
||||
}
|
||||
|
||||
pub fn assets_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("assets"))
|
||||
Ok(data_dir()?.join("metadata").join("assets"))
|
||||
}
|
||||
|
||||
pub fn assets_index_dir() -> PathsResult<PathBuf> {
|
||||
|
|
@ -135,7 +184,7 @@ pub fn assets_object_dir() -> PathsResult<PathBuf> {
|
|||
}
|
||||
|
||||
pub fn legacy_assets_dir() -> PathsResult<PathBuf> {
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> = 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ pub fn cache_file_path(
|
|||
}
|
||||
|
||||
pub fn artifact_absolute_path(stored_path: &str) -> ContentResult<PathBuf> {
|
||||
let root = paths::launcher_dir()?;
|
||||
let root = paths::data_dir()?;
|
||||
Ok(root.join(stored_path))
|
||||
}
|
||||
|
||||
pub fn relative_cache_path(abs: &Path) -> ContentResult<String> {
|
||||
let root = paths::launcher_dir()?;
|
||||
let root = paths::data_dir()?;
|
||||
|
||||
let rel = abs
|
||||
.strip_prefix(root)
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ impl GameProcessManager {
|
|||
self.inner.lock().unwrap().get(&cluster_id).map(|p| p.stage)
|
||||
}
|
||||
|
||||
pub fn active_ids(&self) -> Vec<i64> {
|
||||
self.inner.lock().unwrap().keys().copied().collect()
|
||||
}
|
||||
|
||||
pub fn running_ids(&self) -> Vec<i64> {
|
||||
self.inner
|
||||
.lock()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult<usize> {
|
|||
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? {
|
||||
|
|
|
|||
754
packages/oneclient_core/src/relocate.rs
Normal file
754
packages/oneclient_core/src/relocate.rs
Normal file
|
|
@ -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<u64>,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[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<RelocationPlan, String> {
|
||||
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<RelocationOutcome, String> {
|
||||
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<Leftovers> {
|
||||
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<u64, String> {
|
||||
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<PathBuf, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<OsString> {
|
||||
let mut names = database_names();
|
||||
names.extend(config_owned_names(from));
|
||||
names
|
||||
}
|
||||
|
||||
fn database_names() -> Vec<OsString> {
|
||||
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<OsString> {
|
||||
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<PathBuf>,
|
||||
files: Vec<(PathBuf, u64)>,
|
||||
links: Vec<PathBuf>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
async fn collect(root: &Path, skip_top: &[OsString]) -> Result<Found, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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<PathBuf, String> {
|
||||
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<u64, String> {
|
||||
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 = <sqlx::SqliteConnection as sqlx::Connection>::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<u64, String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
136
packages/oneclient_core/src/settings/data_dir.rs
Normal file
136
packages/oneclient_core/src/settings/data_dir.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<DataDirCheck, String> {
|
||||
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<PathBuf>) -> 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<String> {
|
||||
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<u64> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
pub custom_api_endpoint: Option<String>,
|
||||
pub custom_meta_url_base: Option<String>,
|
||||
pub data_dir: Option<PathBuf>,
|
||||
pub previous_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
mod launcher;
|
||||
|
||||
pub mod data_dir;
|
||||
|
||||
pub use launcher::{LauncherSettings, ViewLayout, ViewState};
|
||||
pub use oneclient_cluster::{
|
||||
GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra,
|
||||
|
|
|
|||
|
|
@ -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::<serde_json::Value>(&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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LauncherState>) {
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult<StorageRepo
|
|||
return Ok(report);
|
||||
}
|
||||
|
||||
let launcher = paths::launcher_dir()?;
|
||||
let data = paths::data_dir()?;
|
||||
let config = paths::config_dir()?;
|
||||
|
||||
let mut categories = vec![
|
||||
entry("Packages", paths::packages_cache_dir()?).await,
|
||||
|
|
@ -85,8 +86,13 @@ pub async fn storage_report(state: &LauncherState) -> LauncherResult<StorageRepo
|
|||
files: unreferenced.len(),
|
||||
};
|
||||
|
||||
let mut total_bytes = dir_size(data).await;
|
||||
if data != config {
|
||||
total_bytes += dir_size(paths::logs_dir()?).await;
|
||||
}
|
||||
|
||||
Ok(StorageReport {
|
||||
total_bytes: dir_size(launcher).await,
|
||||
total_bytes,
|
||||
categories,
|
||||
clusters,
|
||||
unreferenced_cache,
|
||||
|
|
|
|||
|
|
@ -358,3 +358,81 @@ async fn opting_a_single_file_in_keeps_the_bundle_live() {
|
|||
"an explicit opt-in is consent, even with the rest of the bundle disabled"
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_delisted_bundle(state: &LauncherState) {
|
||||
oneclient_db::dao::bundle::upsert_bundle(
|
||||
&state.services.db,
|
||||
oneclient_db::models::NewBundle {
|
||||
remote_path: "bundles/delisted.mrpack",
|
||||
mc_version: MC_VERSION,
|
||||
mc_loader: GameLoader::Fabric as i64,
|
||||
file_name: "delisted.mrpack",
|
||||
name: Some(BUNDLE),
|
||||
version_id: Some("1"),
|
||||
category: Some("test"),
|
||||
loader_version: Some("0.16.0"),
|
||||
disk_path: "bundles/delisted.mrpack",
|
||||
hidden: true,
|
||||
etag: None,
|
||||
synced_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delisted_bundle_content_is_removed() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
seed_delisted_bundle(&state).await;
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
check.removals_available.len(),
|
||||
1,
|
||||
"content exclusive to a bundle the catalog dropped should be removed"
|
||||
);
|
||||
assert_eq!(check.removals_available[0].package_id, PROJECT_ID);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delisted_bundle_content_another_bundle_still_ships_is_kept() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
seed_delisted_bundle(&state).await;
|
||||
let mut successor = manifest(vec![managed_file(true)]);
|
||||
successor.name = "Successor Bundle".to_string();
|
||||
oneclient_core::dev::seed_bundle_archive(&state, successor)
|
||||
.await
|
||||
.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
check.removals_available.is_empty(),
|
||||
"a mod another live bundle still ships is not exclusive: {:?}",
|
||||
check.removals_available
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tracked_bundle_that_never_synced_is_not_removed() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
check.removals_available.is_empty(),
|
||||
"an absent catalog is not a delisting and must not take content down: {:?}",
|
||||
check.removals_available
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,8 @@ pub async fn hide_bundles_not_in(
|
|||
remote_paths: &[String],
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
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<Vec<String>, 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<Vec<BundleRow>, 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Option<PathBuf>> {
|
||||
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<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u32> {
|
||||
let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?;
|
||||
let (head, counter) = inner.rsplit_once('.')?;
|
||||
let (stem, pid) = head.rsplit_once('.')?;
|
||||
|
||||
counter.parse::<u64>().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<Path>) -> PolyIOResult<u64> {
|
||||
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<T: Serialize>(
|
|||
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<R: AsRef<Path>>(
|
|||
path: impl AsRef<Path>,
|
||||
roots: impl IntoIterator<Item = R>,
|
||||
) -> PolyIOResult<Option<PathBuf>> {
|
||||
// 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<R: AsRef<Path>>(
|
|||
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<Item = Result<bytes::Bytes, IOError>> + Unpin + Send {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue