From c63286e80febcb22b22b23129c40340e0811d3c0 Mon Sep 17 00:00:00 2001 From: Brandon Hernandez <142270679+Brandher58@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:16:49 -0700 Subject: [PATCH 1/2] add revert for applied fixes --- src/LuaToolsGui/Models/FixModels.cs | 17 ++ src/LuaToolsGui/Resources/Strings.Designer.cs | 11 ++ src/LuaToolsGui/Resources/Strings.es.resx | 11 ++ src/LuaToolsGui/Resources/Strings.resx | 11 ++ .../Services/Downloads/ManifestJobFactory.cs | 162 +++++++++++++++++- src/LuaToolsGui/ViewModels/FixesViewModel.cs | 77 ++++++++- src/LuaToolsGui/Views/FixesView.xaml | 75 +++++++- 7 files changed, 344 insertions(+), 20 deletions(-) diff --git a/src/LuaToolsGui/Models/FixModels.cs b/src/LuaToolsGui/Models/FixModels.cs index 2921ce0..621b49d 100644 --- a/src/LuaToolsGui/Models/FixModels.cs +++ b/src/LuaToolsGui/Models/FixModels.cs @@ -56,3 +56,20 @@ public class DenuvoDownloadResponse { [JsonPropertyName("url")] public string Url { get; set; } = ""; } + +// ── Fix revert manifest (written to .luatools-fix/ inside the game folder) ── + +public class DenuvoFixManifest +{ + [JsonPropertyName("appId")] public long AppId { get; set; } + [JsonPropertyName("fixId")] public string FixId { get; set; } = ""; + [JsonPropertyName("appliedAt")] public string AppliedAt { get; set; } = ""; + [JsonPropertyName("files")] public List Files { get; set; } = []; +} + +public class DenuvoFixManifestEntry +{ + [JsonPropertyName("relativePath")] public string RelativePath { get; set; } = ""; + [JsonPropertyName("action")] public string Action { get; set; } = ""; // "modified" or "added" + [JsonPropertyName("backupPath")] public string? BackupPath { get; set; } // relative to .luatools-fix/ +} diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 199186b..c20fde5 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -298,6 +298,17 @@ public static class Strings public static string Fixes_Toast_CouldntApply => Get(nameof(Fixes_Toast_CouldntApply)); public static string Fixes_Toast_Refreshed_Title => Get(nameof(Fixes_Toast_Refreshed_Title)); public static string Fixes_Toast_Refreshed_Body => Get(nameof(Fixes_Toast_Refreshed_Body)); + public static string Fixes_Revert => Get(nameof(Fixes_Revert)); + public static string Fixes_Revert_Cancel => Get(nameof(Fixes_Revert_Cancel)); + public static string Fixes_Revert_Confirm_Title => Get(nameof(Fixes_Revert_Confirm_Title)); + public static string Fixes_Revert_Confirm_Body => Get(nameof(Fixes_Revert_Confirm_Body)); + public static string Fixes_Revert_Done => Get(nameof(Fixes_Revert_Done)); + public static string Fixes_Revert_Done_Body => Get(nameof(Fixes_Revert_Done_Body)); + public static string Fixes_Revert_Partial => Get(nameof(Fixes_Revert_Partial)); + public static string Fixes_Revert_Partial_Body => Get(nameof(Fixes_Revert_Partial_Body)); + public static string Fixes_Revert_Failed => Get(nameof(Fixes_Revert_Failed)); + public static string Fixes_Revert_NoManifest => Get(nameof(Fixes_Revert_NoManifest)); + public static string Fixes_Applied_Hint => Get(nameof(Fixes_Applied_Hint)); // ── Add / Download ── public static string Add_Title => Get(nameof(Add_Title)); diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index f49178d..4e84754 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -315,6 +315,17 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo aplicar el parche Actualizado {0} juegos con fixes. + Revertir + Cancelar + ¿Revertir el parche de {0}? + ¿Revertir "{0}"? Los archivos modificados se restaurarán desde la copia de seguridad y los archivos añadidos por el parche se eliminarán. + Parche revertido + Se restauraron {0} y se eliminaron {1} archivo(s) añadido(s). + Parche revertido parcialmente + No se pudieron restaurar {0} archivo(s). Cierra el juego e inténtalo de nuevo. + No se pudo revertir el parche + No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. + Este parche ya está aplicado. Revierte para instalar otro. ¡A por los Luas™! Introduce un App ID, pega un enlace de Steam o busca por nombre de juego Introduce un App ID o busca juegos... diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 340b033..badd357 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -349,6 +349,17 @@ This deletes the .lua files from Steam\config\stplug-in. Couldn't apply fix Refreshed {0} games with fixes. + Revert + Cancel + Revert fix for {0}? + Revert "{0}"? Modified files will be restored from backup and files added by the fix will be deleted. + Fix reverted + {0} restored, {1} added files removed. + Fix partially reverted + {0} file(s) couldn't be restored. Close the game and try again. + Couldn't revert fix + No record of this fix being applied. It may already have been reverted. + This fix is already applied. Revert it if you want to install another. Let's get Luing™! diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index df1ca2d..cedc86b 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -105,7 +105,7 @@ public DownloadJob CreateDenuvoJob( }, (file, _, _) => Task.FromResult(isManifestSlot ? InstallDenuvoManifest(file, appId, gameName) - : ApplyDenuvoFix(file, appId, gameName)), + : ApplyDenuvoFix(file, appId, fixId, gameName)), ConfirmAsync: null, OnFinished: onFinished); } @@ -561,8 +561,9 @@ private JobResult InstallDenuvoManifest(DownloadedFile file, long appId, string } } - /// Denuvo fix slot: extract into the game folder. Only possible if the game is installed. - private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string gameName) + /// Denuvo fix slot: extract into the game folder. Only possible if the game is installed. + /// Existing files are backed up as .bak inside .luatools-fix/ so the fix can be reverted. + private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId, string gameName) { try { @@ -574,22 +575,70 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string gameNam return new JobResult(false, err); } - // Extract into the game folder, overwriting. Best-effort per entry so one locked file - // doesn't abandon the rest of the fix. + string fixKey = SafeFixKey(fixId); + string fixDir = Path.Combine(installDir, FixManifestDir); + string backupDir = Path.Combine(fixDir, fixKey); + string manifestPath = Path.Combine(fixDir, $"{fixKey}.json"); + Directory.CreateDirectory(backupDir); + + var manifest = new DenuvoFixManifest + { + AppId = appId, + FixId = fixId, + AppliedAt = DateTimeOffset.UtcNow.ToString("o"), + }; + using var archive = ZipFile.OpenRead(file.FilePath); int failed = 0; foreach (var entry in archive.Entries) { - if (string.IsNullOrEmpty(entry.Name)) continue; // directory entry + if (string.IsNullOrEmpty(entry.Name)) continue; string dest = Path.Combine(installDir, entry.FullName); + string relPath = entry.FullName.Replace('\\', '/'); + try { + if (File.Exists(dest)) + { + // Back up the original — never clobber a known-good .bak + string bakRel = $"{fixKey}/{Path.GetFileName(dest)}.bak"; + string bakAbs = Path.Combine(installDir, FixManifestDir, bakRel); + if (!File.Exists(bakAbs)) + { + Directory.CreateDirectory(Path.GetDirectoryName(bakAbs)!); + File.Copy(dest, bakAbs, overwrite: false); + } + manifest.Files.Add(new DenuvoFixManifestEntry + { + RelativePath = relPath, + Action = "modified", + BackupPath = bakRel, + }); + } + else + { + manifest.Files.Add(new DenuvoFixManifestEntry + { + RelativePath = relPath, + Action = "added", + }); + } + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); entry.ExtractToFile(dest, overwrite: true); } catch { failed++; } } + // Write manifest even on partial failure — the backed-up files still need to be recoverable. + try + { + string json = System.Text.Json.JsonSerializer.Serialize( + manifest, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(manifestPath, json); + } + catch { /* best effort */ } + if (failed > 0) { string err = string.Format(Resources.Strings.Fixes_Toast_PartiallyApplied_Body, failed); @@ -608,7 +657,106 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string gameNam } finally { - DeleteStaged(file.FilePath); // archive is disposed by now + DeleteStaged(file.FilePath); + } + } + + // ── Fix revert ────────────────────────────────────────────────── + + private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + internal const string FixManifestDir = ".luatools-fix"; + + /// + /// A fix id can carry `/`, `:`, and other characters that are illegal in a Windows path segment + /// (e.g. "online-fix:5e01e852-..."). Sanitise it for use as a folder/file name while keeping the + /// raw id in the manifest itself. + /// + internal static string SafeFixKey(string fixId) => + string.Concat(fixId.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); + + /// Resolve the manifest path for a given fix inside a game's install folder. + internal static string GetFixManifestPath(string installDir, string fixId) => + Path.Combine(installDir, FixManifestDir, $"{SafeFixKey(fixId)}.json"); + + /// Read a fix manifest from disk, or null if it doesn't exist. + internal static DenuvoFixManifest? ReadFixManifest(string manifestPath) + { + try + { + if (!File.Exists(manifestPath)) return null; + string json = File.ReadAllText(manifestPath); + return System.Text.Json.JsonSerializer.Deserialize(json, JsonOpts); + } + catch { return null; } + } + + /// + /// Revert a previously applied Denuvo fix: restore .bak files for modified entries, delete added + /// files, then clean up the backup directory and manifest. + /// + public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) + { + try + { + string? installDir = library.GetInstallDir(appId); + if (installDir is null) + return new JobResult(false, string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName)); + + string manifestPath = GetFixManifestPath(installDir, fixId); + var manifest = ReadFixManifest(manifestPath); + if (manifest is null) + return new JobResult(false, Resources.Strings.Fixes_Revert_NoManifest); + + int restored = 0, deleted = 0, errors = 0; + + foreach (var entry in manifest.Files) + { + string dest = Path.Combine(installDir, entry.RelativePath); + try + { + if (entry.Action == "modified" && entry.BackupPath is { } bakRel) + { + string bakAbs = Path.Combine(installDir, FixManifestDir, bakRel); + if (File.Exists(bakAbs)) + { + File.Copy(bakAbs, dest, overwrite: true); + File.Delete(bakAbs); + restored++; + } + } + else if (entry.Action == "added") + { + if (File.Exists(dest)) + { + File.Delete(dest); + deleted++; + } + } + } + catch { errors++; } + } + + // Clean up backup dir for this fix + string backupDir = Path.Combine(installDir, FixManifestDir, SafeFixKey(fixId)); + try { if (Directory.Exists(backupDir)) Directory.Delete(backupDir, recursive: true); } catch { } + try { if (File.Exists(manifestPath)) File.Delete(manifestPath); } catch { } + + if (errors > 0) + { + string err = string.Format(Resources.Strings.Fixes_Revert_Partial_Body, errors); + toast.Show(Resources.Strings.Fixes_Revert_Partial, err, error: true); + return new JobResult(false, err); + } + + string message = string.Format(Resources.Strings.Fixes_Revert_Done_Body, restored, deleted); + toast.Show(Resources.Strings.Fixes_Revert_Done, message); + return new JobResult(true, message, installDir); + } + catch (Exception ex) + { + toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true); + return new JobResult(false, ex.Message); } } diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index 47c6ee3..b4cdd37 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -79,14 +79,25 @@ public partial class FixItemVm(DenuvoFix f) : ObservableObject /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanDownloadFix), nameof(FixHint))] + private bool _gameInstalled; + /// True when the fix has been applied (its revert manifest exists on disk). Drives the Revert button. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasApplied), nameof(CanDownloadFix), nameof(FixHint))] + private bool _isApplied; + + public bool HasApplied => IsApplied; + public bool CanDownloadManifest => HasManifest && ManifestItem?.IsActive != true; - public bool CanDownloadFix => HasFix && GameInstalled && FixItem?.IsActive != true; + public bool CanDownloadFix => HasFix && GameInstalled && !IsApplied && FixItem?.IsActive != true; /// Why the Fix button is greyed out, or null when it isn't. A null ToolTip shows nothing, /// so this doubles as the "should there be a tooltip at all" test. - public string? FixHint => GameInstalled ? null : Resources.Strings.Fixes_NotInstalled_Hint; + public string? FixHint => + IsApplied ? Resources.Strings.Fixes_Applied_Hint + : GameInstalled ? null + : Resources.Strings.Fixes_NotInstalled_Hint; private static string FormatDate(string? iso) => DateTimeOffset.TryParse(iso, out var d) ? d.UtcDateTime.ToString("d MMM yyyy") : ""; @@ -297,9 +308,16 @@ private async Task OpenGame(FixGameCardVm game) // Is the game on disk? GetInstallDir walks libraryfolders.vdf + appmanifest_*.acf, so // it's file I/O — off the UI thread. Resolved once here rather than per fix row. - bool installed = long.TryParse(game.AppId, out long gameAppId) - && await Task.Run(() => library.GetInstallDir(gameAppId) is not null); - foreach (var f in _allFixes) f.GameInstalled = installed; + string? installDir = long.TryParse(game.AppId, out long gameAppId) + ? await Task.Run(() => library.GetInstallDir(gameAppId)) + : null; + foreach (var f in _allFixes) + { + f.GameInstalled = installDir is not null; + // Whether this specific fix has been applied (revert manifest on disk). + f.IsApplied = installDir is not null + && File.Exists(ManifestJobFactory.GetFixManifestPath(installDir, f.Id)); + } // Build the per-game filter pills from the distinct tags across this game's fixes. // But only when there's more than one (a single tag is no filter). @@ -344,6 +362,48 @@ private void ApplyFixFilter() [RelayCommand] private Task DownloadFix(FixItemVm fix) => RunDownload(fix, "fix"); + /// Confirm, then revert an applied fix back to its original files. + [RelayCommand] + private void RevertFix(FixItemVm fix) + { + if (SelectedGame is not { } game) return; + _pendingRevert = (fix, game); + ConfirmRevertTitle = string.Format(Resources.Strings.Fixes_Revert_Confirm_Title, game.Name); + ConfirmRevertBody = string.Format(Resources.Strings.Fixes_Revert_Confirm_Body, game.Name); + IsConfirmingRevert = true; + } + + [RelayCommand] + private void CancelRevertConfirm() + { + IsConfirmingRevert = false; + _pendingRevert = null; + } + + [RelayCommand] + private async Task ConfirmRevert() + { + IsConfirmingRevert = false; + var pending = _pendingRevert; + _pendingRevert = null; + if (pending is not (var fix, var game)) return; + if (!long.TryParse(game.AppId, out long appId)) return; + + var result = await Task.Run(() => jobs.RevertDenuvoFix(appId, fix.Id, game.Name)); + if (!result.Ok) + { + toast.Show(Resources.Strings.Fixes_Revert_Failed, result.Message ?? "", error: true); + return; + } + + fix.IsApplied = false; + } + + private (FixItemVm Fix, FixGameCardVm Game)? _pendingRevert; + [ObservableProperty] private bool _isConfirmingRevert; + [ObservableProperty] private string _confirmRevertTitle = ""; + [ObservableProperty] private string _confirmRevertBody = ""; + /// /// Queue one slot of a fix. The download, install and result toast all happen in the shared queue, /// so this returns as soon as the item is enqueued. @@ -374,8 +434,15 @@ private async Task RunDownload(FixItemVm fix, string slot) // The factory already toasts success and install failures. A download that never got // that far (network, auth, daily limit) still needs to say something. if (result is null && item.Status == DownloadStatus.Failed) + { toast.Show(Resources.Strings.Fixes_Toast_DownloadFailed, item.Message ?? Resources.Strings.Fixes_Toast_DownloadFailed_Body, error: true); + return; + } + + // A successfully applied fix unlocks the Revert button right away, without closing and + // reopening the flyout. + if (slot == "fix" && result?.Ok == true) fix.IsApplied = true; }); var item = queue.Enqueue(job); diff --git a/src/LuaToolsGui/Views/FixesView.xaml b/src/LuaToolsGui/Views/FixesView.xaml index 51a0ac3..1a9ce14 100644 --- a/src/LuaToolsGui/Views/FixesView.xaml +++ b/src/LuaToolsGui/Views/FixesView.xaml @@ -329,12 +329,13 @@ Background="#1c1c22" BorderBrush="#22FFFFFF" BorderThickness="1,0,0,0"> - - - - - - + + + + + + + @@ -487,9 +488,10 @@ - + + @@ -530,6 +542,53 @@ + + + + + + + + + + + + + + + From af622b75e8b003ed19e096133958185699feff11 Mon Sep 17 00:00:00 2001 From: Brandon Hernandez <142270679+Brandher58@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:36:18 -0700 Subject: [PATCH 2/2] add "My games" filter to fixes page --- src/LuaToolsGui/Resources/Strings.Designer.cs | 3 + src/LuaToolsGui/Resources/Strings.es.resx | 3 + src/LuaToolsGui/Resources/Strings.resx | 3 + src/LuaToolsGui/ViewModels/FixesViewModel.cs | 47 +++++- src/LuaToolsGui/Views/FixesView.xaml | 136 ++++++++++++------ 5 files changed, 144 insertions(+), 48 deletions(-) diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index c20fde5..a6eaa14 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -309,6 +309,9 @@ public static class Strings public static string Fixes_Revert_Failed => Get(nameof(Fixes_Revert_Failed)); public static string Fixes_Revert_NoManifest => Get(nameof(Fixes_Revert_NoManifest)); public static string Fixes_Applied_Hint => Get(nameof(Fixes_Applied_Hint)); + public static string Fixes_MyGames => Get(nameof(Fixes_MyGames)); + public static string Fixes_MyGames_Count => Get(nameof(Fixes_MyGames_Count)); + public static string Fixes_MyGames_NotInstalled => Get(nameof(Fixes_MyGames_NotInstalled)); // ── Add / Download ── public static string Add_Title => Get(nameof(Add_Title)); diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index 4e84754..4e4f2e6 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -326,6 +326,9 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo revertir el parche No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. Este parche ya está aplicado. Revierte para instalar otro. + Mis juegos + {0} de tus juegos tienen parches. Desplázate para ver el resto. + Aún no hay luas agregadas: añade un juego (o instala uno) para verlo aquí. ¡A por los Luas™! Introduce un App ID, pega un enlace de Steam o busca por nombre de juego Introduce un App ID o busca juegos... diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index badd357..4d1bfba 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -360,6 +360,9 @@ This deletes the .lua files from Steam\config\stplug-in. Couldn't revert fix No record of this fix being applied. It may already have been reverted. This fix is already applied. Revert it if you want to install another. + My games + {0} of your games have fixes. Scroll the grid to browse the rest. + No luas added yet — add a game (or install one) to see it here. Let's get Luing™! diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index b4cdd37..556d84e 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -118,11 +118,12 @@ public partial class FixesViewModel : PagedListViewModel private readonly DownloadQueue queue; private readonly ManifestJobFactory jobs; private readonly SteamLibraryService library; + private readonly SteamService steam; public FixesViewModel( LuaToolsApiClient api, AuthService auth, CoverCache covers, ToastService toast, SettingsService settings, DownloadQueue queue, ManifestJobFactory jobs, - SteamLibraryService library) + SteamLibraryService library, SteamService steam) { this.api = api; this.auth = auth; @@ -132,6 +133,7 @@ public FixesViewModel( this.queue = queue; this.jobs = jobs; this.library = library; + this.steam = steam; InitPageSize(settings.FixesPageSize); } @@ -158,6 +160,37 @@ protected override void OnPageSliced(IReadOnlyList slice) [ObservableProperty] private string? _selectedTagId; // null = "All" + // Appids with a lua in Steam's config/stplug-in ("my games"), so the page can filter the fix + // listing down to games the user actually owns. Empty when Steam isn't set up / no luas installed. + private HashSet _installedAppIds = []; + + /// True once the listing has been fetched (set at the end of LoadAsync). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanFilter))] + private bool _loaded; + + /// Gates the filter pills ("my games" + tags) behind a finished, non-empty listing. + public bool CanFilter => Loaded && _allGames.Count > 0; + + /// Only show fix games the user has added (a lua in stplug-in). Mirrors Manage's "my games". + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MyGamesHint))] + private bool _myGamesOnly; + + public string MyGamesHint => _installedAppIds.Count == 0 + ? Resources.Strings.Fixes_MyGames_NotInstalled + : string.Format(Resources.Strings.Fixes_MyGames_Count, _installedAppIds.Count); + + partial void OnMyGamesOnlyChanged(bool value) + { + if (value) + { + SelectedTagId = null; // one filter at a time — turning "my games" on drops any tag + foreach (var pill in Tags) pill.IsSelected = false; + } + ApplyFilter(); + } + // ── Detail flyout ─────────────────────────────────────────────── [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDetailOpen))] @@ -198,6 +231,14 @@ public async Task LoadAsync(bool force = false) _allGames = data.Games.Select(g => new FixGameCardVm(g)).ToList(); Tags.Clear(); foreach (var t in data.Tags) Tags.Add(new TagPillVm(t)); + + // "My games" filter source: the same stplug-in scan the Manage page uses, so the toggle + // shows only games the user actually added. Scanned once per listing load. + _installedAppIds = steam.StPlugInDir is { } dir + ? await Task.Run(() => LuaInstaller.EnumerateInstalled(dir).Select(i => i.AppId).ToHashSet()) + : []; + OnPropertyChanged(nameof(MyGamesHint)); + ApplyFilter(); if (_allGames.Count == 0) EmptyMessage = Resources.Strings.Fixes_Empty_None; } @@ -208,6 +249,7 @@ public async Task LoadAsync(bool force = false) finally { IsLoading = false; + Loaded = true; // gates the filter pills: they appear only once the listing settled } } @@ -216,6 +258,7 @@ private Task Refresh() => RefreshWithCooldownAsync(async () => { if (SearchText.Length > 0) SearchText = ""; // reset filter → full list visible if (SelectedTagId is not null) SelectTag(SelectedTagId); // clear active tag (toggles off) + if (MyGamesOnly) MyGamesOnly = false; // ditto for the "my games" filter await LoadAsync(force: true); toast.Show(Resources.Strings.Fixes_Toast_Refreshed_Title, string.Format(Resources.Strings.Fixes_Toast_Refreshed_Body, _allGames.Count)); @@ -225,6 +268,7 @@ private Task Refresh() => RefreshWithCooldownAsync(async () => private void SelectTag(string? tagId) { SelectedTagId = SelectedTagId == tagId ? null : tagId; // toggle off when re-clicked + if (MyGamesOnly) MyGamesOnly = false; // one filter at a time — picking a tag drops "my games" foreach (var pill in Tags) pill.IsSelected = pill.Id == SelectedTagId; ApplyFilter(); } @@ -234,6 +278,7 @@ private void ApplyFilter() string q = SearchText.Trim(); IEnumerable shown = _allGames; if (SelectedTagId is { } tag) shown = shown.Where(g => g.TagIds.Contains(tag)); + if (MyGamesOnly) shown = shown.Where(g => long.TryParse(g.AppId, out long id) && _installedAppIds.Contains(id)); if (q.Length > 0) shown = shown.Where(g => g.Matches(q)); // Hand the filtered list to the base: it slices the visible page and (via OnPageSliced) warms diff --git a/src/LuaToolsGui/Views/FixesView.xaml b/src/LuaToolsGui/Views/FixesView.xaml index 1a9ce14..c7da032 100644 --- a/src/LuaToolsGui/Views/FixesView.xaml +++ b/src/LuaToolsGui/Views/FixesView.xaml @@ -55,53 +55,95 @@ PlaceholderText="{x:Static res:Strings.Common_SearchPlaceholder}" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" /> - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +