feat: delete mods from dead bundles (#746)
This commit is contained in:
parent
606a15905d
commit
b25ff12ab5
16 changed files with 213 additions and 33 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:
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ fn largest(entries: &[StorageEntry]) -> Option<u64> {
|
|||
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue