refactor(peer): centralize game root path policy

Game scanning, manifest validation, install recovery, migration, and download
cleanup each carried their own spellings and case rules for reserved entries.
Those copies had already diverged, which made it possible for one subsystem to
accept or expose a path that another treated as application-owned state.

Introduce one game_paths module for the canonical names and conservative
portable comparison policy. Keep context-specific predicates for manifest and
scanner protection versus cancellation preservation: cancellation still sweeps
its own version transaction scratch files, while install and migration state
survive. Reuse the constants for all production path construction sites.

Test Plan:
- `just test` -- passed (175 lanspread-peer tests and full workspace)
- `just clippy` -- passed
- `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked
  by 39 pre-existing rumdl issues in five unrelated Markdown files
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-09 17:59:01 +02:00
parent a6ed60a538
commit a1013b028d
13 changed files with 198 additions and 129 deletions
+5 -24
View File
@@ -7,6 +7,8 @@ use std::{
use eyre::WrapErr; use eyre::WrapErr;
use lanspread_db::db::{GameCatalog, GameFileDescription}; use lanspread_db::db::{GameCatalog, GameFileDescription};
use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key};
/// A remote manifest may describe at most this many filesystem entries. /// A remote manifest may describe at most this many filesystem entries.
pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000; pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000;
/// A single remotely described file may be at most one tebibyte. /// A single remotely described file may be at most one tebibyte.
@@ -21,8 +23,6 @@ pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255;
pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900; pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900;
const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000; const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000;
const VERSION_INI: &str = "version.ini";
/// One entry whose path and shape were validated as part of a complete manifest. /// One entry whose path and shape were validated as part of a complete manifest.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ValidatedDownloadEntry { pub(crate) struct ValidatedDownloadEntry {
@@ -259,7 +259,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> {
if windows_alias_component(root_component)? == self.game_alias { if windows_alias_component(root_component)? == self.game_alias {
eyre::bail!("download path contains a doubled game prefix: {display_path}"); eyre::bail!("download path contains a doubled game prefix: {display_path}");
} }
if is_protected_root_component(root_component) { if is_download_protected_root_name(root_component) {
eyre::bail!("download path targets install or recovery state: {display_path}"); eyre::bail!("download path targets install or recovery state: {display_path}");
} }
Ok(()) Ok(())
@@ -337,7 +337,7 @@ fn validate_game_id(game_id: &str) -> eyre::Result<()> {
eyre::bail!("catalog game ID must be one path component: {game_id}"); eyre::bail!("catalog game ID must be one path component: {game_id}");
} }
validate_component(game_id)?; validate_component(game_id)?;
if is_protected_root_component(game_id) { if is_download_protected_root_name(game_id) {
eyre::bail!("catalog game ID is reserved for application state: {game_id}"); eyre::bail!("catalog game ID is reserved for application state: {game_id}");
} }
Ok(()) Ok(())
@@ -447,7 +447,7 @@ fn validate_component(component: &str) -> eyre::Result<()> {
fn windows_alias_component(component: &str) -> eyre::Result<String> { fn windows_alias_component(component: &str) -> eyre::Result<String> {
validate_component(component)?; validate_component(component)?;
Ok(component.to_uppercase()) Ok(portable_name_key(component))
} }
fn is_windows_device_name(stem: &str) -> bool { fn is_windows_device_name(stem: &str) -> bool {
@@ -472,25 +472,6 @@ fn looks_like_dos_short_name(component: &str) -> bool {
}) })
} }
fn is_protected_root_component(component: &str) -> bool {
let alias = component.to_uppercase();
alias == "LOCAL"
|| alias.starts_with(".LOCAL.")
|| alias.starts_with(".VERSION.INI.")
|| matches!(
alias.as_str(),
".SYNC"
| ".LANSPREAD"
| ".LANSPREAD.JSON"
| ".LANSPREAD.JSON.TMP"
| ".LANSPREAD_OWNED"
| ".SOFTLAN_FIRST_START_DONE"
| ".SOFTLAN_GAME_INSTALLED"
| "INSTALL_INTENT.JSON"
| "INSTALL_INTENT.JSON.TMP"
)
}
fn validate_entry_shape( fn validate_entry_shape(
shapes: &mut BTreeMap<String, EntryShape>, shapes: &mut BTreeMap<String, EntryShape>,
alias_path: &str, alias_path: &str,
@@ -3,9 +3,7 @@ use std::{io::ErrorKind, path::Path};
use tokio::fs::OpenOptions; use tokio::fs::OpenOptions;
use super::manifest::ValidatedDownloadManifest; use super::manifest::ValidatedDownloadManifest;
use crate::local_games::is_local_dir_name; use crate::game_paths::is_preserved_on_download_discard;
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files. /// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> { pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> {
@@ -90,7 +88,7 @@ pub(super) async fn discard_cancelled_download(
} }
fn should_preserve_on_download_discard(name: &str) -> bool { fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR is_preserved_on_download_discard(name)
} }
async fn remove_entry(path: &Path) -> eyre::Result<()> { async fn remove_entry(path: &Path) -> eyre::Result<()> {
@@ -3,6 +3,8 @@ use std::path::Path;
use lanspread_db::db::GameFileDescription; use lanspread_db::db::GameFileDescription;
use tokio::{io::AsyncWriteExt, sync::Mutex}; use tokio::{io::AsyncWriteExt, sync::Mutex};
use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE};
#[derive(Debug)] #[derive(Debug)]
pub(super) struct VersionIniBuffer { pub(super) struct VersionIniBuffer {
relative_path: String, relative_path: String,
@@ -48,27 +50,27 @@ impl VersionIniBuffer {
pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> {
tokio::fs::create_dir_all(game_root).await?; tokio::fs::create_dir_all(game_root).await?;
remove_file_if_exists(&game_root.join(".version.ini.tmp")).await?; remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?;
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?; remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?;
let version_path = game_root.join("version.ini"); let version_path = game_root.join(VERSION_INI);
if tokio::fs::metadata(&version_path) if tokio::fs::metadata(&version_path)
.await .await
.is_ok_and(|metadata| metadata.is_file()) .is_ok_and(|metadata| metadata.is_file())
{ {
tokio::fs::rename(version_path, game_root.join(".version.ini.discarded")).await?; tokio::fs::rename(version_path, game_root.join(VERSION_DISCARDED_FILE)).await?;
} }
Ok(()) Ok(())
} }
pub(super) async fn rollback_version_ini_transaction(game_root: &Path) { pub(super) async fn rollback_version_ini_transaction(game_root: &Path) {
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.tmp")).await { if let Err(err) = remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await {
log::warn!( log::warn!(
"Failed to sweep partial version.ini tmp in {}: {err}", "Failed to sweep partial version.ini tmp in {}: {err}",
game_root.display() game_root.display()
); );
} }
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.discarded")).await { if let Err(err) = remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await {
log::warn!( log::warn!(
"Failed to sweep discarded version.ini in {}: {err}", "Failed to sweep discarded version.ini in {}: {err}",
game_root.display() game_root.display()
@@ -80,8 +82,8 @@ pub(super) async fn commit_version_ini_buffer(
game_root: &Path, game_root: &Path,
buffer: &VersionIniBuffer, buffer: &VersionIniBuffer,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let tmp_path = game_root.join(".version.ini.tmp"); let tmp_path = game_root.join(VERSION_TMP_FILE);
let version_path = game_root.join("version.ini"); let version_path = game_root.join(VERSION_INI);
let bytes = buffer.snapshot().await; let bytes = buffer.snapshot().await;
let mut file = tokio::fs::File::create(&tmp_path).await?; let mut file = tokio::fs::File::create(&tmp_path).await?;
@@ -91,7 +93,7 @@ pub(super) async fn commit_version_ini_buffer(
tokio::fs::rename(&tmp_path, &version_path).await?; tokio::fs::rename(&tmp_path, &version_path).await?;
sync_parent_dir(&version_path)?; sync_parent_dir(&version_path)?;
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?; remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?;
Ok(()) Ok(())
} }
+125
View File
@@ -0,0 +1,125 @@
//! Shared names and ownership policy for entries below a game root.
pub(crate) const LOCAL_DIR: &str = "local";
pub(crate) const INSTALLING_DIR: &str = ".local.installing";
pub(crate) const BACKUP_DIR: &str = ".local.backup";
pub(crate) const INSTALL_OWNED_MARKER: &str = ".lanspread_owned";
pub(crate) const VERSION_INI: &str = "version.ini";
pub(crate) const VERSION_TMP_FILE: &str = ".version.ini.tmp";
pub(crate) const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
pub(crate) const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
pub(crate) const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
pub(crate) const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
pub(crate) const INSTALL_INTENT_FILE: &str = "install_intent.json";
pub(crate) const INSTALL_INTENT_TMP_FILE: &str = "install_intent.json.tmp";
/// Returns the conservative cross-platform comparison key used for reserved names.
pub(crate) fn portable_name_key(name: &str) -> String {
name.to_uppercase()
}
/// Matches the committed install directory according to the host filesystem.
#[cfg(target_os = "windows")]
pub(crate) fn is_local_dir_name(name: &str) -> bool {
name.eq_ignore_ascii_case(LOCAL_DIR)
}
/// Matches the committed install directory according to the host filesystem.
#[cfg(not(target_os = "windows"))]
pub(crate) fn is_local_dir_name(name: &str) -> bool {
name == LOCAL_DIR
}
/// Returns whether a top-level entry belongs to install, recovery, or legacy state.
///
/// This deliberately uses a conservative platform-independent comparison because
/// a manifest accepted on one peer may be materialized on another operating system.
pub(crate) fn is_download_protected_root_name(name: &str) -> bool {
let key = portable_name_key(name);
key == "LOCAL"
|| key.starts_with(".LOCAL.")
|| key.starts_with(".VERSION.INI.")
|| matches!(
key.as_str(),
".SYNC"
| ".LANSPREAD"
| ".LANSPREAD.JSON"
| ".LANSPREAD.JSON.TMP"
| ".LANSPREAD_OWNED"
| ".SOFTLAN_FIRST_START_DONE"
| ".SOFTLAN_GAME_INSTALLED"
| "INSTALL_INTENT.JSON"
| "INSTALL_INTENT.JSON.TMP"
)
}
/// Returns whether cancellation cleanup must preserve an application-owned entry.
///
/// Version transaction scratch files are intentionally excluded: they belong to
/// the cancelled download and are safe to sweep. Install and migration state is
/// independent of that download and must survive it.
pub(crate) fn is_preserved_on_download_discard(name: &str) -> bool {
let key = portable_name_key(name);
key == "LOCAL"
|| key.starts_with(".LOCAL.")
|| matches!(
key.as_str(),
".SYNC"
| ".LANSPREAD"
| ".LANSPREAD.JSON"
| ".LANSPREAD.JSON.TMP"
| ".LANSPREAD_OWNED"
| ".SOFTLAN_FIRST_START_DONE"
| ".SOFTLAN_GAME_INSTALLED"
| "INSTALL_INTENT.JSON"
| "INSTALL_INTENT.JSON.TMP"
)
}
/// Returns whether an entry in the configured games directory is application state.
pub(crate) fn is_ignored_games_root_name(name: &str) -> bool {
portable_name_key(name) == ".LANSPREAD"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protected_policy_covers_current_and_legacy_state() {
for name in [
LOCAL_DIR,
"LOCAL",
INSTALLING_DIR,
BACKUP_DIR,
VERSION_TMP_FILE,
VERSION_DISCARDED_FILE,
".sync",
LEGACY_LIBRARY_INDEX_DIR,
LEGACY_INTENT_FILE,
LEGACY_INTENT_TMP_FILE,
INSTALL_OWNED_MARKER,
LEGACY_FIRST_START_DONE_FILE,
LEGACY_SOFTLAN_INSTALL_MARKER,
INSTALL_INTENT_FILE,
INSTALL_INTENT_TMP_FILE,
".ſync",
] {
assert!(is_download_protected_root_name(name), "missed {name}");
}
assert!(!is_download_protected_root_name(VERSION_INI));
assert!(!is_download_protected_root_name("archive.eti"));
}
#[test]
fn cancellation_policy_sweeps_only_download_transaction_state() {
assert!(is_preserved_on_download_discard(LOCAL_DIR));
assert!(is_preserved_on_download_discard(BACKUP_DIR));
assert!(is_preserved_on_download_discard(LEGACY_INTENT_FILE));
assert!(!is_preserved_on_download_discard(VERSION_INI));
assert!(!is_preserved_on_download_discard(VERSION_TMP_FILE));
assert!(!is_preserved_on_download_discard("archive.eti"));
}
}
+5 -4
View File
@@ -6,11 +6,12 @@ use std::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use crate::game_paths::{
INSTALL_INTENT_FILE as INTENT_FILE,
INSTALL_INTENT_TMP_FILE as INTENT_TMP_FILE,
};
const INTENT_SCHEMA_VERSION: u32 = 1; const INTENT_SCHEMA_VERSION: u32 = 1;
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
const INTENT_FILE: &str = "install_intent.json";
const INTENT_TMP_FILE: &str = "install_intent.json.tmp";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InstallIntentState { pub enum InstallIntentState {
+1 -4
View File
@@ -6,10 +6,7 @@ use std::{
use eyre::{WrapErr, bail}; use eyre::{WrapErr, bail};
const LOCAL_DIR: &str = "local"; use crate::game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI};
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const VERSION_INI: &str = "version.ini";
/// Remove the downloaded files for an uninstalled game root. /// Remove the downloaded files for an uninstalled game root.
/// ///
@@ -11,14 +11,18 @@ use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent}, intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker, unpack::Unpacker,
}; };
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path}; use crate::{
game_paths::{
const LOCAL_DIR: &str = "local"; BACKUP_DIR,
const INSTALLING_DIR: &str = ".local.installing"; INSTALL_OWNED_MARKER,
const BACKUP_DIR: &str = ".local.backup"; INSTALLING_DIR,
const OWNED_MARKER: &str = ".lanspread_owned"; LOCAL_DIR,
const VERSION_TMP_FILE: &str = ".version.ini.tmp"; VERSION_DISCARDED_FILE,
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded"; VERSION_TMP_FILE,
},
local_games::version_ini_is_regular_file,
state_paths::launch_settings_applied_path,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState { enum FsEntryState {
@@ -672,7 +676,7 @@ fn backup_dir(game_root: &Path) -> PathBuf {
} }
fn owned_marker(path: &Path) -> PathBuf { fn owned_marker(path: &Path) -> PathBuf {
path.join(OWNED_MARKER) path.join(INSTALL_OWNED_MARKER)
} }
impl From<bool> for FsEntryState { impl From<bool> for FsEntryState {
+1 -2
View File
@@ -20,9 +20,8 @@ use std::{
use eyre::WrapErr; use eyre::WrapErr;
use crate::state_paths::launch_settings_applied_path; use crate::{game_paths::LOCAL_DIR, state_paths::launch_settings_applied_path};
const LOCAL_DIR: &str = "local";
const ACCOUNT_NAME_FILE: &str = "account_name.txt"; const ACCOUNT_NAME_FILE: &str = "account_name.txt";
const LANGUAGE_FILE: &str = "language.txt"; const LANGUAGE_FILE: &str = "language.txt";
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini"; const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
+1
View File
@@ -18,6 +18,7 @@ mod context;
mod download; mod download;
mod error; mod error;
mod events; mod events;
mod game_paths;
mod handlers; mod handlers;
mod identity; mod identity;
mod install; mod install;
+19 -48
View File
@@ -14,25 +14,25 @@ use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, sync::Mutex}; use tokio::{io::AsyncWriteExt, sync::Mutex};
use crate::{context::OperationKind, error::PeerError}; use crate::{
context::OperationKind,
error::PeerError,
game_paths::{
LEGACY_LIBRARY_INDEX_DIR,
LOCAL_DIR,
VERSION_INI,
is_download_protected_root_name,
is_ignored_games_root_name,
},
};
// ============================================================================= // =============================================================================
// Local directory helpers // Local directory helpers
// ============================================================================= // =============================================================================
#[cfg(target_os = "windows")]
pub fn is_local_dir_name(name: &str) -> bool {
name.eq_ignore_ascii_case("local")
}
#[cfg(not(target_os = "windows"))]
pub fn is_local_dir_name(name: &str) -> bool {
name == "local"
}
/// Checks if `local/` is a committed install directory. /// Checks if `local/` is a committed install directory.
pub async fn local_dir_is_directory(path: &Path) -> bool { pub async fn local_dir_is_directory(path: &Path) -> bool {
let local_dir = path.join("local"); let local_dir = path.join(LOCAL_DIR);
tokio::fs::metadata(&local_dir) tokio::fs::metadata(&local_dir)
.await .await
.is_ok_and(|metadata| metadata.is_dir()) .is_ok_and(|metadata| metadata.is_dir())
@@ -40,7 +40,7 @@ pub async fn local_dir_is_directory(path: &Path) -> bool {
/// Checks if the root-level `version.ini` sentinel exists as a regular file. /// Checks if the root-level `version.ini` sentinel exists as a regular file.
pub async fn version_ini_is_regular_file(game_path: &Path) -> bool { pub async fn version_ini_is_regular_file(game_path: &Path) -> bool {
let version_path = game_path.join("version.ini"); let version_path = game_path.join(VERSION_INI);
tokio::fs::metadata(&version_path) tokio::fs::metadata(&version_path)
.await .await
.is_ok_and(|metadata| metadata.is_file()) .is_ok_and(|metadata| metadata.is_file())
@@ -105,11 +105,7 @@ pub async fn local_download_matches_catalog(
// Local library index and scanning // Local library index and scanning
// ============================================================================= // =============================================================================
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const LIBRARY_INDEX_FILE: &str = "library_index.json"; const LIBRARY_INDEX_FILE: &str = "library_index.json";
const INTENT_LOG_FILE: &str = ".lanspread.json";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
static LIBRARY_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(())); static LIBRARY_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
@@ -289,7 +285,7 @@ async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerpr
async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint> { async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint> {
let eti_files = root_eti_fingerprints(game_path).await?; let eti_files = root_eti_fingerprints(game_path).await?;
let version_path = game_path.join("version.ini"); let version_path = game_path.join(VERSION_INI);
let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await { let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await {
Ok(metadata) if metadata.is_file() => { Ok(metadata) if metadata.is_file() => {
let contents = match tokio::fs::read_to_string(&version_path).await { let contents = match tokio::fs::read_to_string(&version_path).await {
@@ -317,40 +313,15 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
}) })
} }
pub fn is_ignored_game_root_name(name: &str) -> bool {
name == LEGACY_LIBRARY_INDEX_DIR
}
fn is_reserved_transient_name(name: &str) -> bool {
name.starts_with(".local.")
|| name == VERSION_TMP_FILE
|| name == VERSION_DISCARDED_FILE
|| name == INTENT_LOG_FILE
|| name == LEGACY_LIBRARY_INDEX_DIR
}
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
if entry.depth() != 1 { if entry.depth() != 1 {
return false; return false;
} }
if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) { entry
return true; .file_name()
} .to_str()
.is_some_and(is_download_protected_root_name)
if let Some(name) = entry.file_name().to_str() {
if is_reserved_transient_name(name) {
return true;
}
if entry.file_type().is_dir() && name == ".sync" {
return true;
}
if entry.file_type().is_file() && name == ".softlan_game_installed" {
return true;
}
}
false
} }
fn canonical_protocol_path(path: &Path) -> eyre::Result<String> { fn canonical_protocol_path(path: &Path) -> eyre::Result<String> {
@@ -659,7 +630,7 @@ pub async fn scan_local_library(
let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else { let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
continue; continue;
}; };
if is_ignored_game_root_name(game_id) { if is_ignored_games_root_name(game_id) {
continue; continue;
} }
+8 -9
View File
@@ -8,20 +8,19 @@ use futures::{StreamExt as _, stream};
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
use crate::{ use crate::{
install::intent::{ game_paths::{
InstallIntent, LEGACY_FIRST_START_DONE_FILE,
LEGACY_INTENT_FILE, LEGACY_INTENT_FILE,
LEGACY_INTENT_TMP_FILE, LEGACY_INTENT_TMP_FILE,
intent_path, LEGACY_LIBRARY_INDEX_DIR,
write_intent, LEGACY_SOFTLAN_INSTALL_MARKER,
is_ignored_games_root_name,
}, },
local_games::{is_ignored_game_root_name, legacy_library_index_path}, install::intent::{InstallIntent, intent_path, write_intent},
local_games::legacy_library_index_path,
state_paths::{local_library_index_path, setup_done_path}, state_paths::{local_library_index_path, setup_done_path},
}; };
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const MIGRATION_CONCURRENCY: usize = 16; const MIGRATION_CONCURRENCY: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
@@ -97,7 +96,7 @@ async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, Pat
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else { let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue; continue;
}; };
if is_ignored_game_root_name(&id) { if is_ignored_games_root_name(&id) {
continue; continue;
} }
@@ -14,13 +14,9 @@ use crate::{
PeerEvent, PeerEvent,
config::LOCAL_GAME_FALLBACK_SCAN_SECS, config::LOCAL_GAME_FALLBACK_SCAN_SECS,
context::Ctx, context::Ctx,
game_paths::{is_download_protected_root_name, is_ignored_games_root_name},
handlers::update_and_announce_games, handlers::update_and_announce_games,
local_games::{ local_games::{rescan_local_game, scan_local_library},
is_ignored_game_root_name,
is_local_dir_name,
rescan_local_game,
scan_local_library,
},
}; };
struct WatchState { struct WatchState {
@@ -197,7 +193,7 @@ async fn list_game_roots(game_dir: &Path) -> eyre::Result<Vec<PathBuf>> {
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue; continue;
}; };
if is_ignored_game_root_name(&name) { if is_ignored_games_root_name(&name) {
continue; continue;
} }
roots.push(entry.path()); roots.push(entry.path());
@@ -303,7 +299,7 @@ fn game_id_from_event_path(game_dir: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(game_dir).ok()?; let relative = path.strip_prefix(game_dir).ok()?;
let mut components = relative.components(); let mut components = relative.components();
let game_id = component_name(components.next()?)?; let game_id = component_name(components.next()?)?;
if is_ignored_game_root_name(game_id) { if is_ignored_games_root_name(game_id) {
return None; return None;
} }
@@ -324,13 +320,7 @@ fn component_name(component: Component<'_>) -> Option<&str> {
} }
fn should_ignore_game_child(name: &str) -> bool { fn should_ignore_game_child(name: &str) -> bool {
is_local_dir_name(name) is_download_protected_root_name(name)
|| name.starts_with(".local.")
|| name.starts_with(".version.ini.")
|| name == ".lanspread"
|| name == ".lanspread.json"
|| name == ".sync"
|| name == ".softlan_game_installed"
} }
#[cfg(test)] #[cfg(test)]
+2 -1
View File
@@ -12,7 +12,8 @@ use crate::{
context::PeerCtx, context::PeerCtx,
error::PeerError, error::PeerError,
events, events,
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_matches_catalog}, game_paths::is_local_dir_name,
local_games::{get_game_file_descriptions, local_download_matches_catalog},
peer::{send_game_file_chunk, send_game_file_data}, peer::{send_game_file_chunk, send_game_file_data},
services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync}, services::handshake::{HandshakeCtx, accept_inbound_hello, spawn_library_resync},
stream_install::{send_game_install_stream, send_stream_install_error}, stream_install::{send_game_install_stream, send_stream_install_error},