[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; namespace SharpEmu.GUI;
[Flags]
internal enum GameEntryChanges
{
None = 0,
Metadata = 1,
Cover = 2,
Background = 4,
}
public sealed class GameEntry : INotifyPropertyChanged public sealed class GameEntry : INotifyPropertyChanged
{ {
// Placeholder gradients for games without cover art, picked // Placeholder gradients for games without cover art, picked
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
private Bitmap? _cover; private Bitmap? _cover;
private IBrush? _placeholderBrush; private IBrush? _placeholderBrush;
private long _sizeBytes; 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( public GameEntry(
string name, string? titleId, string? version, string path, long sizeBytes, string name, string? titleId, string? version, string path, long sizeBytes,
string? coverPath, string? backgroundPath) string? coverPath, string? backgroundPath)
{ {
Name = name; _name = name;
TitleId = titleId; _titleId = titleId;
Version = version; _version = version;
Path = path; Path = path;
_sizeBytes = sizeBytes; _sizeBytes = sizeBytes;
CoverPath = coverPath; _coverPath = coverPath;
BackgroundPath = backgroundPath; _backgroundPath = backgroundPath;
Initials = ComputeInitials(name); _initials = ComputeInitials(name);
_coverStamp = FileStamp.Read(coverPath);
_backgroundStamp = FileStamp.Read(backgroundPath);
} }
public event PropertyChangedEventHandler? PropertyChanged; 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> /// <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; } 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> /// <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> /// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
public string? BackgroundPath { get; } public string? BackgroundPath => _backgroundPath;
/// <summary> /// <summary>
/// Decoded key art used as the window backdrop while this game is /// Decoded key art used as the window backdrop while this game is
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
/// </summary> /// </summary>
public Bitmap? Background { get; set; } public Bitmap? Background { get; set; }
public string Initials { get; } public string Initials => _initials;
// Built lazily: brushes are AvaloniaObjects that must be created on the // Built lazily: brushes are AvaloniaObjects that must be created on the
// UI thread, while GameEntry itself is constructed on the scan thread. // 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> /// <summary>Formatted install size badge shown in the launch bar.</summary>
public string SizeText => FormatSize(SizeBytes); 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) private static string ComputeInitials(string name)
{ {
var initials = name var initials = name
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
_ => $"{bytes} B", _ => $"{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.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": "+ إضافة مجلد", "Library.AddFolder": "+ إضافة مجلد",
"Library.Rescan": "⟳ إعادة الفحص",
"Library.OpenFile": "فتح ملف...", "Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل", "Library.Context.Launch": "تشغيل",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar na biblioteca…", "Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Atualizar biblioteca",
"Library.OpenFile": "Abrir arquivo…", "Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar", "Library.Context.Launch": "Jogar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Bibliothek durchsuchen…", "Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen", "Library.AddFolder": " Spielordner hinzufügen",
"Library.Rescan": "⟳ Neu scannen",
"Library.OpenFile": "Datei öffnen…", "Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Søg i biblioteket…", "Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe", "Library.AddFolder": " Tilføj mappe",
"Library.Rescan": "⟳ Genindlæs",
"Library.OpenFile": "Åbn fil…", "Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start", "Library.Context.Launch": "Start",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Search library…", "Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder", "Library.AddFolder": " Add folder",
"Library.Rescan": "⟳ Rescan",
"Library.OpenFile": "Open file…", "Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch", "Library.Context.Launch": "Launch",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Buscar en la biblioteca…", "Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta", "Library.AddFolder": " Añadir carpeta",
"Library.Rescan": "⟳ Volver a escanear",
"Library.OpenFile": "Abrir archivo…", "Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Rechercher dans la bibliothèque…", "Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier", "Library.AddFolder": " Ajouter un dossier",
"Library.Rescan": "⟳ Analyser à nouveau",
"Library.OpenFile": "Ouvrir un fichier…", "Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer", "Library.Context.Launch": "Lancer",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Keresés a könyvtárban", "Library.SearchWatermark": "Keresés a könyvtárban",
"Library.AddFolder": " Mappa hozzáadása", "Library.AddFolder": " Mappa hozzáadása",
"Library.Rescan": "⟳ Újrakeresés",
"Library.OpenFile": "Fájl megnyitása…", "Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás", "Library.Context.Launch": "Inditás",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Cerca nella libreria…", "Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": " Aggiungi cartella", "Library.AddFolder": " Aggiungi cartella",
"Library.Rescan": "⟳ Riscansiona",
"Library.OpenFile": "Apri file…", "Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia", "Library.Context.Launch": "Avvia",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "ライブラリを検索…", "Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": " フォルダーを追加", "Library.AddFolder": " フォルダーを追加",
"Library.Rescan": "⟳ 再スキャン",
"Library.OpenFile": "ファイルを開く…", "Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動", "Library.Context.Launch": "起動",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "라이브러리 검색…", "Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": " 폴더 추가", "Library.AddFolder": " 폴더 추가",
"Library.Rescan": "⟳ 다시 스캔",
"Library.OpenFile": "파일 열기…", "Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행", "Library.Context.Launch": "실행",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Zoeken in bibliotheek…", "Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": " Map toevoegen", "Library.AddFolder": " Map toevoegen",
"Library.Rescan": "⟳ Opnieuw scannen",
"Library.OpenFile": "Bestand openen…", "Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Pesquisar biblioteca…", "Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Reanalisar",
"Library.OpenFile": "Abrir ficheiro…", "Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Поиск…", "Library.SearchWatermark": "Поиск…",
"Library.AddFolder": "+ Добавить папку", "Library.AddFolder": "+ Добавить папку",
"Library.Rescan": "⟳ Сканировать",
"Library.OpenFile": "Открыть файл…", "Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить", "Library.Context.Launch": "Запустить",
-1
View File
@@ -8,7 +8,6 @@
"Library.SearchWatermark": "Kütüphanede ara…", "Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle", "Library.AddFolder": " Klasör ekle",
"Library.Rescan": "⟳ Yeniden tara",
"Library.OpenFile": "Dosya aç…", "Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat", "Library.Context.Launch": "Başlat",
-1
View File
@@ -80,7 +80,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center"> VerticalAlignment="Center">
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" 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="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" /> <Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</Grid> </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 WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341"));
private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C")); private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C"));
private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489")); 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 List<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new(); private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList<LogLine> _consoleLines = new(); private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _allConsoleLines = new(); private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
@@ -85,8 +79,10 @@ public partial class MainWindow : Window
private string? _runningGameName; private string? _runningGameName;
private string? _runningGameTitleId; private string? _runningGameTitleId;
private long _runningSinceUnixSeconds; private long _runningSinceUnixSeconds;
private int _libraryScanGeneration;
private int _detailLoadGeneration; private int _detailLoadGeneration;
private int _backdropGeneration; private int _backdropGeneration;
private bool _isClosing;
// Bundled key art shown whenever no game-specific backdrop applies; the // Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load. // plain window color remains the fallback when the asset fails to load.
@@ -133,6 +129,7 @@ public partial class MainWindow : Window
} }
GameList.ItemsSource = _visibleGames; GameList.ItemsSource = _visibleGames;
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines; ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) => _consoleMirror = GuiConsoleMirror.Install((line, isError) =>
_pendingLines.Enqueue((line, isError))); _pendingLines.Enqueue((line, isError)));
@@ -173,7 +170,6 @@ public partial class MainWindow : Window
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines(); ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync(); AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync(); EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
@@ -619,7 +615,6 @@ public partial class MainWindow : Window
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark"); SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
AddFolderButton.Content = loc.Get("Library.AddFolder"); AddFolderButton.Content = loc.Get("Library.AddFolder");
RescanButton.Content = loc.Get("Library.Rescan");
OpenFileButton.Content = loc.Get("Library.OpenFile"); OpenFileButton.Content = loc.Get("Library.OpenFile");
CtxLaunch.Header = loc.Get("Library.Context.Launch"); CtxLaunch.Header = loc.Get("Library.Context.Launch");
@@ -798,6 +793,10 @@ public partial class MainWindow : Window
private void OnWindowClosing() private void OnWindowClosing()
{ {
_isClosing = true;
Interlocked.Increment(ref _libraryScanGeneration);
Interlocked.Increment(ref _detailLoadGeneration);
_libraryWatcher.Dispose();
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_libraryBlurTimer.Stop(); _libraryBlurTimer.Stop();
@@ -1176,7 +1175,7 @@ public partial class MainWindow : Window
} }
var changed = false; var changed = false;
if (!_settings.GameFolders.Contains(path, FilePathComparer)) if (!_settings.GameFolders.Contains(path, GameLibraryPath.Comparer))
{ {
_settings.GameFolders.Add(path); _settings.GameFolders.Add(path);
changed = true; changed = true;
@@ -1186,7 +1185,7 @@ public partial class MainWindow : Window
// games beneath it that were removed from the library earlier. // games beneath it that were removed from the library earlier.
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar; var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
changed |= _settings.ExcludedGames.RemoveAll(excluded => changed |= _settings.ExcludedGames.RemoveAll(excluded =>
excluded.StartsWith(prefix, FilePathComparison)) > 0; excluded.StartsWith(prefix, GameLibraryPath.Comparison)) > 0;
if (changed) if (changed)
{ {
@@ -1196,25 +1195,72 @@ public partial class MainWindow : Window
await RescanLibraryAsync(); 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 folders = _settings.GameFolders.ToArray();
var excluded = new HashSet<string>(_settings.ExcludedGames, FilePathComparer); var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary"); _libraryWatcher.Watch(folders);
EmptyState.IsVisible = false; var showLoadingState = showProgress && _allGames.Count == 0;
LoadingState.IsVisible = true; 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)); 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.Clear();
_allGames.AddRange(games); _allGames.AddRange(reconciliation.Games);
RefreshVisibleGames(); RefreshVisibleGames(reconciliation.BackgroundsChanged);
LoadingState.IsVisible = false; LoadingState.IsVisible = false;
LoadGameDetailsInBackground(games); LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence(); UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0 if (showProgress)
? Localization.Instance.Get("Status.AddFolderPrompt") {
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length); StatusBarRight.Text = folders.Length == 0
? Localization.Instance.Get("Status.AddFolderPrompt")
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
}
} }
/// <summary> /// <summary>
@@ -1222,44 +1268,57 @@ public partial class MainWindow : Window
/// game's install folder size — posting results back as they become /// game's install folder size — posting results back as they become
/// ready. A newer scan invalidates older loads. /// ready. A newer scan invalidates older loads.
/// </summary> /// </summary>
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games) private void LoadGameDetailsInBackground(
IReadOnlyList<GameEntry> coversToLoad,
IReadOnlyList<GameEntry> gamesToMeasure)
{ {
var generation = ++_detailLoadGeneration; var generation = ++_detailLoadGeneration;
_ = Task.Run(() => _ = Task.Run(() =>
{ {
// Covers first: they are cheap and the most visible, so the grid // Covers first: they are cheap and the most visible, so the grid
// fills with art before the (potentially slow) size pass runs. // fills with art before the (potentially slow) size pass runs.
foreach (var game in games) foreach (var game in coversToLoad)
{ {
if (generation != _detailLoadGeneration) if (generation != _detailLoadGeneration)
{ {
return; return;
} }
if (game.CoverPath is null) var requestedCoverPath = game.CoverPath;
if (requestedCoverPath is null)
{ {
continue; continue;
} }
try try
{ {
using var stream = File.OpenRead(game.CoverPath); using var stream = File.OpenRead(requestedCoverPath);
var bitmap = Bitmap.DecodeToWidth(stream, 312); var bitmap = Bitmap.DecodeToWidth(stream, 312);
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
if (generation == _detailLoadGeneration) if (generation == _detailLoadGeneration
&& _allGames.Contains(game)
&& string.Equals(
game.CoverPath,
requestedCoverPath,
StringComparison.Ordinal))
{ {
game.Cover = bitmap; 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) if (generation != _detailLoadGeneration)
{ {
@@ -1271,7 +1330,7 @@ public partial class MainWindow : Window
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
if (generation == _detailLoadGeneration) if (generation == _detailLoadGeneration && _allGames.Contains(game))
{ {
game.SizeBytes = size; game.SizeBytes = size;
} }
@@ -1306,9 +1365,10 @@ public partial class MainWindow : Window
total += file.Length; 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; return total;
@@ -1317,7 +1377,7 @@ public partial class MainWindow : Window
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths) private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
{ {
var games = new List<GameEntry>(); var games = new List<GameEntry>();
var seen = new HashSet<string>(FilePathComparer); var seen = new HashSet<string>(GameLibraryPath.Comparer);
var enumeration = new EnumerationOptions var enumeration = new EnumerationOptions
{ {
IgnoreInaccessible = true, IgnoreInaccessible = true,
@@ -1347,8 +1407,10 @@ public partial class MainWindow : Window
{ {
size = new FileInfo(fullPath).Length; 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); var (title, titleId, version) = TryReadParamJson(fullPath);
@@ -1357,9 +1419,10 @@ public partial class MainWindow : Window
FindCoverFor(fullPath), FindBackgroundFor(fullPath))); 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; return;
} }
if (!_settings.ExcludedGames.Contains(game.Path, FilePathComparer)) if (!_settings.ExcludedGames.Contains(game.Path, GameLibraryPath.Comparer))
{ {
_settings.ExcludedGames.Add(game.Path); _settings.ExcludedGames.Add(game.Path);
_settings.Save(); _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; GameList.SelectedItem = null;
RefreshVisibleGames(); RefreshVisibleGames();
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name); 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 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) foreach (var game in _allGames)
{ {
if (query.Length == 0 || if (query.Length == 0 ||
@@ -1625,21 +1690,37 @@ public partial class MainWindow : Window
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) || game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)) (game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
{ {
_visibleGames.Add(game); desired.Add(game);
} }
} }
if (selectedPath is not null && GameLibraryReconciler.ReconcileVisibleGames(_visibleGames, desired);
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, FilePathComparison))
is { } reselected) 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; EmptyState.IsVisible = _visibleGames.Count == 0;
UpdateEmptyStateTexts(); UpdateEmptyStateTexts();
UpdateSelectedGame(); if (!ReferenceEquals(selectedBefore, selectedAfter))
{
UpdateSelectedGame();
}
else
{
UpdateSelectedGameTexts();
UpdateRunButtons();
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
{
_ = UpdateBackdropAsync(selectedAfter);
}
}
} }
/// <summary> /// <summary>
@@ -1861,7 +1942,8 @@ public partial class MainWindow : Window
} }
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId) 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; : titleId;
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId)); 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);
}
}
}