diff --git a/src-tauri/src/game_artwork.rs b/src-tauri/src/game_artwork.rs index dc91910..275fcb9 100644 --- a/src-tauri/src/game_artwork.rs +++ b/src-tauri/src/game_artwork.rs @@ -34,8 +34,23 @@ impl ArtworkKind { Self::ShortcutIcon => "game shortcut icon", } } + + // Windows shortcuts need .ico files, so shortcut icons are stored as ICO. + fn formats(self) -> &'static [ImageFormat] { + match self { + Self::Icon | Self::Banner => &ImageFormat::ALL, + Self::ShortcutIcon => &SHORTCUT_ICON_FORMATS, + } + } } +const SHORTCUT_ICON_FORMATS: [ImageFormat; 4] = [ + ImageFormat::Png, + ImageFormat::Jpeg, + ImageFormat::Webp, + ImageFormat::Ico, +]; + #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct GameArtworkResult { @@ -281,7 +296,7 @@ fn existing_paths( } let mut paths = Vec::new(); - for format in ImageFormat::ALL { + for &format in kind.formats() { let path = artwork_path(directory, game_id, format); match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { @@ -509,6 +524,24 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn shortcut_ico_is_found_by_path() { + let root = test_dir(); + let store = GameArtworkStore::new(Ok(root.join("app-data"))); + let id = "00000000-0000-0000-0000-000000000001"; + let icon = crate::image_trim::shortcut_ico(&jpeg_image()).unwrap(); + store.set_shortcut_icon(id, icon).unwrap(); + + let path = store.path(id, ArtworkKind::ShortcutIcon).unwrap().unwrap(); + assert_eq!( + path, + root.join(format!("app-data/game-shortcut-icons/{id}.ico")) + ); + store.remove(id, ArtworkKind::ShortcutIcon).unwrap(); + assert_eq!(store.path(id, ArtworkKind::ShortcutIcon).unwrap(), None); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn icon_is_persisted_and_returned_with_its_content_type() { let root = test_dir(); diff --git a/src-tauri/src/game_uninstall.rs b/src-tauri/src/game_uninstall.rs index b42a614..ca456c6 100644 --- a/src-tauri/src/game_uninstall.rs +++ b/src-tauri/src/game_uninstall.rs @@ -152,7 +152,27 @@ pub(crate) fn is_system_directory(path: &Path) -> bool { /// Whether `path` lies in an operating system directory that Legio must never write to or delete. #[cfg(windows)] pub(crate) fn is_system_directory(path: &Path) -> bool { - std::env::var_os("SystemRoot").is_some_and(|system| path.starts_with(Path::new(&system))) + std::env::var_os("SystemRoot").is_some_and(|system| { + is_within_windows_directory(&path.to_string_lossy(), &system.to_string_lossy()) + }) +} + +// Windows paths ignore case and accept both separators, which Path::starts_with does not. +#[cfg(any(windows, test))] +fn is_within_windows_directory(path: &str, directory: &str) -> bool { + let path = normalize_windows_path(path); + let directory = normalize_windows_path(directory); + path == directory + || path + .strip_prefix(&directory) + .is_some_and(|rest| rest.starts_with('\\')) +} + +#[cfg(any(windows, test))] +fn normalize_windows_path(path: &str) -> String { + path.replace('/', "\\") + .trim_end_matches('\\') + .to_lowercase() } fn remove_installation( @@ -319,6 +339,28 @@ mod tests { use crate::database::{CreateGameInput, database_error}; use rusqlite::params; + #[test] + fn system_directory_match() { + assert!(is_within_windows_directory( + r"C:\WINDOWS\System32\game.exe", + r"C:\Windows" + )); + assert!(is_within_windows_directory( + "c:/windows/system32", + r"C:\Windows\" + )); + assert!(is_within_windows_directory(r"C:\Windows", r"C:\WINDOWS")); + // Sibling directories that only share a name prefix are not system directories. + assert!(!is_within_windows_directory( + r"C:\WindowsApps\game.exe", + r"C:\Windows" + )); + assert!(!is_within_windows_directory( + r"D:\Games\Windows", + r"C:\Windows" + )); + } + struct Fixture { root: PathBuf, directory: PathBuf,