diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index f719494..4804525 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -45,6 +45,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // revert-to-vanilla-Steam feature (local) services.AddSingleton(); services.AddSingleton(); services.AddTransient(); // one per page (Home, Add) diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 51974f0..b1823ef 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -165,6 +165,17 @@ public static class Strings public static string Mode_Btn_Update => Get(nameof(Mode_Btn_Update)); public static string Mode_Btn_Install => Get(nameof(Mode_Btn_Install)); public static string Mode_Btn_Switch => Get(nameof(Mode_Btn_Switch)); + public static string Mode_Revert_Title => Get(nameof(Mode_Revert_Title)); + public static string Mode_Revert_Desc => Get(nameof(Mode_Revert_Desc)); + public static string Mode_Revert_Button => Get(nameof(Mode_Revert_Button)); + public static string Mode_Revert_Confirm_Body => Get(nameof(Mode_Revert_Confirm_Body)); + public static string Mode_Revert_Opt_Lua => Get(nameof(Mode_Revert_Opt_Lua)); + public static string Mode_Revert_Opt_Manifests => Get(nameof(Mode_Revert_Opt_Manifests)); + public static string Mode_Revert_Toast_Done => Get(nameof(Mode_Revert_Toast_Done)); + public static string Mode_Revert_Toast_Done_NoStart => Get(nameof(Mode_Revert_Toast_Done_NoStart)); + public static string Mode_Revert_Failed_Body => Get(nameof(Mode_Revert_Failed_Body)); + public static string Mode_Backup_Summary => Get(nameof(Mode_Backup_Summary)); + public static string Mode_Backup_Summary_None => Get(nameof(Mode_Backup_Summary_None)); public static string Mode_Confirm_Reinstall => Get(nameof(Mode_Confirm_Reinstall)); public static string Mode_Confirm_Switch => Get(nameof(Mode_Confirm_Switch)); public static string Mode_Toast_Updated => Get(nameof(Mode_Toast_Updated)); diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index fe4c107..06c2a27 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -162,6 +162,18 @@ RECOMMENDED Steam will be closed so its files can be changed, then restarted automatically. Any running games or downloads will stop. Cancel + + Revert to vanilla Steam + Remove the unlocker and restore Steam to how it was before LuaTools touched it. A backup of the original files was taken before the first install. + Revert + Steam will be closed, the unlocker files removed, the original files restored, and Steam restarted. Your games and saves are not affected. + Also remove game lua files (removes added games) + Also remove pinned manifest files + Steam reverted to vanilla and restarted. + Steam reverted to vanilla. Start it manually when ready. + Revert failed. + Original files backed up + No backup — the unlocker files will be removed instead of restored (safe: Steam uses its own system DLLs when these are absent) Close Steam & continue An open source fork of OpenSteamTools actively maintained by the LuaTools team. Introducing fixes and new features! diff --git a/src/LuaToolsGui/Services/BackupService.cs b/src/LuaToolsGui/Services/BackupService.cs new file mode 100644 index 0000000..ccaba77 --- /dev/null +++ b/src/LuaToolsGui/Services/BackupService.cs @@ -0,0 +1,250 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LuaToolsGui.Services; + +/// One backed-up file entry in the backup manifest. +public sealed record BackupEntry( + [property: JsonPropertyName("file")] string File, + [property: JsonPropertyName("sha256")] string Sha256); + +/// manifest.json describing a backup set. Written into the backup folder itself. +public sealed record BackupManifest( + [property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc, + [property: JsonPropertyName("entries")] List Entries); + +/// Summary of the current backup state for UI display. +public sealed record BackupInfo(bool HasBackup, string? CreatedText, int FileCount); + +/// +/// One-time safety backup of the Steam root before a managed mode ever writes to it, plus the +/// restore half of "revert to vanilla Steam". +/// +/// +/// Design notes: +/// • The backup captures the PRE-modification state of the loader DLLs + opensteamtool.toml, the +/// first time a mode install touches the Steam root. If those files were already a mode install +/// (not vanilla), restoring just returns the machine to that earlier state — which is exactly +/// what a backup means. If they were vanilla (the common case), restore is a true revert. +/// • dwmapi.dll/xinput1_4.dll in the Steam root are NOT the Windows system DLLs — they are proxy +/// DLLs a mode places there (or files that were already present before us). Nothing outside the +/// Steam folder is ever touched; "restore originals from System32" is deliberately NOT done +/// because System32 files are irrelevant to the Steam root. +/// • Hashes (sha256) are recorded at backup time so a restore can verify the copies are intact. +/// • The backup is created once and then left alone by later installs, so it always represents +/// the oldest state this app observed. +/// +/// +public class BackupService(SteamService steam) +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// Files worth backing up from the Steam root. Only ones WE (a mode install) would + /// overwrite or that describe our configuration. Never touches Steam's own files. + private static readonly string[] RootFilesToBackup = + [ + "dwmapi.dll", + "xinput1_4.dll", + "opensteamtool.toml", + ]; + + /// Mode payload file (removed on revert; recreated by a future install). + public const string OpenSteamToolDll = "OpenSteamTool.dll"; + + /// CloudRedirect add-on payload (removed on revert when present). + public const string CloudRedirectDll = "cloud_redirect.dll"; + + private static string Dir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "LuaToolsGui", "backups"); + + private static string ManifestPath => Path.Combine(Dir, "manifest.json"); + + /// True when a backup set exists on disk (manifest present with entries). + public bool HasBackup + { + get + { + try + { + if (!File.Exists(ManifestPath)) return false; + var manifest = ReadManifest(); + return manifest is { Entries.Count: > 0 }; + } + catch { return false; } + } + } + + /// Backup summary for the Mode page UI (exists + when + how many files). + public BackupInfo GetInfo() + { + try + { + var manifest = ReadManifest(); + if (manifest is null || manifest.Entries.Count == 0) + return new BackupInfo(false, null, 0); + + // Local time, short format — e.g. "9/6/2026 6:46 PM". + string when = manifest.CreatedAtUtc.LocalDateTime.ToString("g"); + return new BackupInfo(true, when, manifest.Entries.Count); + } + catch + { + return new BackupInfo(false, null, 0); + } + } + + /// + /// Back up the Steam-root files we would touch — but ONLY once, and only what actually exists. + /// Called by before the first managed install. Later + /// installs keep the original backup so it always preserves the oldest observed state. + /// Returns true if a backup exists after this call (new or previous). + /// + public bool BackupIfNeeded() + { + if (HasBackup) return true; // one-time: never overwrite the original backup + + string? root = steam.EffectivePath; + if (root is null) return false; + + Directory.CreateDirectory(Dir); + + var entries = new List(); + try + { + foreach (string file in RootFilesToBackup) + { + string src = Path.Combine(root, file); + if (!File.Exists(src)) continue; + + string dst = Path.Combine(Dir, file); + File.Copy(src, dst, overwrite: true); + entries.Add(new BackupEntry(file, AssetHash.OfFile(dst))); + } + + if (entries.Count == 0) + { + // Nothing to back up (fresh Steam, no loader DLLs yet). Don't leave an empty backup + // dir masquerading as one: remove it so BackupIfNeeded can try again later if needed. + try { Directory.Delete(Dir, recursive: true); } catch { /* best effort */ } + return false; + } + + WriteManifest(new BackupManifest(DateTimeOffset.UtcNow, entries)); + return true; + } + catch + { + // Failed backup must NOT block install silently-as-success: report no backup. The caller + // decides whether to proceed (it does — a backup is a nicety, not a hard requirement). + return false; + } + } + + /// + /// Restore the backed-up files into the Steam root (overwriting whatever a mode left there), + /// verify each restored copy's hash, and remove any restored file that fails verification. + /// Returns per-file failures; empty = full success. + /// + public List RestoreBackedUpFiles() + { + var failures = new List(); + + var manifest = TryReadManifestOrNull(); + if (manifest is null || manifest.Entries.Count == 0) + { + failures.Add("no backup manifest"); + return failures; + } + + string? root = steam.EffectivePath; + if (root is null) + { + failures.Add("steam not found"); + return failures; + } + + foreach (var entry in manifest.Entries) + { + try + { + string src = Path.Combine(Dir, entry.File); + if (!File.Exists(src)) + { + failures.Add(entry.File); + continue; + } + + // Verify the backup copy is intact BEFORE it lands in the Steam root. + if (!AssetHash.OfFile(src).Equals(entry.Sha256, StringComparison.OrdinalIgnoreCase)) + { + failures.Add(entry.File); // corrupted backup copy → don't propagate it + continue; + } + + string dst = Path.Combine(root, entry.File); + File.Copy(src, dst, overwrite: true); + + // Double-check what actually landed on disk. + if (!AssetHash.OfFile(dst).Equals(entry.Sha256, StringComparison.OrdinalIgnoreCase)) + { + try { File.Delete(dst); } catch { /* best effort */ } + failures.Add(entry.File); + } + } + catch + { + failures.Add(entry.File); // locked (Steam running?) or IO error + } + } + + return failures; + } + + /// The backed-up file names, or empty if no backup. For UI text ("restores 3 files"). + public IReadOnlyList BackedUpFiles() + { + try + { + return TryReadManifestOrNull()?.Entries.Select(e => e.File).ToList() ?? []; + } + catch { return []; } + } + + /// Delete the backup set entirely (opt-in "forget the backup" action). + public void DeleteBackup() + { + try { if (Directory.Exists(Dir)) Directory.Delete(Dir, recursive: true); } + catch { /* best effort */ } + } + + // ── plumbing ── + + private BackupManifest? TryReadManifestOrNull() + { + try { return ReadManifest(); } + catch { return null; } + } + + private BackupManifest? ReadManifest() + { + if (!File.Exists(ManifestPath)) return null; + string json = File.ReadAllText(ManifestPath); + return JsonSerializer.Deserialize(json, JsonOpts); + } + + private static void WriteManifest(BackupManifest manifest) + { + string json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }); + File.WriteAllText(ManifestPath, json); + } +} diff --git a/src/LuaToolsGui/Services/UnlockerService.cs b/src/LuaToolsGui/Services/UnlockerService.cs index 18234ae..5341fcb 100644 --- a/src/LuaToolsGui/Services/UnlockerService.cs +++ b/src/LuaToolsGui/Services/UnlockerService.cs @@ -14,7 +14,7 @@ namespace LuaToolsGui.Services; /// files. Switching overwrites shared files but doesn't delete the previous mode's leftovers. The /// active mode persists in settings. /// -public class UnlockerService(SteamService steam, SettingsService settings, CacheService cache, GithubProxy gh) +public class UnlockerService(SteamService steam, SettingsService settings, CacheService cache, GithubProxy gh, BackupService backup) { private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; @@ -202,6 +202,148 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) return (ModeStatus.UpdateAvailable, latest.TagName); } + // ── Revert to vanilla ─────────────────────────────────────────── + + /// True when a revert has something to undo: any mode-managed artifact is present in + /// the Steam root. Works with OR without a backup — without one (installs made before backups + /// existed), the mode-placed files are removed instead of restored. Cheap, disk-only. + public bool CanRevert => ModeArtifactsPresent().Count > 0; + + /// Human-readable backup summary for the Mode page. With a backup: when it was taken + + /// how many files. Without one: a note that the files will be removed rather than restored. + public string? BackupSummary + { + get + { + var info = backup.GetInfo(); + return info.HasBackup + ? $"{Resources.Strings.Mode_Backup_Summary} · {info.CreatedText} · {info.FileCount}" + : Resources.Strings.Mode_Backup_Summary_None; + } + } + + /// Mode-managed artifacts currently present in the Steam root: the loader DLLs, the + /// payload DLL, CloudRedirect and opensteamtool.toml. Used to decide if a revert has work to do. + private List ModeArtifactsPresent() + { + var present = new List(); + string? root = steam.EffectivePath; + if (root is null) return present; + + foreach (string f in new[] + { + BackupService.OpenSteamToolDll, + BackupService.CloudRedirectDll, + "dwmapi.dll", "xinput1_4.dll", "opensteamtool.toml", + }) + if (File.Exists(Path.Combine(root, f))) present.Add(f); + return present; + } + + /// + /// Revert Steam to its pre-LuaTools state: Steam must already be stopped (the caller owns the + /// close/relaunch choreography). Restores every backed-up file (verified), removes mode payload + /// DLLs and opensteamtool.toml (restored by the backup when it existed before us), clears the + /// selected mode, and optionally cleans game luas + pinned manifests. + /// + /// Also delete <Steam>/config/stplug-in/*.lua (game unlocks). + /// Also delete <Steam>/config/depotcache/*.manifest (pins). + public ModeInstallResult Revert(bool cleanLua = false, bool cleanManifests = false) + { + string? root = steam.EffectivePath; + if (root is null || !steam.IsValid) + return ModeInstallResult.Fail(Resources.Strings.Err_SteamNotFound); + + var failed = new List(); + + // 1. Restore backed-up files (loader DLLs + opensteamtool.toml as they were before us). + // No backup (e.g. the mode was installed before backups existed) → the loader DLLs were + // placed by the mode, so deleting them IS the revert: absent proxies, steam.exe loads the + // real system DLLs from System32. (opensteamtool.toml is handled by step 3 below.) + if (backup.HasBackup) + failed.AddRange(backup.RestoreBackedUpFiles()); + else + foreach (string f in new[] { "dwmapi.dll", "xinput1_4.dll" }) + { + try { string p = Path.Combine(root, f); if (File.Exists(p)) File.Delete(p); } + catch { failed.Add(f); } + } + + // 2. Remove mode payload files that have no backup to restore over them. + foreach (string f in new[] { BackupService.OpenSteamToolDll, BackupService.CloudRedirectDll }) + { + try + { + string p = Path.Combine(root, f); + // Only delete when it's NOT covered by the backup (the restore already overwrote it). + if (backup.BackedUpFiles().Any(b => b.Equals(f, StringComparison.OrdinalIgnoreCase))) continue; + if (File.Exists(p)) File.Delete(p); + } + catch { failed.Add(f); } + } + + // 3. opensteamtool.toml: if the backup has no copy, the file was created by us → delete it. + try + { + string toml = Path.Combine(root, "opensteamtool.toml"); + if (!backup.BackedUpFiles().Any(b => b.Equals("opensteamtool.toml", StringComparison.OrdinalIgnoreCase)) + && File.Exists(toml)) + File.Delete(toml); + } + catch { failed.Add("opensteamtool.toml"); } + + // 4. Optional: remove game luas (the user's unlocks). + if (cleanLua) + { + try + { + string? dir = steam.StPlugInDir; + if (dir is not null && Directory.Exists(dir)) + foreach (string f in Directory.GetFiles(dir, "*.lua")) + File.Delete(f); + } + catch { failed.Add("*.lua"); } + } + + // 5. Optional: remove pinned manifests. + if (cleanManifests) + { + try + { + string? dir = steam.DepotCacheDir; + if (dir is not null && Directory.Exists(dir)) + foreach (string f in Directory.GetFiles(dir, "*.manifest")) + File.Delete(f); + } + catch { failed.Add("*.manifest"); } + } + + // 6. Clear the active mode so the app reflects vanilla state (onboarding re-opens). + settings.SelectedMode = null; + cache.OpenSteamToolsInstalledVersion = null; + cache.OpenSteamToolsInstalledZipDigest = null; + + return failed.Count > 0 + ? new ModeInstallResult(false, + string.Format(Resources.Strings.Err_WriteFailedCount, failed.Count), failed) + : ModeInstallResult.Ok(); + } + + /// Delete stale lua files from stplug-in. Returns count removed (for UI), -1 on failure. + /// Used by the revert flow when the user opts into lua cleanup. + public int CleanLuaFiles() + { + try + { + string? dir = steam.StPlugInDir; + if (dir is null || !Directory.Exists(dir)) return 0; + int n = 0; + foreach (string f in Directory.GetFiles(dir, "*.lua")) { File.Delete(f); n++; } + return n; + } + catch { return -1; } + } + // ── Install / switch ───────────────────────────────────────────── /// Download + verify a mode's files, place them in the Steam root, remove the other mode's @@ -290,7 +432,12 @@ public async Task InstallAsync( return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, manifest.File)); } - // 2. Copy verified files into the Steam root (overwrite). Locked files → Failed (Steam running). + // 2. SAFETY BACKUP (one-time): before the first managed install ever writes to the Steam + // root, snapshot the current loader DLLs + opensteamtool.toml so a later revert can + // restore this exact state. Never blocks install on failure. + try { backup.BackupIfNeeded(); } catch { /* best-effort safety net */ } + + // 3. Copy verified files into the Steam root (overwrite). Locked files → Failed (Steam running). var failed = new List(); foreach (string file in def.PlaceFiles) { @@ -306,7 +453,7 @@ public async Task InstallAsync( } } - // 3. This mode is now the active one. (No cleanup of other modes' files. Just overwrite.) + // 4. This mode is now the active one. (No cleanup of other modes' files. Just overwrite.) settings.SelectedMode = mode.ToString(); // Record the installed zip digest/version for reference (the up-to-date check uses per-DLL diff --git a/src/LuaToolsGui/ViewModels/ModeViewModel.cs b/src/LuaToolsGui/ViewModels/ModeViewModel.cs index e480668..7f95031 100644 --- a/src/LuaToolsGui/ViewModels/ModeViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ModeViewModel.cs @@ -66,6 +66,21 @@ public partial class ModeViewModel : ObservableObject [ObservableProperty] private string _confirmTitle = ""; private ModeCardViewModel? _pendingCard; + // Revert-to-vanilla confirmation overlay state. + [ObservableProperty] private bool _isConfirmingRevert; + + /// "Also remove game luas" checkbox on the revert confirmation. + [ObservableProperty] private bool _revertCleanLua; + + /// "Also remove pinned manifests" checkbox on the revert confirmation. + [ObservableProperty] private bool _revertCleanManifests; + + /// True when a backup exists AND mode artifacts are present → the Revert card is offered. + [ObservableProperty] private bool _canRevert; + + /// Backup status line for the revert card, or null. + [ObservableProperty] private string? _backupSummary; + public ModeViewModel(UnlockerService unlocker, ToastService toast, SteamService steam, CloudRedirectService cloudRedirect) { @@ -289,6 +304,69 @@ public async Task LoadAsync(bool forceRefresh = false) // Bottom CloudRedirect add-on panel (locked unless Nightly BST is the active mode). await RefreshCloudRedirectAsync(forceRefresh); + + // Revert card: visible only when a backup exists AND there are mode artifacts to undo. + CanRevert = _unlocker.CanRevert; + BackupSummary = _unlocker.BackupSummary; + } + + // ── Revert to vanilla Steam ────────────────────────────────────── + + /// "Revert to vanilla" button → show the confirmation overlay. + [RelayCommand] + private void StartRevert() + { + if (IsBusy || !_unlocker.CanRevert) return; + RevertCleanLua = false; + RevertCleanManifests = false; + IsConfirmingRevert = true; + } + + [RelayCommand] + private void CancelRevert() + { + IsConfirmingRevert = false; + } + + /// Confirmed revert: close Steam, restore/delete mode files, restart Steam, report. + [RelayCommand] + private async Task ConfirmRevert() + { + IsConfirmingRevert = false; + if (IsBusy) return; + IsBusy = true; + IsProgressIndeterminate = true; + Progress = 0; + try + { + // Files are locked while Steam runs; same choreography as install. + await Task.Run(_steam.StopSteam); + + var result = await Task.Run(() => + _unlocker.Revert(RevertCleanLua, RevertCleanManifests)); + + if (result.Success) + { + bool started = await Task.Run(_steam.StartSteam); + _toast.Show(Resources.Strings.Mode_Revert_Title, started + ? Resources.Strings.Mode_Revert_Toast_Done + : Resources.Strings.Mode_Revert_Toast_Done_NoStart); + } + else + { + // Revert (partially) failed: still bring Steam back up. + await Task.Run(_steam.StartSteam); + _toast.Show(Resources.Strings.Mode_Revert_Title, + result.Error ?? Resources.Strings.Mode_Revert_Failed_Body, error: true); + } + + await LoadAsync(); + } + finally + { + IsBusy = false; + IsProgressIndeterminate = false; + } } private DateTime _lastCheck; diff --git a/src/LuaToolsGui/Views/ModeView.xaml b/src/LuaToolsGui/Views/ModeView.xaml index cb5f118..e7f45c1 100644 --- a/src/LuaToolsGui/Views/ModeView.xaml +++ b/src/LuaToolsGui/Views/ModeView.xaml @@ -199,6 +199,61 @@ + + + + + + + + + + + + + + + + + + + + + @@ -298,6 +353,62 @@ + + + + + + + + + + + + + + + +