[GUI] add game library watcher & remove rescan button (#678)

This commit is contained in:
Daniel Freak
2026-07-28 17:09:22 +03:00
committed by GitHub
parent a3130e30ff
commit b07e4f2bc6
23 changed files with 655 additions and 77 deletions
+124 -12
View File
@@ -8,6 +8,15 @@ using Avalonia.Media.Imaging;
namespace SharpEmu.GUI;
[Flags]
internal enum GameEntryChanges
{
None = 0,
Metadata = 1,
Cover = 2,
Background = 4,
}
public sealed class GameEntry : INotifyPropertyChanged
{
// Placeholder gradients for games without cover art, picked
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
private Bitmap? _cover;
private IBrush? _placeholderBrush;
private long _sizeBytes;
private string _name;
private string? _titleId;
private string? _version;
private string? _coverPath;
private string? _backgroundPath;
private string _initials;
private FileStamp _coverStamp;
private FileStamp _backgroundStamp;
public GameEntry(
string name, string? titleId, string? version, string path, long sizeBytes,
string? coverPath, string? backgroundPath)
{
Name = name;
TitleId = titleId;
Version = version;
_name = name;
_titleId = titleId;
_version = version;
Path = path;
_sizeBytes = sizeBytes;
CoverPath = coverPath;
BackgroundPath = backgroundPath;
Initials = ComputeInitials(name);
_coverPath = coverPath;
_backgroundPath = backgroundPath;
_initials = ComputeInitials(name);
_coverStamp = FileStamp.Read(coverPath);
_backgroundStamp = FileStamp.Read(backgroundPath);
}
public event PropertyChangedEventHandler? PropertyChanged;
public string Name { get; }
public string Name => _name;
public string? TitleId { get; }
public string? TitleId => _titleId;
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
public string? Version { get; }
public string? Version => _version;
public string Path { get; }
@@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged
}
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
public string? CoverPath { get; }
public string? CoverPath => _coverPath;
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
public string? BackgroundPath { get; }
public string? BackgroundPath => _backgroundPath;
/// <summary>
/// Decoded key art used as the window backdrop while this game is
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
/// </summary>
public Bitmap? Background { get; set; }
public string Initials { get; }
public string Initials => _initials;
// Built lazily: brushes are AvaloniaObjects that must be created on the
// UI thread, while GameEntry itself is constructed on the scan thread.
@@ -121,6 +140,74 @@ public sealed class GameEntry : INotifyPropertyChanged
/// <summary>Formatted install size badge shown in the launch bar.</summary>
public string SizeText => FormatSize(SizeBytes);
/// <summary>
/// Applies a fresh filesystem scan to this presentation object so its
/// ListBox container and selection remain stable
/// </summary>
internal GameEntryChanges UpdateFrom(GameEntry scanned)
{
if (!Path.Equals(scanned.Path, GameLibraryPath.Comparison))
{
throw new ArgumentException("Only matching game paths can be reconciled", nameof(scanned));
}
var changes = GameEntryChanges.None;
if (!string.Equals(_name, scanned.Name, StringComparison.Ordinal))
{
_name = scanned.Name;
_initials = ComputeInitials(scanned.Name);
_placeholderBrush = null;
RaisePropertyChanged(nameof(Name));
RaisePropertyChanged(nameof(Initials));
RaisePropertyChanged(nameof(PlaceholderBrush));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_titleId, scanned.TitleId, StringComparison.Ordinal))
{
_titleId = scanned.TitleId;
RaisePropertyChanged(nameof(TitleId));
RaisePropertyChanged(nameof(HasTitleId));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_version, scanned.Version, StringComparison.Ordinal))
{
_version = scanned.Version;
RaisePropertyChanged(nameof(Version));
RaisePropertyChanged(nameof(VersionText));
RaisePropertyChanged(nameof(HasVersion));
changes |= GameEntryChanges.Metadata;
}
if (!string.Equals(_coverPath, scanned.CoverPath, StringComparison.Ordinal)
|| _coverStamp != scanned._coverStamp)
{
_coverPath = scanned.CoverPath;
_coverStamp = scanned._coverStamp;
var previousCover = Cover;
Cover = null;
previousCover?.Dispose();
changes |= GameEntryChanges.Cover;
}
if (!string.Equals(_backgroundPath, scanned.BackgroundPath, StringComparison.Ordinal)
|| _backgroundStamp != scanned._backgroundStamp)
{
_backgroundPath = scanned.BackgroundPath;
_backgroundStamp = scanned._backgroundStamp;
var previousBackground = Background;
Background = null;
previousBackground?.Dispose();
changes |= GameEntryChanges.Background;
}
return changes;
}
private void RaisePropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private static string ComputeInitials(string name)
{
var initials = name
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
_ => $"{bytes} B",
};
}
private readonly record struct FileStamp(long Length, long LastWriteTimeUtcTicks)
{
public static FileStamp Read(string? path)
{
if (path is null)
{
return default;
}
try
{
var file = new FileInfo(path);
return file.Exists
? new FileStamp(file.Length, file.LastWriteTimeUtc.Ticks)
: default;
}
catch (Exception exception) when (
exception is IOException
or UnauthorizedAccessException)
{
return default;
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
internal static class GameLibraryPath
{
public static StringComparer Comparer { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
public static StringComparison Comparison { get; } =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
internal sealed record GameLibraryReconciliation(
IReadOnlyList<GameEntry> Games,
IReadOnlyList<GameEntry> CoversToLoad,
IReadOnlySet<GameEntry> BackgroundsChanged);
/// <summary>
/// Applies filesystem scan results while preserving presentation object
/// identity for games that remain in the library
/// </summary>
internal static class GameLibraryReconciler
{
public static GameLibraryReconciliation Reconcile(
IReadOnlyList<GameEntry> current,
IReadOnlyList<GameEntry> scanned)
{
var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer);
var merged = new List<GameEntry>(scanned.Count);
var coversToLoad = new List<GameEntry>();
var backgroundsChanged = new HashSet<GameEntry>();
foreach (var scannedGame in scanned)
{
if (!existingByPath.TryGetValue(scannedGame.Path, out var existing))
{
merged.Add(scannedGame);
if (scannedGame.CoverPath is not null)
{
coversToLoad.Add(scannedGame);
}
continue;
}
var changes = existing.UpdateFrom(scannedGame);
merged.Add(existing);
if ((changes & GameEntryChanges.Cover) != 0 && existing.CoverPath is not null)
{
coversToLoad.Add(existing);
}
if ((changes & GameEntryChanges.Background) != 0)
{
backgroundsChanged.Add(existing);
}
}
return new GameLibraryReconciliation(merged, coversToLoad, backgroundsChanged);
}
/// <summary>
/// Reorders, inserts and removes only the items needed to reach the desired
/// visible sequence
/// </summary>
public static void ReconcileVisibleGames(
IList<GameEntry> visible,
IReadOnlyList<GameEntry> desired)
{
for (var index = 0; index < desired.Count; index++)
{
var game = desired[index];
if (index < visible.Count && ReferenceEquals(visible[index], game))
{
continue;
}
var existingIndex = -1;
for (var candidate = index + 1; candidate < visible.Count; candidate++)
{
if (ReferenceEquals(visible[candidate], game))
{
existingIndex = candidate;
break;
}
}
if (existingIndex >= 0)
{
var existing = visible[existingIndex];
visible.RemoveAt(existingIndex);
visible.Insert(index, existing);
}
else
{
visible.Insert(index, game);
}
}
while (visible.Count > desired.Count)
{
visible.RemoveAt(visible.Count - 1);
}
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Watches configured game folders and collapses filesystem bursts into one
/// library refresh request
/// </summary>
internal sealed class GameLibraryWatcher : IDisposable
{
private static readonly TimeSpan DefaultDebounceInterval = TimeSpan.FromMilliseconds(600);
private readonly object _sync = new();
private readonly TimeSpan _debounceInterval;
private readonly List<FileSystemWatcher> _watchers = [];
private Timer? _debounceTimer;
private bool _disposed;
internal GameLibraryWatcher(TimeSpan? debounceInterval = null)
{
_debounceInterval = debounceInterval ?? DefaultDebounceInterval;
}
public event EventHandler? RefreshRequested;
/// <summary>
/// Replaces the watched roots with the current configured game folders
/// </summary>
public void Watch(IReadOnlyList<string> folders)
{
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
foreach (var watcher in _watchers)
{
watcher.Dispose();
}
_watchers.Clear();
_debounceTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
foreach (var folder in folders.Distinct(GameLibraryPath.Comparer))
{
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
continue;
}
try
{
var watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.DirectoryName
| NotifyFilters.LastWrite
| NotifyFilters.Size,
};
watcher.Created += OnFileSystemChanged;
watcher.Changed += OnFileSystemChanged;
watcher.Deleted += OnFileSystemChanged;
watcher.Renamed += OnFileSystemChanged;
watcher.Error += OnWatcherError;
watcher.EnableRaisingEvents = true;
_watchers.Add(watcher);
}
catch (Exception exception) when (
exception is ArgumentException
or IOException
or UnauthorizedAccessException)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not watch game folder '{folder}': {exception.Message}");
}
}
}
}
private void OnFileSystemChanged(object sender, FileSystemEventArgs args)
=> ScheduleRefresh();
private void OnWatcherError(object sender, ErrorEventArgs args)
{
Console.Error.WriteLine(
$"[GUI][WARN] Game library watcher reported an error: {args.GetException().Message}");
ScheduleRefresh();
}
private void ScheduleRefresh()
{
lock (_sync)
{
if (_disposed)
{
return;
}
if (_debounceTimer is null)
{
_debounceTimer = new Timer(
static state => ((GameLibraryWatcher)state!).RaiseRefreshRequested(),
this,
_debounceInterval,
Timeout.InfiniteTimeSpan);
}
else
{
_debounceTimer.Change(_debounceInterval, Timeout.InfiniteTimeSpan);
}
}
}
private void RaiseRefreshRequested()
{
lock (_sync)
{
if (_disposed)
{
return;
}
}
RefreshRequested?.Invoke(this, EventArgs.Empty);
}
public void Dispose()
{
lock (_sync)
{
if (_disposed)
{
return;
}
_disposed = true;
_debounceTimer?.Dispose();
_debounceTimer = null;
foreach (var watcher in _watchers)
{
watcher.Dispose();
}
_watchers.Clear();
}
}
}
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": "+ إضافة مجلد",
"Library.Rescan": "⟳ إعادة الفحص",
"Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Atualizar biblioteca",
"Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen",
"Library.Rescan": "⟳ Neu scannen",
"Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe",
"Library.Rescan": "⟳ Genindlæs",
"Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder",
"Library.Rescan": "⟳ Rescan",
"Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta",
"Library.Rescan": "⟳ Volver a escanear",
"Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier",
"Library.Rescan": "⟳ Analyser à nouveau",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Keresés a könyvtárban",
"Library.AddFolder": " Mappa hozzáadása",
"Library.Rescan": "⟳ Újrakeresés",
"Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": " Aggiungi cartella",
"Library.Rescan": "⟳ Riscansiona",
"Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": " フォルダーを追加",
"Library.Rescan": "⟳ 再スキャン",
"Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": " 폴더 추가",
"Library.Rescan": "⟳ 다시 스캔",
"Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": " Map toevoegen",
"Library.Rescan": "⟳ Opnieuw scannen",
"Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Reanalisar",
"Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Поиск…",
"Library.AddFolder": "+ Добавить папку",
"Library.Rescan": "⟳ Сканировать",
"Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle",
"Library.Rescan": "⟳ Yeniden tara",
"Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat",
-1
View File
@@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center">
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
<Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" VerticalAlignment="Center" />
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
</StackPanel>
</Grid>
+131 -49
View File
@@ -41,15 +41,9 @@ public partial class MainWindow : Window
private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489"));
private static readonly StringComparer FilePathComparer = OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
private static readonly StringComparison FilePathComparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
private readonly List<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
@@ -85,8 +79,10 @@ public partial class MainWindow : Window
private string? _runningGameName;
private string? _runningGameTitleId;
private long _runningSinceUnixSeconds;
private int _libraryScanGeneration;
private int _detailLoadGeneration;
private int _backdropGeneration;
private bool _isClosing;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
@@ -133,6 +129,7 @@ public partial class MainWindow : Window
}
GameList.ItemsSource = _visibleGames;
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
_pendingLines.Enqueue((line, isError)));
@@ -173,7 +170,6 @@ public partial class MainWindow : Window
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
@@ -619,7 +615,6 @@ public partial class MainWindow : Window
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
AddFolderButton.Content = loc.Get("Library.AddFolder");
RescanButton.Content = loc.Get("Library.Rescan");
OpenFileButton.Content = loc.Get("Library.OpenFile");
CtxLaunch.Header = loc.Get("Library.Context.Launch");
@@ -798,6 +793,10 @@ public partial class MainWindow : Window
private void OnWindowClosing()
{
_isClosing = true;
Interlocked.Increment(ref _libraryScanGeneration);
Interlocked.Increment(ref _detailLoadGeneration);
_libraryWatcher.Dispose();
_settings.Save();
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
@@ -1176,7 +1175,7 @@ public partial class MainWindow : Window
}
var changed = false;
if (!_settings.GameFolders.Contains(path, FilePathComparer))
if (!_settings.GameFolders.Contains(path, GameLibraryPath.Comparer))
{
_settings.GameFolders.Add(path);
changed = true;
@@ -1186,7 +1185,7 @@ public partial class MainWindow : Window
// games beneath it that were removed from the library earlier.
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
excluded.StartsWith(prefix, FilePathComparison)) > 0;
excluded.StartsWith(prefix, GameLibraryPath.Comparison)) > 0;
if (changed)
{
@@ -1196,25 +1195,72 @@ public partial class MainWindow : Window
await RescanLibraryAsync();
}
private async Task RescanLibraryAsync()
private void OnLibraryRefreshRequested(object? sender, EventArgs args)
{
Dispatcher.UIThread.Post(
() =>
{
if (!_isClosing)
{
_ = RescanLibraryFromWatcherAsync();
}
},
DispatcherPriority.Background);
}
private async Task RescanLibraryFromWatcherAsync()
{
try
{
await RescanLibraryAsync(showProgress: false);
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Automatic library refresh failed: {exception.Message}");
}
}
private async Task RescanLibraryAsync(bool showProgress = true)
{
Dispatcher.UIThread.VerifyAccess();
var scanGeneration = Interlocked.Increment(ref _libraryScanGeneration);
var folders = _settings.GameFolders.ToArray();
var excluded = new HashSet<string>(_settings.ExcludedGames, FilePathComparer);
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
EmptyState.IsVisible = false;
LoadingState.IsVisible = true;
var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
_libraryWatcher.Watch(folders);
var showLoadingState = showProgress && _allGames.Count == 0;
if (showProgress)
{
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
}
if (showLoadingState)
{
EmptyState.IsVisible = false;
LoadingState.IsVisible = true;
}
var games = await Task.Run(() => ScanFolders(folders, excluded));
if (_isClosing || scanGeneration != Volatile.Read(ref _libraryScanGeneration))
{
return;
}
Dispatcher.UIThread.VerifyAccess();
var reconciliation = GameLibraryReconciler.Reconcile(_allGames, games);
_allGames.Clear();
_allGames.AddRange(games);
RefreshVisibleGames();
_allGames.AddRange(reconciliation.Games);
RefreshVisibleGames(reconciliation.BackgroundsChanged);
LoadingState.IsVisible = false;
LoadGameDetailsInBackground(games);
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0
? Localization.Instance.Get("Status.AddFolderPrompt")
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
if (showProgress)
{
StatusBarRight.Text = folders.Length == 0
? Localization.Instance.Get("Status.AddFolderPrompt")
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
}
}
/// <summary>
@@ -1222,44 +1268,57 @@ public partial class MainWindow : Window
/// game's install folder size — posting results back as they become
/// ready. A newer scan invalidates older loads.
/// </summary>
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games)
private void LoadGameDetailsInBackground(
IReadOnlyList<GameEntry> coversToLoad,
IReadOnlyList<GameEntry> gamesToMeasure)
{
var generation = ++_detailLoadGeneration;
_ = Task.Run(() =>
{
// Covers first: they are cheap and the most visible, so the grid
// fills with art before the (potentially slow) size pass runs.
foreach (var game in games)
foreach (var game in coversToLoad)
{
if (generation != _detailLoadGeneration)
{
return;
}
if (game.CoverPath is null)
var requestedCoverPath = game.CoverPath;
if (requestedCoverPath is null)
{
continue;
}
try
{
using var stream = File.OpenRead(game.CoverPath);
using var stream = File.OpenRead(requestedCoverPath);
var bitmap = Bitmap.DecodeToWidth(stream, 312);
Dispatcher.UIThread.Post(() =>
{
if (generation == _detailLoadGeneration)
if (generation == _detailLoadGeneration
&& _allGames.Contains(game)
&& string.Equals(
game.CoverPath,
requestedCoverPath,
StringComparison.Ordinal))
{
game.Cover = bitmap;
}
else
{
bitmap.Dispose();
}
});
}
catch (Exception)
catch (Exception exception)
{
// A missing or undecodable image keeps the placeholder.
Console.Error.WriteLine(
$"[GUI][WARN] Could not load cover '{requestedCoverPath}': {exception.Message}");
}
}
foreach (var game in games)
foreach (var game in gamesToMeasure)
{
if (generation != _detailLoadGeneration)
{
@@ -1271,7 +1330,7 @@ public partial class MainWindow : Window
{
Dispatcher.UIThread.Post(() =>
{
if (generation == _detailLoadGeneration)
if (generation == _detailLoadGeneration && _allGames.Contains(game))
{
game.SizeBytes = size;
}
@@ -1306,9 +1365,10 @@ public partial class MainWindow : Window
total += file.Length;
}
}
catch (Exception)
catch (Exception exception)
{
// Fall back to whatever was accumulated so far.
Console.Error.WriteLine(
$"[GUI][WARN] Could not measure game folder '{directory}': {exception.Message}");
}
return total;
@@ -1317,7 +1377,7 @@ public partial class MainWindow : Window
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
{
var games = new List<GameEntry>();
var seen = new HashSet<string>(FilePathComparer);
var seen = new HashSet<string>(GameLibraryPath.Comparer);
var enumeration = new EnumerationOptions
{
IgnoreInaccessible = true,
@@ -1347,8 +1407,10 @@ public partial class MainWindow : Window
{
size = new FileInfo(fullPath).Length;
}
catch (IOException)
catch (IOException exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
var (title, titleId, version) = TryReadParamJson(fullPath);
@@ -1357,9 +1419,10 @@ public partial class MainWindow : Window
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
}
}
catch (Exception)
catch (Exception exception)
{
// Skip folders that fail to enumerate.
Console.Error.WriteLine(
$"[GUI][WARN] Could not scan game folder '{folder}': {exception.Message}");
}
}
@@ -1600,24 +1663,26 @@ public partial class MainWindow : Window
return;
}
if (!_settings.ExcludedGames.Contains(game.Path, FilePathComparer))
if (!_settings.ExcludedGames.Contains(game.Path, GameLibraryPath.Comparer))
{
_settings.ExcludedGames.Add(game.Path);
_settings.Save();
}
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, FilePathComparison));
_allGames.RemoveAll(g =>
string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
GameList.SelectedItem = null;
RefreshVisibleGames();
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
}
private void RefreshVisibleGames()
private void RefreshVisibleGames(IReadOnlySet<GameEntry>? backgroundsChanged = null)
{
var query = SearchBox.Text?.Trim() ?? string.Empty;
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
var selectedBefore = GameList.SelectedItem as GameEntry;
var selectedPath = selectedBefore?.Path;
var desired = new List<GameEntry>(_allGames.Count);
_visibleGames.Clear();
foreach (var game in _allGames)
{
if (query.Length == 0 ||
@@ -1625,21 +1690,37 @@ public partial class MainWindow : Window
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
{
_visibleGames.Add(game);
desired.Add(game);
}
}
if (selectedPath is not null &&
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, FilePathComparison))
is { } reselected)
GameLibraryReconciler.ReconcileVisibleGames(_visibleGames, desired);
var selectedAfter = selectedPath is null
? null
: _visibleGames.FirstOrDefault(game =>
game.Path.Equals(selectedPath, GameLibraryPath.Comparison));
if (!ReferenceEquals(GameList.SelectedItem, selectedAfter))
{
GameList.SelectedItem = reselected;
GameList.SelectedItem = selectedAfter;
}
EmptyState.IsVisible = _visibleGames.Count == 0;
UpdateEmptyStateTexts();
UpdateSelectedGame();
if (!ReferenceEquals(selectedBefore, selectedAfter))
{
UpdateSelectedGame();
}
else
{
UpdateSelectedGameTexts();
UpdateRunButtons();
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
{
_ = UpdateBackdropAsync(selectedAfter);
}
}
}
/// <summary>
@@ -1861,7 +1942,8 @@ public partial class MainWindow : Window
}
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
? _allGames.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId
? _allGames.FirstOrDefault(game =>
game.Path.Equals(ebootPath, GameLibraryPath.Comparison))?.TitleId
: titleId;
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
@@ -0,0 +1,100 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.ObjectModel;
using SharpEmu.GUI;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class GameLibraryReconcilerTests
{
[Fact]
public void Reconcile_PreservesExistingEntriesAndAppliesMetadataChanges()
{
var retained = CreateGame("retained", name: "Old name", version: "01.000");
var removed = CreateGame("removed");
var scannedRetained = CreateGame(
"retained",
name: "New name",
version: "02.000");
var added = CreateGame("added");
var result = GameLibraryReconciler.Reconcile(
[retained, removed],
[scannedRetained, added]);
Assert.Equal(2, result.Games.Count);
Assert.Same(retained, result.Games[0]);
Assert.Same(added, result.Games[1]);
Assert.Equal("New name", retained.Name);
Assert.Equal("02.000", retained.Version);
Assert.DoesNotContain(removed, result.Games);
}
[Fact]
public void Reconcile_ChangedCoverAndBackgroundReloadsExistingEntry()
{
var retained = CreateGame(
"retained",
coverPath: AssetPath("old-cover.png"),
backgroundPath: AssetPath("old-background.png"));
var scanned = CreateGame(
"retained",
coverPath: AssetPath("new-cover.png"),
backgroundPath: AssetPath("new-background.png"));
var result = GameLibraryReconciler.Reconcile([retained], [scanned]);
Assert.Same(retained, Assert.Single(result.Games));
Assert.Same(retained, Assert.Single(result.CoversToLoad));
Assert.Contains(retained, result.BackgroundsChanged);
Assert.Equal(scanned.CoverPath, retained.CoverPath);
Assert.Equal(scanned.BackgroundPath, retained.BackgroundPath);
}
[Fact]
public void ReconcileVisibleGames_UsesMinimalIdentityPreservingChanges()
{
var first = CreateGame("first");
var second = CreateGame("second");
var removed = CreateGame("removed");
var added = CreateGame("added");
var visible = new ObservableCollection<GameEntry>
{
first,
second,
removed,
};
GameLibraryReconciler.ReconcileVisibleGames(
visible,
[second, first, added]);
Assert.Equal([second, first, added], visible);
Assert.Same(second, visible[0]);
Assert.Same(first, visible[1]);
Assert.Same(added, visible[2]);
}
private static GameEntry CreateGame(
string id,
string? name = null,
string? version = null,
string? coverPath = null,
string? backgroundPath = null)
=> new(
name ?? id,
$"PPSA-{id}",
version,
GamePath(id),
1,
coverPath,
backgroundPath);
private static string GamePath(string id)
=> Path.GetFullPath(Path.Combine("library-tests", id, "eboot.bin"));
private static string AssetPath(string name)
=> Path.GetFullPath(Path.Combine("library-tests", "assets", name));
}
@@ -0,0 +1,38 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.GUI;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class GameLibraryWatcherTests
{
[Fact]
public async Task Watch_FileCreation_RequestsRefresh()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"sharpemu-library-watcher-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var watcher = new GameLibraryWatcher(TimeSpan.FromMilliseconds(25));
var refreshRequested = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
watcher.RefreshRequested += (_, _) => refreshRequested.TrySetResult();
watcher.Watch([directory]);
await File.WriteAllTextAsync(
Path.Combine(directory, "eboot.bin"),
"test");
await refreshRequested.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}