Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 71 additions & 39 deletions src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -577,9 +577,7 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId,

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
{
Expand All @@ -588,7 +586,13 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId,
AppliedAt = DateTimeOffset.UtcNow.ToString("o"),
};

// ── Phase 1: plan every entry (modified vs added) WITHOUT writing anything ────────────────
// Deciding needs only File.Exists → the manifest is written first, so a busy/locked manifest
// path aborts BEFORE a single game file is touched. Otherwise a manifest write failure (bug
// fix for the old catch{} at the end) left .bak files with no manifest to index them — and
// since IsApplied keys off the manifest on disk, no Revert button ever appeared for them.
using var archive = ZipFile.OpenRead(file.FilePath);
var plan = new List<(string RelPath, string DestPath, string? BakRel, ZipArchiveEntry Entry)>();
int failed = 0;
foreach (var entry in archive.Entries)
{
Expand All @@ -599,39 +603,66 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId,
// count it, rather than writing wherever it points.
if (ResolveInside(installDir, entry.FullName) is not { } dest) { failed++; continue; }

// Back up the original — never clobber a known-good .bak.
//
// Keyed by the FULL relative path, not just the file name: fix archives routinely
// ship the same name in several folders (steam_api64.dll, config.ini), and keying
// by name alone collapsed them onto one .bak. The !File.Exists guard below then
// skipped backing the second one up while still recording it as "modified"
// pointing at the first one's backup — so a revert restored one file with the
// other's contents, or silently restored nothing once the .bak was consumed.
if (File.Exists(dest))
{
string bakRel = $"{fixKey}/{relPath}.bak";
plan.Add((relPath, dest, bakRel, entry));
manifest.Files.Add(new DenuvoFixManifestEntry
{
RelativePath = relPath,
Action = "modified",
BackupPath = bakRel,
});
}
else
{
plan.Add((relPath, dest, null, entry));
manifest.Files.Add(new DenuvoFixManifestEntry { RelativePath = relPath, Action = "added" });
}
}

// ── Phase 2: persist the manifest as the apply's "commit point" ───────────────────────────
// If it can't be written the apply STOPS here: nothing was extracted, nothing backed up,
// no orphaned state. From this point on the manifest exists on disk, so even a mid-extract
// failure leaves the Revert button usable (IsApplied = manifest exists) — the same
// recoverability guarantee as the old "write even on partial failure", minus the hole where
// the manifest itself failed to persist.
string manifestJson = System.Text.Json.JsonSerializer.Serialize(
manifest, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
try
{
Directory.CreateDirectory(fixDir);
File.WriteAllText(manifestPath, manifestJson);
}
catch (Exception ex)
{
toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true);
return new JobResult(false, ex.Message);
}

// ── Phase 3: apply. Every backup write or extract error counts; the already-written
// manifest keeps whatever DID land recoverable (either finished files, or files whose
// backup exists but whose restore is one retry away — e.g. a locked game dir).
foreach (var (relPath, dest, bakRel, entry) in plan)
{
try
{
if (File.Exists(dest))
if (bakRel is { } bak)
{
// Back up the original — never clobber a known-good .bak.
//
// Keyed by the FULL relative path, not just the file name: fix archives routinely
// ship the same name in several folders (steam_api64.dll, config.ini), and keying
// by name alone collapsed them onto one .bak. The !File.Exists guard below then
// skipped backing the second one up while still recording it as "modified"
// pointing at the first one's backup — so a revert restored one file with the
// other's contents, or silently restored nothing once the .bak was consumed.
string bakRel = $"{fixKey}/{relPath}.bak";
string bakAbs = Path.Combine(installDir, FixManifestDir, bakRel);
string bakAbs = Path.Combine(installDir, FixManifestDir, bak);
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)!);
Expand All @@ -640,15 +671,6 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId,
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);
Expand Down Expand Up @@ -735,12 +757,22 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName)
{
string? installDir = library.GetInstallDir(appId);
if (installDir is null)
return new JobResult(false, string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName));
{
// The factory owns every standard revert toast (done / partial / not-found / no-manifest),
// matching the apply path, so callers must not add their own.
string err = string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName);
toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, err, error: true);
return new JobResult(false, err);
}

string manifestPath = GetFixManifestPath(installDir, fixId);
var manifest = ReadFixManifest(manifestPath);
if (manifest is null)
return new JobResult(false, Resources.Strings.Fixes_Revert_NoManifest);
{
string err = Resources.Strings.Fixes_Revert_NoManifest;
toast.Show(Resources.Strings.Fixes_Revert_Failed, err, error: true);
return new JobResult(false, err);
}

int restored = 0, deleted = 0, errors = 0;

Expand Down Expand Up @@ -799,7 +831,7 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName)
}
catch (Exception ex)
{
toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true);
toast.Show(Resources.Strings.Fixes_Revert_Failed, ex.Message, error: true);
return new JobResult(false, ex.Message);
}
}
Expand Down
25 changes: 15 additions & 10 deletions src/LuaToolsGui/ViewModels/FixesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,19 @@ protected override void OnPageSliced(IReadOnlyList<FixGameCardVm> slice)
[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);
/// <summary>How many of the user's installed games actually appear in the fix listing.</summary>
public string MyGamesHint
{
get
{
if (_installedAppIds.Count == 0)
return Resources.Strings.Fixes_MyGames_NotInstalled;

int withFixes = _allGames.Count(g =>
long.TryParse(g.AppId, out long id) && _installedAppIds.Contains(id));
return string.Format(Resources.Strings.Fixes_MyGames_Count, withFixes);
}
}

partial void OnMyGamesOnlyChanged(bool value)
{
Expand Down Expand Up @@ -435,13 +445,8 @@ private async Task ConfirmRevert()
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;
// The factory shows the toast (done / partial / not-found / no-manifest) — single owner.
if (result.Ok) fix.IsApplied = false;
}

private (FixItemVm Fix, FixGameCardVm Game)? _pendingRevert;
Expand Down