diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 6f3a84b9..08919b3a 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -68,13 +68,28 @@ pub async fn unlink_cluster_file( content_type: ContentType, file_name: &str, ) -> ContentResult<()> { - let path = paths::clusters_dir()? + let dir = paths::clusters_dir()? .join(&cluster.folder_name) - .join(content_type.folder_name()) - .join(file_name); + .join(content_type.folder_name()); - if path.exists() { - polyio::remove_file(&path).await?; - } + // Both spellings: the database always stores the bare name, but the folder + // can still be holding a `.disabled` left by an older launcher or by + // the user renaming it by hand. Removing a package has to take both. + remove_entry(&dir.join(file_name)).await?; + remove_entry(&dir.join(format!("{file_name}.disabled"))).await?; + Ok(()) +} + +/// Deletes `path` if anything is there, symlink or not. +/// +/// `Path::exists` resolves symlinks, so a link whose artifact was evicted from +/// the cache reads as absent and the dead link survives every unlink; the +/// package then keeps showing up in the folder it was removed from. +async fn remove_entry(path: &Path) -> ContentResult<()> { + if polyio::symlink_metadata(path).await.is_err() { + return Ok(()); + } + + polyio::remove_file(path).await?; Ok(()) } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 9af2cbf7..f1068302 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -126,6 +126,21 @@ pub async fn import_manual_content( continue; } + // The shared dir is rebuilt from the database on every launch, so + // anything here that the launcher already has in its artifact cache + // is a leftover from a session whose cleanup never ran — a crash, or + // a bundle update that dropped the package in between. Importing it + // would re-link the package the update just removed, and the next + // launch would put it back in the game. Drop it instead; the cache + // still holds the file if it is ever wanted again. + if is_cached_artifact(services, &path).await { + tracing::debug!(file = name, "discarding stale launcher content in shared dir"); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale shared content"); + } + continue; + } + match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await { Ok(_) => { tracing::debug!(file = name, "registered manually-added shared content") @@ -140,6 +155,21 @@ pub async fn import_manual_content( } } +/// Whether `path` is a copy of something already in the launcher's artifact +/// cache, i.e. content the launcher itself put in the shared dir rather than +/// something the user dropped there. +async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { + let Ok(hash) = polyio::sha1_file(path).await else { + return false; + }; + + artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten() + .is_some() +} + fn has_content_extension(content_type: ContentType, name: &str) -> bool { let lower = name.to_lowercase(); match content_type {