diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index a3f4ad65..be22f77e 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -16,7 +16,7 @@ inputs: cache-namespace: description: > Cache namespace. Workflows that compile with different profile settings - MUST use different namespaces — cargo fingerprints every dependency by its + MUST use different namespaces - cargo fingerprints every dependency by its profile, so sharing one would mean each workflow evicts artifacts the other cannot use. required: false diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml index 7e77c5be..c73906df 100644 --- a/.github/workflows/oneclient_nightly.yml +++ b/.github/workflows/oneclient_nightly.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true # Nightlies are unsigned test builds, so they do not need the release profile's -# whole-program optimization — and that optimization is exactly what makes them +# whole-program optimization - and that optimization is exactly what makes them # slow. `lto = "fat"` + `codegen-units = 1` force a single-threaded re-optimization # of the entire dependency graph at link time, on every run, which no cache can # ever skip. Thin LTO keeps nearly all of the runtime benefit at a fraction of the @@ -21,7 +21,7 @@ concurrency: # output directory and every path below is unaffected. The shipped release binary # is untouched: oneclient_release.yml still builds the fat-LTO profile from # Cargo.toml. That difference is why the two workflows must not share a cache -# namespace — see `cache-namespace` on the Setup Rust step. +# namespace - see `cache-namespace` on the Setup Rust step. env: CARGO_PROFILE_RELEASE_LTO: thin CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' @@ -213,7 +213,7 @@ jobs: } foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { if (-not (Test-Path (Join-Path $dest $dll.Name))) { - Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work" + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" exit 1 } } diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 1ebcd22c..1ad5909d 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -18,7 +18,7 @@ on: # NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at # workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment # variable into its cache key, so a workflow-level declaration gave this workflow -# a cache namespace that the nightly workflow could never populate — every +# a cache namespace that the nightly workflow could never populate - every # release built the whole dependency graph from scratch. They are scoped to the # individual steps that actually need them instead. @@ -113,7 +113,7 @@ jobs: # already shipped. A *draft* is a previous failed/partial run of this # same workflow, so reuse it. if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag $TAG already exists on origin — OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." exit 1 fi @@ -123,7 +123,7 @@ jobs: RELEASE_EXISTS=1 fi if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then - echo "::error::Release $TAG already exists and is published — refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." exit 1 fi @@ -189,7 +189,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - # Pin to the commit `prepare` resolved — with `bump` that is the version + # Pin to the commit `prepare` resolved - with `bump` that is the version # commit it just pushed, which github.sha would not point at. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -465,7 +465,7 @@ jobs: } foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { if (-not (Test-Path (Join-Path $dest $dll.Name))) { - Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work" + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" exit 1 } } @@ -524,7 +524,7 @@ jobs: # Identify the single updater artifact for this platform (the file with a # sibling .sig): -setup.exe (nsis), *.app.tar.gz (app), *.AppImage (appimage). # NOTE: nullglob is on, so an unmatched glob expands to nothing. Use arrays - # rather than `ls | head` — the latter falls back to listing the cwd + # rather than `ls | head` - the latter falls back to listing the cwd # when nothing matches, silently picking an unrelated file. fmt=""; ext=""; matches=() case "$OS_KEY" in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d0b3af4..a5b328e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues #### Making Changes Locally This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a -recent Rust toolchain (`rustc` **1.85+**) installed before proceeding — the easiest way +recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way is via [rustup]. To make changes locally, follow these steps: diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index ede809b7..86ce9314 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -767,7 +767,7 @@ impl Component for CorruptionSimulator { .spacing(10.) .child( label() - .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.") + .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching - which is the point.") .font_size(13.) .color(colors::fg_secondary()), ) diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 6dcb253a..79765e6c 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -331,7 +331,7 @@ fn offline_dialog( .child(field_label("UUID")) .child( label() - .text(uuid_preview.unwrap_or_else(|| "—".to_string())) + .text(uuid_preview.unwrap_or_else(|| "-".to_string())) .font_size(12.) .color(colors::fg_secondary()), ), diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index e3883dc2..8ab5b2e8 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -267,7 +267,7 @@ fn largest(entries: &[StorageEntry]) -> Option { 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!( @@ -280,7 +280,7 @@ fn unused_cache_description(entry: &ReclaimableEntry) -> String { 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!( diff --git a/packages/oneclient_app/src/view/console.rs b/packages/oneclient_app/src/view/console.rs index 85bfcd41..21dcf3b4 100644 --- a/packages/oneclient_app/src/view/console.rs +++ b/packages/oneclient_app/src/view/console.rs @@ -13,7 +13,7 @@ use tracing::Level; use crate::components::{Button, Icon, IconType, LogViewer, Segment, SegmentedControl, TextInput}; use crate::theme::colors; -const WINDOW_TITLE: &str = "OneClient — Log Console"; +const WINDOW_TITLE: &str = "OneClient - Log Console"; /// Old lines are dropped from the front this window can be left open for a whole session const MAX_LINES: usize = 5_000; diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index 344f1601..d7b5920a 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -255,7 +255,7 @@ fn build_stats(view: StatsView) -> Option { "~{} left", format_duration_hms(remaining as i64) )), - None => parts.push("~— left".to_string()), + None => parts.push("~- left".to_string()), } if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { diff --git a/packages/oneclient_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs index 84ea1ef4..af0a1d91 100644 --- a/packages/oneclient_auth/src/diagnostics.rs +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -306,7 +306,7 @@ fn xerr(code: u64) -> MinecraftAuthError { fn network_guidance(failure: NetworkFailure) -> AuthErrorGuidance { match failure { NetworkFailure::Certificate => AuthErrorGuidance::new( - "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection — usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", + "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection - usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", &[ "Turn off HTTPS/SSL scanning in your antivirus (often called \"encrypted connection scanning\", \"web shield\", or \"SSL interception\")", "Temporarily disable your VPN or proxy and try signing in again", diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs index 358a4ad6..8fc2315e 100644 --- a/packages/oneclient_content/src/bundles/manager.rs +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -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()?; diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 944fd547..1c08cbf2 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; +use oneclient_db::dao::bundle as bundle_catalog_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; @@ -86,6 +87,20 @@ async fn check_bundle_updates_inner( .filter(|name| !loaded_bundle_names.contains(name)) .collect(); + let delisted_bundles: HashSet = if unavailable_tracked.is_empty() { + HashSet::new() + } else { + bundle_catalog_dao::list_delisted_names_for_version_loader( + &ctx.db, + &cluster.mc_version, + loader as i64, + ) + .await? + .into_iter() + .filter(|name| unavailable_tracked.contains(name)) + .collect() + }; + let overrides_map: HashMap<(String, String), OverrideType> = overrides .iter() .filter_map(|o| { @@ -211,7 +226,7 @@ async fn check_bundle_updates_inner( let Some(bundle_name) = &bundle_pkg.bundle_name else { continue; }; - if unavailable_tracked.contains(bundle_name) { + if unavailable_tracked.contains(bundle_name) && !delisted_bundles.contains(bundle_name) { continue; } diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs index 5298637c..c58e7625 100644 --- a/packages/oneclient_core/src/game/diagnosis.rs +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -19,11 +19,11 @@ impl CrashDiagnosis { pub fn body(&self) -> String { match self { Self::CorruptArchive { file: Some(file) } => format!( - "The game could not read {file} — the file is damaged. \ + "The game could not read {file} - the file is damaged. \ Verifying will re-download anything that does not match." ), Self::CorruptArchive { file: None } => "The game could not read one of its \ - library or mod files — it is damaged. Verifying will re-download \ + library or mod files - it is damaged. Verifying will re-download \ anything that does not match." .to_string(), } diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs index 11ff2ffd..63c3f6d2 100644 --- a/packages/oneclient_core/src/simulate.rs +++ b/packages/oneclient_core/src/simulate.rs @@ -1,7 +1,3 @@ -//! Deliberately damaging an installation so repair paths can be exercised -//! Each mode breaks files differently because the launcher catches different -//! damage at different layers - use std::path::{Path, PathBuf}; use oneclient_content::packages::PackageStore; @@ -15,8 +11,6 @@ use crate::LauncherResult; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SimulationReport { pub affected: usize, - /// Capped sample not the full list 50 damaged assets must not become a - /// 50-line toast pub samples: Vec, } @@ -51,9 +45,7 @@ impl SimulationReport { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Damage { - /// Same length wrong bytes passes a size check fails a hash check Corrupt, - /// Half length as an interrupted write would leave it Truncate, Delete, } diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index f8427fb9..312e130c 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -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 + ); +} diff --git a/packages/oneclient_db/src/dao/bundle.rs b/packages/oneclient_db/src/dao/bundle.rs index beeecd1c..b1461574 100644 --- a/packages/oneclient_db/src/dao/bundle.rs +++ b/packages/oneclient_db/src/dao/bundle.rs @@ -54,10 +54,8 @@ pub async fn hide_bundles_not_in( remote_paths: &[String], ) -> Result { if remote_paths.is_empty() { - let result = sqlx::query("UPDATE bundles SET hidden = 1 WHERE hidden = 0") - .execute(pool) - .await?; - return Ok(result.rows_affected()); + tracing::warn!("refusing to hide bundles for an empty catalog"); + return Ok(0); } let mut builder = QueryBuilder::new( @@ -73,6 +71,21 @@ pub async fn hide_bundles_not_in( Ok(result.rows_affected()) } +pub async fn list_delisted_names_for_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + "SELECT name FROM bundles \ + WHERE mc_version = ? AND mc_loader = ? AND hidden = 1 AND name IS NOT NULL", + ) + .bind(mc_version) + .bind(mc_loader) + .fetch_all(pool) + .await +} + pub async fn list_visible_for_version_loader( pool: &SqlitePool, mc_version: &str, @@ -150,3 +163,80 @@ pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> .fetch_all(pool) .await } + +#[cfg(test)] +mod tests { + use super::*; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("../oneclient_db/migrations") + .run(&pool) + .await + .expect("migrations run"); + pool + } + + async fn seed(pool: &SqlitePool, remote_path: &str) { + upsert_bundle( + pool, + NewBundle { + remote_path, + mc_version: "1.21.11", + mc_loader: 1, + file_name: "bundle.mrpack", + name: Some("SkyBlock"), + version_id: Some("1"), + category: Some("test"), + loader_version: Some("0.16.0"), + disk_path: remote_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await + .expect("seed bundle"); + } + + #[tokio::test] + async fn an_empty_catalog_hides_nothing() { + let pool = pool().await; + seed(&pool, "bundles/skyblock.mrpack").await; + + assert_eq!(hide_bundles_not_in(&pool, &[]).await.unwrap(), 0); + assert_eq!( + list_visible_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap() + .len(), + 1, + "an empty catalog must not delist a live bundle" + ); + } + + #[tokio::test] + async fn a_catalog_that_drops_one_bundle_hides_only_that_one() { + let pool = pool().await; + seed(&pool, "bundles/skyblock.mrpack").await; + seed(&pool, "bundles/qol.mrpack").await; + + hide_bundles_not_in(&pool, &["bundles/qol.mrpack".to_string()]) + .await + .unwrap(); + + let visible = list_visible_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].remote_path, "bundles/qol.mrpack"); + assert_eq!( + list_delisted_names_for_version_loader(&pool, "1.21.11", 1) + .await + .unwrap(), + vec!["SkyBlock".to_string()] + ); + } +}