From eaf3cb38504864451b9ec39eb2414490747bd792 Mon Sep 17 00:00:00 2001 From: madoiscool <110845835+madoiscool@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:54:31 +1000 Subject: [PATCH 01/29] Revise README for LuaTools App and usage instructions Updated project name and added image to README. Clarified instructions regarding `dotnet watch` and `dotnet build` conflicts. --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2744380..2a89af5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# LuaTools GUI +# LuaTools App - https://lua.tools/app + +LuaTools_bQbIoCNECC A Windows desktop client for managing Steam manifest/lua configurations, built with WPF on .NET 8. @@ -26,13 +28,6 @@ For iterative development: dotnet watch --project src/LuaToolsGui/LuaToolsGui.csproj ``` -> **Do not run `dotnet build` while `dotnet watch` is running.** They collide on `obj/` and produce -> spurious `CS0579` duplicate-attribute errors from the generated `_wpftmp` project. Let watch do -> the building, or stop it first. -> -> Editing a `.resx` file or any `x:Static` XAML binding requires a **watch restart**. Hot reload -> can't compile new RESX into satellite assemblies or re-parse compile-time `x:Static`. - ## Test ```powershell From 1079e4d38dd3319cd6035476b85353c2830fd717 Mon Sep 17 00:00:00 2001 From: peeblyweeb <74932007+PeeblyWeeb@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:39:46 -0400 Subject: [PATCH 02/29] Create CONTRIBUTING.md --- CONTRIBUTING.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..daf846f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +## Building from source / Developing + +```powershell +dotnet build LuaToolsGui.sln +dotnet run --project src/LuaToolsGui/LuaToolsGui.csproj +``` + +For iterative development: + +```powershell +dotnet watch --project src/LuaToolsGui/LuaToolsGui.csproj +``` + +### Test + +```powershell +dotnet test +``` + +### Releases + +Official builds are packaged and signed by the maintainers, so the release tooling isn't part of +this repo. Released builds are framework-dependent (~10 MB) and the setup auto-installs the .NET 8 +Desktop Runtime on a clean machine; the app then self-updates through Velopack. + +To produce a local build for testing, `dotnet publish -c Release` is enough. + +### Layout + +| Path | Contents | +|---|---| +| `src/LuaToolsGui/` | The application: `Views/` (XAML), `ViewModels/`, `Services/`, `Models/`, `Resources/` (localization) | +| `src/LuaToolsGui/AppConfig.cs` | All compiled-in endpoints, mirrors and public client values | +| `tests/LuaToolsGui.Tests/` | xUnit tests | +| `scripts/check-i18n.py` | Translation validator, run by CI on every RESX change | + +## Contribution Guidelines + +Three rules are non-negotiable. + +### 1. Every user-facing string must be localized + +The app ships 29 languages. **Never hardcode a visible string**, whether in XAML, a toast, a +`MessageBox`, or an empty-state label. Adding text means: + +1. Add the key to the English source, `src/LuaToolsGui/Resources/Strings.resx`. +2. Add the accessor to the hand-maintained `Strings.Designer.cs` + (`public static string Key => Get(nameof(Key));`). +3. Add the same key, with a real translation, to all `Strings..resx` files. A key missing + from any one of them is a bug. +4. Reference it. XAML: `Text="{x:Static res:Strings.Key}"`; C#: `Resources.Strings.Key` or + `string.Format(Resources.Strings.Key, arg)`. + +`.github/workflows/i18n-check.yml` runs `scripts/check-i18n.py` on every PR touching the RESX files. +Translation contributions are pull requests against those files. See +[`src/LuaToolsGui/Resources/README.md`](src/LuaToolsGui/Resources/README.md) for the translator +guide, including which terms to leave untranslated and why. + +### 2. Every GitHub request goes through `GithubProxy` + +The app must work in regions where `github.com` and `api.github.com` are blocked. **Never call +GitHub directly.** Route through `Services/GithubProxy.cs` (a DI singleton), which tries the direct +URL first and then falls through capability-matched mirrors: + +```csharp +await gh.SendAsync(url, ct); // API / metadata +await gh.DownloadAsync(url, dest, progress, ct); // release-asset binaries +``` + +The Velopack auto-updater is covered too. `UpdateService` passes a `ProxiedFileDownloader` that +reuses `GithubProxy.Candidates`, so the update feed and `.nupkg` fall back to mirrors as well. + +### 3. Store-page plugin calls go through `callServerMethod` + +The injected store-page script runs inside an HTTPS page and cannot raw-`fetch` a localhost +backend; mixed-content policy kills it silently. Any new store-page → backend call must go through +`Millennium.callServerMethod("luatools", "", args)` and needs a matching entry in +`CefInjectorService.CallBackendMethod`'s `methodMap`. An unmapped name silently no-ops instead of +erroring. + +`Millennium.callServerMethod` resolves one of two ways depending on the user's setup: under +Millennium it's the framework's own object, and otherwise it's the queue-based polyfill +`CefInjectorService` injects ahead of the plugin script. Either way the actual HTTP request is made +outside the browser, which is what sidesteps the mixed-content block. From 878812e97380e9e9748803847970484d127e6515 Mon Sep 17 00:00:00 2001 From: peeblyweeb <74932007+PeeblyWeeb@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:40:50 -0400 Subject: [PATCH 03/29] Update README.md --- README.md | 118 +++++++++++------------------------------------------- 1 file changed, 23 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 2a89af5..2ba5a84 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,34 @@ -# LuaTools App - https://lua.tools/app - -LuaTools_bQbIoCNECC - -A Windows desktop client for managing Steam manifest/lua configurations, built with WPF on .NET 8. - -LuaTools browses and installs manifest sources, edits `stplug-in` lua files (depot pinning, -per-depot enable/disable), manages unlocker modes, and injects a companion plugin into Steam's -store pages. It ships fully localized in 29 languages and auto-updates via Velopack. +

+ luatools +

+ +# LuaTools +

+ + + [Discord](https://discord.gg/luatools) • [Website](https://lua.tools) • [Git Mirror](https://git.lua.tools/luatools) + + A Windows desktop client for managing Steam manifest/lua configurations, built with WPF on .NET 8. + + LuaTools browses and installs manifest sources, edits `stplug-in` lua files (depot pinning, + per-depot enable/disable), manages unlocker modes, and injects a companion plugin into Steam's + store pages. + + It ships fully translated in 29 languages and auto-updates via Velopack. +
Found a translation error? Tell us about it over on [Discord](https://discord.gg/luatools) +

## Requirements - Windows 10/11 - [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) (the released installer bundles a - check for the .NET 8 **Desktop Runtime** and installs it if missing; building from source needs + check for the .NET 8 **Desktop Runtime** and installs it if missing; [building from source](https://github.com/madoiscool/LuaTools/blob/main/CONTRIBUTING.md#building-from-source--developing) needs the full SDK) -## Build - -```powershell -dotnet build LuaToolsGui.sln -dotnet run --project src/LuaToolsGui/LuaToolsGui.csproj -``` - -For iterative development: - -```powershell -dotnet watch --project src/LuaToolsGui/LuaToolsGui.csproj -``` - -## Test - -```powershell -dotnet test -``` - -## Releases - -Official builds are packaged and signed by the maintainers, so the release tooling isn't part of -this repo. Released builds are framework-dependent (~10 MB) and the setup auto-installs the .NET 8 -Desktop Runtime on a clean machine; the app then self-updates through Velopack. - -To produce a local build for testing, `dotnet publish -c Release` is enough. - -## Layout - -| Path | Contents | -|---|---| -| `src/LuaToolsGui/` | The application: `Views/` (XAML), `ViewModels/`, `Services/`, `Models/`, `Resources/` (localization) | -| `src/LuaToolsGui/AppConfig.cs` | All compiled-in endpoints, mirrors and public client values | -| `tests/LuaToolsGui.Tests/` | xUnit tests | -| `scripts/check-i18n.py` | Translation validator, run by CI on every RESX change | - -## Contributing - -Three rules are non-negotiable. - -### 1. Every user-facing string must be localized - -The app ships 29 languages. **Never hardcode a visible string**, whether in XAML, a toast, a -`MessageBox`, or an empty-state label. Adding text means: - -1. Add the key to the English source, `src/LuaToolsGui/Resources/Strings.resx`. -2. Add the accessor to the hand-maintained `Strings.Designer.cs` - (`public static string Key => Get(nameof(Key));`). -3. Add the same key, with a real translation, to all `Strings..resx` files. A key missing - from any one of them is a bug. -4. Reference it. XAML: `Text="{x:Static res:Strings.Key}"`; C#: `Resources.Strings.Key` or - `string.Format(Resources.Strings.Key, arg)`. - -`.github/workflows/i18n-check.yml` runs `scripts/check-i18n.py` on every PR touching the RESX files. -Translation contributions are pull requests against those files. See -[`src/LuaToolsGui/Resources/README.md`](src/LuaToolsGui/Resources/README.md) for the translator -guide, including which terms to leave untranslated and why. - -### 2. Every GitHub request goes through `GithubProxy` - -The app must work in regions where `github.com` and `api.github.com` are blocked. **Never call -GitHub directly.** Route through `Services/GithubProxy.cs` (a DI singleton), which tries the direct -URL first and then falls through capability-matched mirrors: - -```csharp -await gh.SendAsync(url, ct); // API / metadata -await gh.DownloadAsync(url, dest, progress, ct); // release-asset binaries -``` - -The Velopack auto-updater is covered too. `UpdateService` passes a `ProxiedFileDownloader` that -reuses `GithubProxy.Candidates`, so the update feed and `.nupkg` fall back to mirrors as well. - -### 3. Store-page plugin calls go through `callServerMethod` - -The injected store-page script runs inside an HTTPS page and cannot raw-`fetch` a localhost -backend; mixed-content policy kills it silently. Any new store-page → backend call must go through -`Millennium.callServerMethod("luatools", "", args)` and needs a matching entry in -`CefInjectorService.CallBackendMethod`'s `methodMap`. An unmapped name silently no-ops instead of -erroring. - -`Millennium.callServerMethod` resolves one of two ways depending on the user's setup: under -Millennium it's the framework's own object, and otherwise it's the queue-based polyfill -`CefInjectorService` injects ahead of the plugin script. Either way the actual HTTP request is made -outside the browser, which is what sidesteps the mixed-content block. +## Installation +You can find release builds on the [luatools website](https://lua.tools/app) or in the [releases](https://github.com/madoiscool/LuaTools/releases/latest) tab. -## Related +## Credits / Adjacent software - [Millennium](https://steambrew.app/): the Steam plugin framework whose injection API this app polyfills when Millennium isn't installed From e254e1c67b3f92751b0ae53a95f7a5d925ce3ab1 Mon Sep 17 00:00:00 2001 From: peeblyweeb <74932007+PeeblyWeeb@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:43:10 -0400 Subject: [PATCH 04/29] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ba5a84..d26d7e5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # LuaTools

- + [Discord](https://discord.gg/luatools) • [Website](https://lua.tools) • [Git Mirror](https://git.lua.tools/luatools) @@ -23,7 +23,7 @@ - Windows 10/11 - [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) (the released installer bundles a check for the .NET 8 **Desktop Runtime** and installs it if missing; [building from source](https://github.com/madoiscool/LuaTools/blob/main/CONTRIBUTING.md#building-from-source--developing) needs - the full SDK) + the full SDK ## Installation You can find release builds on the [luatools website](https://lua.tools/app) or in the [releases](https://github.com/madoiscool/LuaTools/releases/latest) tab. From 53abd723fe99cdd03b593b1475026f8491edd78f Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Fri, 14 Aug 2026 02:28:38 +1000 Subject: [PATCH 05/29] Polish wording and localize error messages Clean up punctuation and wording across many files and replace several hard-coded user-facing error/status strings with Resources.Strings localization keys. Minor readability tweaks in comments, logging messages and scripts/check-i18n.py; XAML and VM comments adjusted. These are behavior-neutral edits (no logic changes intended); tests updated to match wording. If any new resource keys were introduced, ensure they exist in Strings.resx and in all language files. --- .gitignore | 2 +- scripts/check-i18n.py | 8 +- src/LuaToolsGui/AppConfig.cs | 18 +-- src/LuaToolsGui/Converters.cs | 6 +- src/LuaToolsGui/MainWindow.xaml.cs | 14 +- src/LuaToolsGui/Models/ApiModels.cs | 10 +- src/LuaToolsGui/Models/FixModels.cs | 6 +- src/LuaToolsGui/Program.cs | 20 +-- src/LuaToolsGui/Services/AnalyticsService.cs | 8 +- .../Services/AppInfo/AppInfoFile.cs | 10 +- src/LuaToolsGui/Services/AppInfo/BinaryVdf.cs | 12 +- .../Services/AppInfo/LaunchModStore.cs | 14 +- .../Services/AppInfo/LaunchOption.cs | 12 +- .../Services/AppInfo/LaunchOptionsService.cs | 16 +-- src/LuaToolsGui/Services/AuthService.cs | 20 +-- src/LuaToolsGui/Services/CacheService.cs | 8 +- .../Services/CefInjectorService.cs | 44 +++--- .../Services/CloudRedirectService.cs | 6 +- src/LuaToolsGui/Services/CoverCache.cs | 6 +- src/LuaToolsGui/Services/DonateKeysService.cs | 6 +- src/LuaToolsGui/Services/GithubProxy.cs | 12 +- .../Services/HardwareAppIdService.cs | 2 +- src/LuaToolsGui/Services/HttpServerService.cs | 16 +-- src/LuaToolsGui/Services/HubcapService.cs | 16 +-- src/LuaToolsGui/Services/LuaEditor.cs | 12 +- src/LuaToolsGui/Services/LuaFileParser.cs | 16 +-- src/LuaToolsGui/Services/LuaInstaller.cs | 34 ++--- src/LuaToolsGui/Services/LuaToolsApiClient.cs | 35 ++--- src/LuaToolsGui/Services/LuaVault.cs | 70 +++++----- src/LuaToolsGui/Services/PluginAddService.cs | 12 +- .../Services/PluginInstallerService.cs | 74 +++++----- .../Services/ProxiedFileDownloader.cs | 2 +- src/LuaToolsGui/Services/SteamAppInfoCache.cs | 28 ++-- src/LuaToolsGui/Services/SteamAppListCache.cs | 4 +- src/LuaToolsGui/Services/SteamDepotInfo.cs | 14 +- .../Services/SteamLibraryService.cs | 2 +- src/LuaToolsGui/Services/SteamLinkParser.cs | 6 +- src/LuaToolsGui/Services/SteamService.cs | 6 +- src/LuaToolsGui/Services/SteamlessService.cs | 6 +- src/LuaToolsGui/Services/ToastService.cs | 6 +- src/LuaToolsGui/Services/UpdateService.cs | 12 +- src/LuaToolsGui/ViewModels/BuildsViewModel.cs | 130 +++++++++--------- .../ViewModels/DownloadViewModel.cs | 40 +++--- .../ViewModels/DropInstallViewModel.cs | 4 +- src/LuaToolsGui/ViewModels/FixesViewModel.cs | 14 +- src/LuaToolsGui/ViewModels/HomeViewModel.cs | 4 +- .../ViewModels/LaunchOptionsViewModel.cs | 18 +-- src/LuaToolsGui/ViewModels/MainViewModel.cs | 2 +- src/LuaToolsGui/ViewModels/ManageViewModel.cs | 46 +++---- .../ViewModels/PagedListViewModel.cs | 14 +- src/LuaToolsGui/ViewModels/PluginViewModel.cs | 10 +- .../ViewModels/SettingsViewModel.cs | 20 +-- src/LuaToolsGui/Views/BuildsView.xaml | 9 +- src/LuaToolsGui/Views/BuildsView.xaml.cs | 2 +- src/LuaToolsGui/Views/ConfirmOverlay.xaml | 2 +- src/LuaToolsGui/Views/DownloadView.xaml.cs | 2 +- src/LuaToolsGui/Views/DropZone.xaml | 3 +- src/LuaToolsGui/Views/DropZone.xaml.cs | 4 +- src/LuaToolsGui/Views/FixesView.xaml | 2 +- src/LuaToolsGui/Views/FixesView.xaml.cs | 2 +- src/LuaToolsGui/Views/ManageView.xaml | 2 +- src/LuaToolsGui/Views/ManageView.xaml.cs | 2 +- src/LuaToolsGui/Views/PluginView.xaml | 2 +- tests/LuaToolsGui.Tests/AppInfoCodecTests.cs | 14 +- .../BuildsActiveHashTests.cs | 4 +- .../LanguageDetectionTests.cs | 8 +- .../LuaToolsGui.Tests/LaunchModStoreTests.cs | 2 +- .../LaunchOptionsServiceTests.cs | 4 +- tests/LuaToolsGui.Tests/LuaBuildTests.cs | 4 +- tests/LuaToolsGui.Tests/LuaEditorTests.cs | 12 +- tests/LuaToolsGui.Tests/LuaVaultTests.cs | 24 ++-- .../SteamDepotInfoCacheTests.cs | 4 +- .../LuaToolsGui.Tests/SteamLinkParserTests.cs | 4 +- 73 files changed, 523 insertions(+), 522 deletions(-) diff --git a/.gitignore b/.gitignore index 7a452b6..3430bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ AGENTS.md *.suo TestResults/ -# Third-party binary reference material — kept locally, never committed. +# Third-party binary reference material. Kept locally, never committed. # These are other projects' compiled artifacts (no redistribution licence) and # nothing in the build references them. OpenSteamTool-releases/ diff --git a/scripts/check-i18n.py b/scripts/check-i18n.py index 9aee689..48d649f 100644 --- a/scripts/check-i18n.py +++ b/scripts/check-i18n.py @@ -31,7 +31,7 @@ PENDING_TRANSLATION: set[str] = set() # Empty on purpose: every key is translated in all 29 languages, so the parity check above is # unconditional. Add a key here ONLY while its feature's UI is still moving, and clear it again -# as soon as the translations land — anything listed is English-only for every user. +# as soon as the translations land. Anything listed is English-only for every user. @@ -60,7 +60,7 @@ def main(): try: tr = parse(path) except ET.ParseError as e: - problems.append(f"{name}: INVALID XML — {e}") + problems.append(f"{name}: INVALID XML. {e}") continue missing = base_keys - set(tr) - PENDING_TRANSLATION @@ -95,8 +95,8 @@ def main(): print(" -", k) stale = PENDING_TRANSLATION - base_keys if stale: - print(f"\nNOTE: {len(stale)} PENDING_TRANSLATION entr(ies) no longer exist in Strings.resx — " - f"drop them from the list: {', '.join(sorted(stale))}") + print(f"\nNOTE: {len(stale)} PENDING_TRANSLATION entr(ies) no longer exist in Strings.resx. " + f"Drop them from the list: {', '.join(sorted(stale))}") return 0 diff --git a/src/LuaToolsGui/AppConfig.cs b/src/LuaToolsGui/AppConfig.cs index c5a8f85..68d06f0 100644 --- a/src/LuaToolsGui/AppConfig.cs +++ b/src/LuaToolsGui/AppConfig.cs @@ -17,7 +17,7 @@ public static class AppConfig // startup lets the app prompt the user to re-link their full lua.tools account. public const string BotAccountEmailDomain = "@bot.lua.tools"; - // Hubcap (hubcapmanifest.com) — the app talks to this directly with the user's own API key + // Hubcap (hubcapmanifest.com): the app talks to this directly with the user's own API key // (no lua.tools proxy). Key + stats are managed in Settings; key-gated source downloads hit it. public const string HubcapBaseUrl = "https://hubcapmanifest.com"; @@ -31,7 +31,7 @@ public static class AppConfig // Public upstream APIs the app calls directly (no lua.tools proxy needed for guest browsing). public const string SteamStoreSearchUrl = "https://store.steampowered.com/api/storesearch/"; - // Steam's storefront "featured categories" (top sellers, new releases, etc.) — drives the Add page's + // Steam's storefront "featured categories" (top sellers, new releases, etc.). Drives the Add page's // featured strips. Public, no auth. public const string SteamFeaturedUrl = "https://store.steampowered.com/api/featuredcategories"; @@ -41,18 +41,18 @@ public static class AppConfig public const string HardwareAppIdListUrl = "https://raw.githubusercontent.com/jsnli/steamappidlist/master/data/hardware_appid.json"; - // Steamless (atom0s) — strips SteamStub DRM from a game's .exe. Downloaded via GithubProxy and + // Steamless (atom0s): strips SteamStub DRM from a game's .exe. Downloaded via GithubProxy and // cached locally; the "Remove Steam DRM" Manage action runs Steamless.CLI.exe against the game's exe. public const string SteamlessRepo = "atom0s/Steamless"; - // CloudRedirect (Selectively11) — the Mode page "Manage" button downloads the latest CloudRedirect.exe + // CloudRedirect (Selectively11): the Mode page "Manage" button downloads the latest CloudRedirect.exe // GUI manager from here and launches it. (Separate from the CLI fixer used by the mode install flow.) public const string CloudRedirectRepo = "Selectively11/CloudRedirect"; public const string ManifestBackendUrl = "http://167.235.229.108"; public const string ManifestBackendUserAgent = "secretgoonpoon"; // The donate-keys endpoint gates on a DIFFERENT User-Agent than the manifest backend (it 403s - // otherwise) — this matches the LuaTools plugin's config.USER_AGENT. + // otherwise). This matches the LuaTools plugin's config.USER_AGENT. public const string DonateKeysUserAgent = "discord(dot)gg/luatools"; // ── Umami analytics (anonymous app-launch counting) ────────────── @@ -63,7 +63,7 @@ public static class AppConfig ///

/// Public GitHub repos hosting Velopack release assets, in priority order. The auto-updater reads its /// feed from the FIRST repo; if that whole repo is unreachable or gone (e.g. banned / DMCA'd / account - /// removed — a failure the GitHub proxy mirrors can't fix, since they'd still point at a dead repo), it + /// removed. A failure the GitHub proxy mirrors can't fix, since they'd still point at a dead repo), it /// falls through to the next. Mirror repos are populated MANUALLY (re-upload the same Velopack assets to /// the backup only if the primary goes down). Each is still individually proxied for blocked regions. /// TODO: set the real primary repo before the first `vpk upload`; add a backup repo URL when one exists. @@ -71,7 +71,7 @@ public static class AppConfig public static readonly string[] GithubReleasesRepos = [ "https://github.com/madoiscool/LuaTools", // primary - "https://github.com/mendy-tools/LuaTools", // backup — create this repo + re-upload the Velopack + "https://github.com/mendy-tools/LuaTools", // backup. Create this repo + re-upload the Velopack // assets ONLY if the primary goes down (404s harmlessly // until then; UpdateService just falls through past it). ]; @@ -89,9 +89,9 @@ public static class AppConfig // ── GitHub proxy mirrors (for blocked/throttled regions, e.g. China) ────────────── // github.com / api.github.com are often unreachable in some countries. Any GitHub request is tried // DIRECT first, then prefixed onto the MATCHING mirrors ("https://") until one works. - // Two capability classes — GithubProxy.Candidates picks by URL so we never make a guaranteed-wasted hop + // Two capability classes: GithubProxy.Candidates picks by URL so we never make a guaranteed-wasted hop // (an API mirror 400s a download; a download mirror 403s the API): - // • API metadata (api.github.com): ONLY our self-hosted lua.tools/gh proxy can serve it — server-side + // • API metadata (api.github.com): ONLY our self-hosted lua.tools/gh proxy can serve it. Server-side // PAT (60→5000/hr) + cache. No PUBLIC proxy serves the REST API (they all 403 it), so there's no // public backup here. Fixes the plugin release-metadata lookup in China / under rate-limit. 404s // harmlessly until the /api/gh route is deployed, then lights up automatically. diff --git a/src/LuaToolsGui/Converters.cs b/src/LuaToolsGui/Converters.cs index a3548e3..5480b48 100644 --- a/src/LuaToolsGui/Converters.cs +++ b/src/LuaToolsGui/Converters.cs @@ -28,7 +28,7 @@ public class ImagePathToSourceConverter : IValueConverter } catch { - return null; // bad path/URL — show nothing + return null; // bad path/URL. Show nothing } } @@ -99,7 +99,7 @@ public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, /// Maps a Manage filter/sort option's stored value (an English key like "Recently added" or /// "Any") to its localized DISPLAY label, leaving the stored value itself unchanged so all the /// switch/equality logic in ManageViewModel keeps working. Unknown values (Steam genres/types/years) -/// pass through verbatim — they're data, not UI chrome. +/// pass through verbatim. They're data, not UI chrome. public class FilterOptionDisplayConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => @@ -116,7 +116,7 @@ public class FilterOptionDisplayConverter : IValueConverter "Metacritic" => Resources.Strings.Manage_Sort_Metacritic, "Most reviewed" => Resources.Strings.Manage_Sort_MostReviewed, "All" => Resources.Strings.Manage_PageSize_All, // page-size dropdown - _ => s, // genre/type/year/page-number data — show as-is + _ => s, // genre/type/year/page-number data. Show as-is } : value; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => diff --git a/src/LuaToolsGui/MainWindow.xaml.cs b/src/LuaToolsGui/MainWindow.xaml.cs index 0f8f6cd..2adf975 100644 --- a/src/LuaToolsGui/MainWindow.xaml.cs +++ b/src/LuaToolsGui/MainWindow.xaml.cs @@ -9,7 +9,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { private readonly SettingsService _settings; private System.Windows.Forms.NotifyIcon? _trayIcon; - private bool _reallyExiting; // true once the user picks tray "Exit" — lets the close go through + private bool _reallyExiting; // true once the user picks tray "Exit". Lets the close go through public MainWindow(MainViewModel viewModel, IServiceProvider services, SettingsService settings) { @@ -27,7 +27,7 @@ public MainWindow(MainViewModel viewModel, IServiceProvider services, SettingsSe { RootNavigation.Navigate(typeof(HomeView)); try { await viewModel.InitializeAsync(); } - catch { /* auth restore failed (e.g. offline) — UI still loads as guest */ } + catch { /* auth restore failed (e.g. offline). UI still loads as guest */ } }; } @@ -40,7 +40,7 @@ private void InitializeTrayIcon() using var stream = Application.GetResourceStream(new Uri("pack://application:,,,/icon.ico"))?.Stream; if (stream is not null) _trayIcon.Icon = new System.Drawing.Icon(stream); } - catch { /* fall back to no icon — tray menu still works */ } + catch { /* fall back to no icon. Tray menu still works */ } _trayIcon.DoubleClick += (_, _) => RestoreFromTray(); @@ -53,7 +53,7 @@ private void InitializeTrayIcon() /// Clicking the window's close (X) button hides to the tray instead of quitting, when the /// MinimizeToTray setting is on OR the app was launched with --tray-locked (the loader passes this to - /// keep the backend alive — session-only, doesn't touch the saved setting). Either way, only the tray + /// keep the backend alive. Session-only, doesn't touch the saved setting). Either way, only the tray /// "Exit" item (which sets _reallyExiting) actually closes the app. private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) { @@ -67,7 +67,7 @@ private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventAr _trayIcon?.Dispose(); // ShutdownMode is OnExplicitShutdown (a silent/--minimized launch never shows a window, so - // OnLastWindowClose would otherwise tear the app down before it does anything) — so a real + // OnLastWindowClose would otherwise tear the app down before it does anything), so a real // window close has to ask for shutdown itself instead of relying on the window count. Application.Current.Shutdown(); } @@ -103,7 +103,7 @@ public void StartSilent() if (_trayIcon is not null) _trayIcon.Visible = true; } - /// Pop a Windows balloon from the tray icon — reports a silent install's outcome. + /// Pop a Windows balloon from the tray icon. Reports a silent install's outcome. public void ShowInstallNotification(string message, bool error) { if (_trayIcon is null) return; @@ -136,7 +136,7 @@ public void ShowInstallNotification(string message, bool error) /// Switch to Mode (used by Home's mode status row). public void NavigateToMode() => RootNavigation.Navigate(typeof(ModeView)); - // "Restart Steam" is an action, not a page — run the command, don't leave it selected. + // "Restart Steam" is an action, not a page: run the command, don't leave it selected. private void RestartSteam_Click(object sender, RoutedEventArgs e) { if (DataContext is MainViewModel vm) vm.RestartSteamCommand.Execute(null); diff --git a/src/LuaToolsGui/Models/ApiModels.cs b/src/LuaToolsGui/Models/ApiModels.cs index 6821524..99cedbd 100644 --- a/src/LuaToolsGui/Models/ApiModels.cs +++ b/src/LuaToolsGui/Models/ApiModels.cs @@ -42,9 +42,9 @@ public class SteamFeaturedItem { [JsonPropertyName("id")] public long Id { get; set; } [JsonPropertyName("name")] public string Name { get; set; } = ""; - // 616×353 capsule — the nice wide art for a featured card. + // 616×353 capsule. The nice wide art for a featured card. [JsonPropertyName("large_capsule_image")] public string? LargeCapsuleImage { get; set; } - [JsonPropertyName("type")] public int Type { get; set; } // 0 = game; non-zero are bundles/etc. — skip + [JsonPropertyName("type")] public int Type { get; set; } // 0 = game; non-zero are bundles/etc., so skip } public class GameDetails @@ -89,7 +89,7 @@ public class DlcInfo [JsonPropertyName("depots")] public List Depots { get; set; } = []; } -/// Hubcap (hubcapmanifest.com) /api/v1/user/stats response — usage for the user's own key. +/// Hubcap (hubcapmanifest.com) /api/v1/user/stats response. Usage for the user's own key. public class HubcapStats { [JsonPropertyName("user_id")] public string UserId { get; set; } = ""; @@ -99,7 +99,7 @@ public class HubcapStats [JsonPropertyName("api_key_expires_at")] public string? ApiKeyExpiresAt { get; set; } } -/// Hubcap /api/v1/status/{appid} response — whether a manifest exists (free, no usage count). +/// Hubcap /api/v1/status/{appid} response. Whether a manifest exists (free, no usage count). public class HubcapManifestStatus { [JsonPropertyName("status")] public string Status { get; set; } = ""; @@ -119,7 +119,7 @@ public class SupporterStatus [JsonPropertyName("isSupporter")] public bool IsSupporter { get; set; } } -/// Response from /api/auth/code/redeem — a Discord bot login code exchanged for a magic-link token. +/// Response from /api/auth/code/redeem. A Discord bot login code exchanged for a magic-link token. public class CodeRedeemResponse { [JsonPropertyName("user_id")] public string UserId { get; set; } = ""; diff --git a/src/LuaToolsGui/Models/FixModels.cs b/src/LuaToolsGui/Models/FixModels.cs index 9443ea2..2921ce0 100644 --- a/src/LuaToolsGui/Models/FixModels.cs +++ b/src/LuaToolsGui/Models/FixModels.cs @@ -2,7 +2,7 @@ namespace LuaToolsGui.Models; -// ── /api/denuvo/listings (public) — the game grid ─────────────────── +// ── /api/denuvo/listings (public). The game grid ─────────────────── public class DenuvoListingsResponse { @@ -27,7 +27,7 @@ public class DenuvoTag [JsonPropertyName("color")] public string? Color { get; set; } } -// ── /api/denuvo/fixes?appid= (public) — per-game fix detail ────────── +// ── /api/denuvo/fixes?appid= (public). Per-game fix detail ────────── public class DenuvoFixesResponse { @@ -50,7 +50,7 @@ public class DenuvoFix [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } } -// ── /api/denuvo/download?fix=&slot= (auth) — returns a signed URL ──── +// ── /api/denuvo/download?fix=&slot= (auth). Returns a signed URL ──── public class DenuvoDownloadResponse { diff --git a/src/LuaToolsGui/Program.cs b/src/LuaToolsGui/Program.cs index 16608bd..eaba064 100644 --- a/src/LuaToolsGui/Program.cs +++ b/src/LuaToolsGui/Program.cs @@ -13,7 +13,7 @@ public static class Program [STAThread] public static void Main(string[] args) { - // MUST run before any WPF/UI work — handles Velopack install/update hooks, + // MUST run before any WPF/UI work: handles Velopack install/update hooks, // then no-ops on a normal launch. VelopackApp.Build().Run(); @@ -57,10 +57,10 @@ public static void Main(string[] args) if (protocolUrl is not null) Services.ProtocolService.WritePending(protocolUrl); - // Already running (possibly hidden in the tray) — poke the live instance to surface its + // Already running (possibly hidden in the tray). Poke the live instance to surface its // window, then bow out. This is what users expect when they relaunch the app. // Exception: a --minimized relaunch explicitly wants to stay silent (this is what our - // Steam DLL hijack passes on every launch — steam.exe loads it into more than one + // Steam DLL hijack passes on every launch: steam.exe loads it into more than one // process/thread per boot, so DllMain and this whole Main() can legitimately run more // than once in the same Steam startup). Don't surface the window for that duplicate. if (!startMinimized) @@ -73,11 +73,11 @@ public static void Main(string[] args) signal.Dispose(); } } - catch { /* best effort — if signalling fails, just exit quietly */ } + catch { /* best effort. If signalling fails, just exit quietly */ } } // A --tray-locked relaunch (the loader passes this) asks the live instance to switch close-to-tray - // on for the session — otherwise, if the app was already open WITHOUT the flag when the loader + // on for the session. Otherwise, if the app was already open WITHOUT the flag when the loader // ran, it would stay killable by a window close (this second process exits without the live one // ever learning about the flag). if (trayLocked) @@ -108,7 +108,7 @@ public static void Main(string[] args) return; } - // First instance — store the URL so App.OnStartup can route it. + // First instance: store the URL so App.OnStartup can route it. StartupUrl = protocolUrl; StartMinimized = startMinimized; SessionTrayLock = trayLocked; @@ -142,7 +142,7 @@ public static void Main(string[] args) internal static EventWaitHandle? EnableTrayLockSignal; /// Named event a --tray-locked second instance (the loader relaunching on Steam-open) sets to - /// ask the running app to re-run its update flow — otherwise an already-running app would never update + /// ask the running app to re-run its update flow. Otherwise an already-running app would never update /// just from the user opening Steam. private const string RecheckUpdatesEventName = "LuaToolsGui.RecheckUpdates"; @@ -157,7 +157,7 @@ public static void Main(string[] args) /// True when launched with --tray-locked (the loader DLL passes this): the window's close /// button minimizes to the tray for THIS session regardless of the saved MinimizeToTray setting, so the - /// backend can't be killed by an accidental window close — only the tray "Exit" item fully quits. This + /// backend can't be killed by an accidental window close. Only the tray "Exit" item fully quits. This /// is in-memory only and never written to settings, so a normal launch keeps the user's real setting. internal static bool SessionTrayLock; @@ -192,7 +192,7 @@ private static void ApplyUiCulture() } catch { - // Never let a culture problem stop the app from launching — fall through to default (English). + // Never let a culture problem stop the app from launching. Fall through to default (English). } } @@ -211,7 +211,7 @@ private static string ResolveLanguageTag() /// Falls back to "en". Internal-visible for tests. internal static string MatchOsLanguage(CultureInfo os) { - // 1. Exact match — handles tags we ship verbatim (e.g. "pt-BR", "pt-PT", "zh-Hant", "es-419"). + // 1. Exact match: handles tags we ship verbatim (e.g. "pt-BR", "pt-PT", "zh-Hant", "es-419"). if (IsSupported(os.Name)) return os.Name; string two = os.TwoLetterISOLanguageName; // "es", "fr", "pt", "zh", "nb"/"nn"... diff --git a/src/LuaToolsGui/Services/AnalyticsService.cs b/src/LuaToolsGui/Services/AnalyticsService.cs index b63226e..66e3973 100644 --- a/src/LuaToolsGui/Services/AnalyticsService.cs +++ b/src/LuaToolsGui/Services/AnalyticsService.cs @@ -7,8 +7,8 @@ namespace LuaToolsGui.Services; /// /// Anonymous app-launch analytics via a self-hosted Umami instance. Posts a single "app_launch" event -/// per launch to Umami's /api/send (the same endpoint the browser script calls). No personal data — -/// just an event name + app version. Best-effort fire-and-forget: never throws, never blocks startup. +/// per launch to Umami's /api/send (the same endpoint the browser script calls). No personal data. +/// Just an event name + app version. Best-effort fire-and-forget: never throws, never blocks startup. /// public class AnalyticsService { @@ -45,7 +45,7 @@ public async Task TrackAppLaunchAsync(CancellationToken ct = default) }; // Umami's bot filter SILENTLY DROPS events from non-standard User-Agents (it still returns // 200 but with a {"beep":"boop"} body and records nothing). A custom "LuaToolsDesktop/x" - // suffix tripped that filter — so we send a plain, standard Windows Chrome UA. The launch is + // suffix tripped that filter, so we send a plain, standard Windows Chrome UA. The launch is // still identifiable in the dashboard via the "app_launch" event name + desktop.lua.tools host. req.Headers.TryAddWithoutValidation( "User-Agent", @@ -55,7 +55,7 @@ public async Task TrackAppLaunchAsync(CancellationToken ct = default) } catch { - // Telemetry must never affect the app — swallow everything. + // Telemetry must never affect the app: swallow everything. } } } diff --git a/src/LuaToolsGui/Services/AppInfo/AppInfoFile.cs b/src/LuaToolsGui/Services/AppInfo/AppInfoFile.cs index 7b3074b..8be3c38 100644 --- a/src/LuaToolsGui/Services/AppInfo/AppInfoFile.cs +++ b/src/LuaToolsGui/Services/AppInfo/AppInfoFile.cs @@ -4,7 +4,7 @@ namespace LuaToolsGui.Services.AppInfo; -/// Thrown when appinfo.vdf isn't a format we understand — a Steam-side version bump. +/// Thrown when appinfo.vdf isn't a format we understand. A Steam-side version bump. public class AppInfoFormatException(string message) : Exception(message); /// Where one app's record sits in the file, and its parsed metadata. @@ -20,7 +20,7 @@ public sealed record AppInfoEntry(int AppId, long Offset, int Size, uint ChangeN /// [v29] string table at the header offset: count u32, then NUL-terminated UTF-8 /// /// -/// size counts the bytes AFTER the size field — i.e. meta + blob. The meta block is 60 bytes on +/// size counts the bytes AFTER the size field, i.e. meta + blob. The meta block is 60 bytes on /// v28+ (40 on v27, which has no binary SHA-1): /// infoState(4) lastUpdated(4) picsToken(8) sha1_text(20) changeNumber(4) sha1_binary(20). /// @@ -56,7 +56,7 @@ public sealed class AppInfoFile : IDisposable /// /// /// A few table entries aren't valid UTF-8. Decoding them to a string and re-encoding turns each bad - /// byte into U+FFFD — three bytes instead of one — so a no-edit rebuild came out 12 bytes larger. + /// byte into U+FFFD (three bytes instead of one), so a no-edit rebuild came out 12 bytes larger. /// Keeping the original bytes makes the rebuild exact; only newly interned keys are encoded. /// /// @@ -98,7 +98,7 @@ private void ReadStringTable(long offset) { byte[] raw = BinaryVdf.ReadCStringBytes(_file); _stringBytes.Add(raw); - Strings.Add(Encoding.UTF8.GetString(raw)); // lookup/display only — raw is what's written + Strings.Add(Encoding.UTF8.GetString(raw)); // lookup/display only. Raw is what's written } } @@ -184,7 +184,7 @@ uint Intern(string key) BinaryVdf.Write(blob, replacement, Intern, IndexedKeys); byte[] bytes = blob.ToArray(); - // sha1_binary is exactly SHA1(blob) — verified against 3,000 apps and against + // sha1_binary is exactly SHA1(blob). Verified against 3,000 apps and against // SteamEdit's own writer. Leaving it stale would ship a hash of the pre-edit bytes. // sha1_text is NOT reproducible from the blob (it hashes Steam's own text rendering), // so it and changeNumber are carried over untouched. diff --git a/src/LuaToolsGui/Services/AppInfo/BinaryVdf.cs b/src/LuaToolsGui/Services/AppInfo/BinaryVdf.cs index fae8a27..ff8af6c 100644 --- a/src/LuaToolsGui/Services/AppInfo/BinaryVdf.cs +++ b/src/LuaToolsGui/Services/AppInfo/BinaryVdf.cs @@ -31,7 +31,7 @@ public sealed class VdfProperty(string name, VdfType type, object value) public VdfTable? AsTable() => Value as VdfTable; - /// Decode a string value for display. Lossy for the rare non-UTF-8 blob — which is exactly + /// Decode a string value for display. Lossy for the rare non-UTF-8 blob, which is exactly /// why the raw bytes are what's stored, so an untouched value still writes back unchanged. public string AsText() => Value is byte[] b ? Encoding.UTF8.GetString(b) : Value?.ToString() ?? ""; @@ -45,7 +45,7 @@ public static VdfProperty Text(string name, string value) => /// A binary-VDF object: an ORDERED LIST of properties, deliberately not a dictionary. /// /// -/// The format permits the SAME KEY TWICE inside one object — 15 of the 176,869 apps in a real +/// The format permits the SAME KEY TWICE inside one object. 15 of the 176,869 apps in a real /// appinfo.vdf do it (app 46830 lists /silent twice). A dictionary silently collapses those, /// which shortens the re-serialized blob and corrupts the file. Order also has to be preserved exactly, /// since the bytes are hashed. @@ -71,8 +71,8 @@ public VdfTable EnsureTable(string name) return table; } - /// Set a string value, or remove the key entirely when is empty — - /// real launch entries omit unused optionals rather than storing "". + /// Set a string value, or remove the key entirely when is empty. + /// Real launch entries omit unused optionals rather than storing "". public void SetText(string name, string? value) { if (string.IsNullOrEmpty(value)) { Remove(name); return; } @@ -87,7 +87,7 @@ public void Remove(string name) Items.RemoveAt(i); } - /// Deep copy — used to snapshot a launch table before it's edited. + /// Deep copy. Used to snapshot a launch table before it's edited. public VdfTable Clone() { var copy = new VdfTable(); @@ -195,7 +195,7 @@ public static byte[] ReadExactly(Stream s, int count) /// /// Read a NUL-terminated string as RAW BYTES. Not decoded: a handful of old apps (17390, 42990, /// 96800…) carry text that isn't valid UTF-8, and decoding with replacement turns each bad byte - /// into U+FFFD — which re-encodes to three bytes and silently changes the blob's length. + /// into U+FFFD, which re-encodes to three bytes and silently changes the blob's length. /// public static byte[] ReadCStringBytes(Stream s) { diff --git a/src/LuaToolsGui/Services/AppInfo/LaunchModStore.cs b/src/LuaToolsGui/Services/AppInfo/LaunchModStore.cs index fc48c92..b1a8883 100644 --- a/src/LuaToolsGui/Services/AppInfo/LaunchModStore.cs +++ b/src/LuaToolsGui/Services/AppInfo/LaunchModStore.cs @@ -12,7 +12,7 @@ public sealed class LaunchMod /// one, the app has been updated and the snapshot is re-based rather than trusted. public uint ChangeNumber { get; set; } - /// The app's launch entries BEFORE we touched them. A whole snapshot, not a diff — a diff + /// The app's launch entries BEFORE we touched them. A whole snapshot, not a diff. A diff /// against a file Steam rewrites at will is worthless. public List Original { get; set; } = []; @@ -26,7 +26,7 @@ public sealed class LaunchMod /// Persists launch-option edits so they survive Steam rewriting appinfo.vdf. /// /// -/// Steam regenerates appinfo from PICS on login, app updates and store browsing — it rewrote the file +/// Steam regenerates appinfo from PICS on login, app updates and store browsing. It rewrote the file /// twice during development. A one-shot edit therefore silently disappears. This mirrors what SteamEdit /// does with its mods.dat: keep the desired state plus a pre-edit snapshot in our own file, and /// re-apply when the live appinfo no longer matches. @@ -63,8 +63,8 @@ internal LaunchModStore(string path) lock (_gate) return _mods.GetValueOrDefault(appId); } - /// Record an edit. The snapshot is only captured the FIRST time a game is modified — - /// re-saving must not overwrite the original with our own previous edit. + /// Record an edit. The snapshot is only captured the FIRST time a game is modified. + /// Re-saving must not overwrite the original with our own previous edit. public void Save(int appId, uint changeNumber, IReadOnlyList current, IReadOnlyList desired) { @@ -97,7 +97,7 @@ public void Remove(int appId) } /// - /// Re-base a stored snapshot when Steam has updated the app since it was taken — SteamEdit's trick. + /// Re-base a stored snapshot when Steam has updated the app since it was taken. SteamEdit's trick. /// Without this, "restore original" would write back launch entries from a version of the game that /// no longer exists. /// @@ -134,7 +134,7 @@ private void Load() } } - /// Atomic write (tmp → move) with a .bak, same as SettingsService — a torn file here loses + /// Atomic write (tmp → move) with a .bak, same as SettingsService. A torn file here loses /// every recorded edit AND the snapshots needed to undo them. private void Persist() { @@ -159,7 +159,7 @@ private void Persist() } } - /// True when two entry lists differ in any field we manage — used to detect that Steam has + /// True when two entry lists differ in any field we manage. Used to detect that Steam has /// overwritten our edits. public static bool Differs(IReadOnlyList a, IReadOnlyList b) { diff --git a/src/LuaToolsGui/Services/AppInfo/LaunchOption.cs b/src/LuaToolsGui/Services/AppInfo/LaunchOption.cs index 1bfde6f..55da6ad 100644 --- a/src/LuaToolsGui/Services/AppInfo/LaunchOption.cs +++ b/src/LuaToolsGui/Services/AppInfo/LaunchOption.cs @@ -3,7 +3,7 @@ namespace LuaToolsGui.Services.AppInfo; /// -/// One entry behind Steam's Play button — config.launch.<n> in appinfo. +/// One entry behind Steam's Play button: config.launch.<n> in appinfo. /// /// /// Field frequency across 64,117 real entries: executable 100%, config 91% @@ -21,8 +21,8 @@ public sealed class LaunchOption /// /// The key this entry was READ from, which stays put when is renumbered. - /// Needed because looks the old table up to carry across keys we don't model - /// — after a reorder, looking it up by the NEW index would graft a different entry's extra keys + /// Needed because looks the old table up to carry across keys we don't model. + /// After a reorder, looking it up by the NEW index would graft a different entry's extra keys /// (e.g. Half-Life's per-entry vacmodulefilename) onto this one. Empty for entries we created. /// public string SourceIndex { get; set; } = ""; @@ -72,7 +72,7 @@ public static LaunchOption FromTable(string index, VdfTable table) /// /// Apply this entry onto a VDF table, preserving any keys we don't model (some entries carry - /// vacmodulefilename, description_loc, …) — rewriting from scratch would drop them. + /// vacmodulefilename, description_loc, …). Rewriting from scratch would drop them. /// public VdfTable ToTable(VdfTable? existing = null) { @@ -95,7 +95,7 @@ public VdfTable ToTable(VdfTable? existing = null) } else { - // Only drop `config` if we're the ones who emptied it — an entry that never had one stays + // Only drop `config` if we're the ones who emptied it. An entry that never had one stays // without one, and one whose only config keys we don't model keeps them. if (table.GetTable("config") is { } cfg && cfg.Items.Count == 0) table.Remove("config"); } @@ -139,7 +139,7 @@ public static void WriteAll(VdfTable appBody, IReadOnlyList option config.Items.Add(VdfProperty.Table("launch", launch)); } - /// Renumber to 0,1,2… in list order — what makes a reorder actually take effect. + /// Renumber to 0,1,2… in list order. What makes a reorder actually take effect. public static void Renumber(IReadOnlyList options) { for (int i = 0; i < options.Count; i++) options[i].Index = i.ToString(); diff --git a/src/LuaToolsGui/Services/AppInfo/LaunchOptionsService.cs b/src/LuaToolsGui/Services/AppInfo/LaunchOptionsService.cs index 055ef50..cfdde62 100644 --- a/src/LuaToolsGui/Services/AppInfo/LaunchOptionsService.cs +++ b/src/LuaToolsGui/Services/AppInfo/LaunchOptionsService.cs @@ -41,7 +41,7 @@ public class LaunchOptionsService public LaunchOptionsService(SteamService steam, LaunchModStore store) : this(() => steam.EffectivePath is { } p ? Path.Combine(p, "appcache", "appinfo.vdf") : null, SteamService.IsSteamRunning, steam.StopSteam, () => steam.StartSteam(), store, - // Backups live NEXT TO appinfo.vdf, on Steam's own drive — deliberately not %AppData%. + // Backups live NEXT TO appinfo.vdf, on Steam's own drive. Deliberately not %AppData%. // The file is ~373 MB and the system drive is often the tightest on space (this dev // machine had 874 MB free), so parking copies there would fill it in two applies. Same // drive also makes the copy a local one rather than a cross-volume transfer. @@ -87,7 +87,7 @@ internal LaunchOptionsService(Func appInfoPath, Func isSteamRunni uint changeNumber = file.Entries[appId].ChangeNumber; // If Steam updated the app since we snapshotted it, the snapshot describes a version that no - // longer exists — refresh it from what's live now. + // longer exists. Refresh it from what's live now. _store.Rebase(appId, changeNumber, current); return new LaunchState(appId, changeNumber, current, @@ -105,7 +105,7 @@ public void Stage(int appId, uint changeNumber, public IReadOnlyList? StageRestore(int appId) => _store.Get(appId)?.Original; /// - /// Re-apply the staged edits for these apps — what the drift notice's "Re-apply" button runs, given + /// Re-apply the staged edits for these apps: what the drift notice's "Re-apply" button runs, given /// the ids from . Ids no longer in the store are skipped rather than /// failing the batch: between the drift check and the user clicking, an app can have been restored. /// @@ -122,7 +122,7 @@ public ApplyResult Reapply(IReadOnlyList appIds, bool restartSteam = true) /// Write every staged edit into appinfo.vdf. /// /// - /// Steam MUST be closed first — it holds the file and rewrites it from its own in-memory state, so + /// Steam MUST be closed first: it holds the file and rewrites it from its own in-memory state, so /// writing underneath it is pointless. This mirrors SteamEdit, which kills Steam, writes, and /// relaunches. controls whether it's brought back up. /// @@ -148,7 +148,7 @@ public ApplyResult Apply(IReadOnlyDictionary> e { var root = file.ReadApp(appId); var body = root?.GetTable("appinfo"); - if (root is null || body is null) continue; // app vanished from appinfo — skip it + if (root is null || body is null) continue; // app vanished from appinfo. Skip it LaunchOption.WriteAll(body, options); replacements[appId] = root; } @@ -174,7 +174,7 @@ public ApplyResult Apply(IReadOnlyDictionary> e } /// - /// Which staged edits no longer match what's in appinfo — i.e. Steam has overwritten them. Runs off + /// Which staged edits no longer match what's in appinfo, i.e. Steam has overwritten them. Runs off /// the UI thread; returns an empty list when nothing is staged so the common case costs nothing. /// public IReadOnlyList FindDrifted() @@ -196,7 +196,7 @@ public IReadOnlyList FindDrifted() } catch { - return []; // unreadable/locked cache — nothing actionable to report + return []; // unreadable/locked cache. Nothing actionable to report } } @@ -230,7 +230,7 @@ private static void Backup(string path, string backupDir) } catch { - // A failed backup shouldn't block the edit — the .tmp swap is the real safety net. + // A failed backup shouldn't block the edit: the .tmp swap is the real safety net. } } } diff --git a/src/LuaToolsGui/Services/AuthService.cs b/src/LuaToolsGui/Services/AuthService.cs index 6cc2efd..bc7668e 100644 --- a/src/LuaToolsGui/Services/AuthService.cs +++ b/src/LuaToolsGui/Services/AuthService.cs @@ -71,7 +71,7 @@ public async Task InitializeAsync() } catch { - ClearSession(); // revoked/expired — fall back to guest + ClearSession(); // revoked/expired. Fall back to guest AuthStateChanged?.Invoke(); return false; } @@ -185,7 +185,7 @@ private static async Task WaitForCallbackAsync(HttpListener listener, Ca } catch (OperationCanceledException) { - throw new AuthException("Sign-in timed out — no response from the browser."); + throw new AuthException(Resources.Strings.Auth_Err_Timeout); } string? code = HttpUtility.ParseQueryString(ctx.Request.Url?.Query ?? "").Get("code"); @@ -193,7 +193,7 @@ private static async Task WaitForCallbackAsync(HttpListener listener, Ca if (code is null && error is null) { - // Favicon or stray request — ignore and keep waiting + // Favicon or stray request: ignore and keep waiting ctx.Response.StatusCode = 404; ctx.Response.Close(); continue; @@ -224,10 +224,10 @@ private async Task ExchangeCodeAsync(string code, string verifi var res = await _http.SendAsync(req, ct); string body = await res.Content.ReadAsStringAsync(ct); if (!res.IsSuccessStatusCode) - throw new AuthException($"Token exchange failed ({(int)res.StatusCode}): {body}"); + throw new AuthException(string.Format(Resources.Strings.Auth_Err_TokenExchangeFailed, (int)res.StatusCode, body)); return JsonSerializer.Deserialize(body) - ?? throw new AuthException("Token exchange returned an empty session."); + ?? throw new AuthException(Resources.Strings.Auth_Err_TokenExchangeEmpty); } // ── Token access / refresh ────────────────────────────────────── @@ -235,7 +235,7 @@ private async Task ExchangeCodeAsync(string code, string verifi /// Returns a valid access token, refreshing first when close to expiry. Throws for guests. public async Task GetValidAccessTokenAsync() { - if (_refreshToken is null) throw new AuthException("Not signed in."); + if (_refreshToken is null) throw new AuthException(Resources.Strings.Auth_Err_NotSignedIn); if (_expiresAt <= DateTimeOffset.UtcNow.AddMinutes(2)) { @@ -266,10 +266,10 @@ private async Task RefreshAsync() var res = await _http.SendAsync(req); string body = await res.Content.ReadAsStringAsync(); if (!res.IsSuccessStatusCode) - throw new AuthException($"Session refresh failed ({(int)res.StatusCode})."); + throw new AuthException(string.Format(Resources.Strings.Auth_Err_RefreshFailed, (int)res.StatusCode)); var session = JsonSerializer.Deserialize(body) - ?? throw new AuthException("Session refresh returned an empty session."); + ?? throw new AuthException(Resources.Strings.Auth_Err_RefreshEmpty); ApplySession(session); } @@ -337,7 +337,7 @@ private static void SaveStored(StoredAuth auth) JsonSerializer.SerializeToUtf8Bytes(auth), null, DataProtectionScope.CurrentUser); // The token file can be momentarily locked (another instance, AV, indexer). A failed - // write must never break sign-in: the session is already live in memory — persisting is + // write must never break sign-in: the session is already live in memory. Persisting is // a convenience for next launch. Retry briefly, then give up silently. for (int attempt = 0; ; attempt++) { @@ -352,7 +352,7 @@ private static void SaveStored(StoredAuth auth) } catch { - return; // locked/denied — stay signed in for this session, just don't persist + return; // locked/denied. Stay signed in for this session, just don't persist } } } diff --git a/src/LuaToolsGui/Services/CacheService.cs b/src/LuaToolsGui/Services/CacheService.cs index cbf2a71..3fbc686 100644 --- a/src/LuaToolsGui/Services/CacheService.cs +++ b/src/LuaToolsGui/Services/CacheService.cs @@ -3,7 +3,7 @@ namespace LuaToolsGui.Services; -/// App-tracked bookkeeping (not user settings) — install fingerprints, cached digests, etc. +/// App-tracked bookkeeping (not user settings). Install fingerprints, cached digests, etc. public class CacheData { // ── OpenSteamTools install fingerprint (Mode page up-to-date check) ── @@ -17,7 +17,7 @@ public class CacheData // ── Donate Keys dedup (permanent) ──────────────────────────────── // Appids whose decryption keys have already been donated. The backend accepts each depot only - // once per IP, so this is permanent — a donated appid is never re-sent. + // once per IP, so this is permanent. A donated appid is never re-sent. public List DonatedAppIds { get; set; } = []; // ── Hardware appid blacklist (refreshed from GitHub, ~14-day TTL) ── @@ -32,12 +32,12 @@ public class CacheData // ── First-run onboarding ───────────────────────────────────────── // True once the user has seen (and dismissed) the welcome overlay, or the app decided they're - // already set up. Kept in cache (not settings) — it's app bookkeeping, not a user preference. + // already set up. Kept in cache (not settings). It's app bookkeeping, not a user preference. public bool OnboardingComplete { get; set; } } /// -/// Persists internal app state to %AppData%\LuaToolsGui\cache.json — distinct from user-facing +/// Persists internal app state to %AppData%\LuaToolsGui\cache.json. Distinct from user-facing /// settings (SettingsService). Use this for values the app records about itself (versions/hashes /// of installed components, cached lookups), not for choices the user makes. /// diff --git a/src/LuaToolsGui/Services/CefInjectorService.cs b/src/LuaToolsGui/Services/CefInjectorService.cs index 9aaf23a..bf8b7bb 100644 --- a/src/LuaToolsGui/Services/CefInjectorService.cs +++ b/src/LuaToolsGui/Services/CefInjectorService.cs @@ -25,11 +25,11 @@ public class CefInjectorService : IHostedService private int _cdpId; // CDP is opened by the `.cef-enable-remote-debugging` junction PluginInstallerService manages, not by - // the loader DLL anymore — and Steam's own internal logic hardcodes this port. Confirmed this can't be + // the loader DLL anymore, and Steam's own internal logic hardcodes this port. Confirmed this can't be // changed (file content has no effect on it), so unlike the old hook-based design (which deliberately - // picked the uncommon 42067 to dodge collisions), this is stuck with 8080 — a commonly-occupied port + // picked the uncommon 42067 to dodge collisions), this is stuck with 8080. A commonly-occupied port // (dev servers, Docker, etc.). PluginInstallerService.IsPort8080BusyAsync() surfaces a best-effort - // warning for that case (probing /json to rule out Steam's own CDP server before warning — a bare bind + // warning for that case (probing /json to rule out Steam's own CDP server before warning. A bare bind // test can't tell "someone else has it" from "Steam has it and it's working correctly"); there is no // fallback port to switch to. private const string CefDebugUrl = "http://127.0.0.1:8080/json"; @@ -54,11 +54,11 @@ public async Task StartAsync(CancellationToken cancellationToken) } /// (Re)reads luatools.js + the polyfill from disk into memory. Called once at startup, and - /// again by PluginInstallerService right after install/uninstall — the file-finding paths get checked + /// again by PluginInstallerService right after install/uninstall. The file-finding paths get checked /// exactly once otherwise, so a plugin installed/updated/removed after launch would silently never take /// effect until the app was restarted (the app is commonly launched by the loader BEFORE anything is - /// installed, so this isn't just a theoretical edge case). _luatoolsJs/_polyfillJs are plain strings — - /// reassignment is atomic, so this is safe to call while InjectionLoop is concurrently reading them; the + /// installed, so this isn't just a theoretical edge case). _luatoolsJs/_polyfillJs are plain strings. + /// Reassignment is atomic, so this is safe to call while InjectionLoop is concurrently reading them; the /// next poll cycle (~1s) just picks up the new content, no page reload needed. public async Task ReloadPluginFilesAsync() { @@ -97,7 +97,7 @@ public Task StopAsync(CancellationToken cancellationToken) } // Loop cadence: tick fast (drain queued RPCs every tick so the page's calls resolve in ~150ms rather - // than the old ~1s), but only refresh the tab list + re-inject on every Nth tick (~1s) — injection + // than the old ~1s), but only refresh the tab list + re-inject on every Nth tick (~1s). Injection // doesn't need to be fast, and the liveness/re-inject logic is unchanged, just decoupled from the drain. private const int TickMs = 150; private const int InjectEveryTicks = 7; // 7 * 150ms ≈ 1s @@ -115,8 +115,8 @@ private async Task InjectionLoop(CancellationToken ct) // ── Slow cadence (~1s): discover store tabs, ensure luatools.js is injected ── if (tick % InjectEveryTicks == 0) { - // Millennium may also be present and running its own plugin loader on this same page — - // that's fine now. The polyfill (BuildInlinePolyfill) only takes over "luatools" calls and + // Millennium may also be present and running its own plugin loader on this same page. + // That's fine now. The polyfill (BuildInlinePolyfill) only takes over "luatools" calls and // passes everything else through to Millennium's real window.Millennium if one exists, so // the two no longer need to be mutually exclusive. var tabsJson = await _http.GetStringAsync(CefDebugUrl, ct); @@ -126,7 +126,7 @@ private async Task InjectionLoop(CancellationToken ct) // Inject luatools.js into every store-page tab whose JS context isn't already alive. // Steam reuses the same CDP tab ID across SPA-style navigation (Home <-> store pages), - // but navigating to a different page wipes the JS context entirely — so "already + // but navigating to a different page wipes the JS context entirely, so "already // injected by tab ID" is the wrong signal. Check liveness in the CURRENT context every // cycle instead (window.__LuaToolsReady, set as the last statement of luatools.js's // main IIFE) and re-inject whenever it's gone. @@ -230,11 +230,11 @@ private async Task CallBackendMethod(string method, string argsRaw) ["StartLuaToolsAdd"] = ("POST", "/add/{appid}"), ["GetLuaToolsAddStatus"] = ("GET", "/add-status/{appid}"), ["PickLuaToolsAddSource"] = ("POST", "/add-source/{appid}"), - // Menu actions (Settings, Fixes, Restart Steam) — same mixed-content problem, same fix. + // Menu actions (Settings, Fixes, Restart Steam). Same mixed-content problem, same fix. ["OpenSettings"] = ("POST", "/open/settings"), ["OpenFix"] = ("POST", "/open/fix/{appid}"), ["RestartSteam"] = ("POST", "/restart-steam"), - // Open an external URL (Discord link, etc.) and check-for-updates — also route + // Open an external URL (Discord link, etc.), and check-for-updates. Also route // through the bridge (previously used a dead direct-fetch to a proxy port). ["OpenExternalUrl"] = ("POST", "/open-url"), ["CheckForUpdatesNow"] = ("POST", "/check-updates"), @@ -278,14 +278,14 @@ private async Task CallBackendMethod(string method, string argsRaw) } } - /// Return the reused (or freshly opened) CDP socket for a tab. Throws if the connect fails — - /// the caller evicts on any exception. + /// Return the reused (or freshly opened) CDP socket for a tab. Throws if the connect fails. + /// The caller evicts on any exception. private async Task GetSocketAsync(string tabId, string wsUrl, CancellationToken ct) { if (_sockets.TryGetValue(tabId, out var existing)) { if (existing.State == WebSocketState.Open) return existing; - EvictSocket(tabId); // stale (closed/aborted) — drop and reopen below + EvictSocket(tabId); // stale (closed/aborted). Drop and reopen below } var ws = new ClientWebSocket(); @@ -321,7 +321,7 @@ private async Task EvaluateReturnAsync(string tabId, string wsUrl, strin // Each CDP message is one WS message (possibly multi-frame). Read whole messages until we get // the one carrying our id. We never enable any CDP domain, so in practice the only traffic is - // our own responses — the id check is just defensive against any stray event frames. + // our own responses. The id check is just defensive against any stray event frames. while (!linked.Token.IsCancellationRequested) { var sb = new StringBuilder(); @@ -336,10 +336,10 @@ private async Task EvaluateReturnAsync(string tabId, string wsUrl, strin var responseText = sb.ToString(); JsonElement root; try { using var doc = JsonDocument.Parse(responseText); root = doc.RootElement.Clone(); } - catch { continue; } // unparseable frame — keep reading + catch { continue; } // unparseable frame. Keep reading if (!(root.TryGetProperty("id", out var idEl) && idEl.TryGetInt32(out var rid) && rid == id)) - continue; // an event or a different id — not our response yet + continue; // an event or a different id, not our response yet if (root.TryGetProperty("result", out var cdpResult) && cdpResult.TryGetProperty("result", out var evalResult) && @@ -373,7 +373,7 @@ private async Task EvaluateAsync(string tabId, string wsUrl, string script, Canc private string? FindLuaToolsJs() { string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - // The frontend is no longer bundled with the app — PluginInstallerService downloads it from + // The frontend is no longer bundled with the app. PluginInstallerService downloads it from // GitHub releases into %AppData%\LuaToolsGui\plugin. If it isn't installed yet, nothing injects. string[] candidates = { @@ -398,13 +398,13 @@ private async Task EvaluateAsync(string tabId, string wsUrl, string script, Canc } /// Real Millennium (if present on this page) already defines window.Millennium.callServerMethod - /// as one shared object used by every Millennium plugin, routed server-side by the pluginName argument — - /// it is NOT per-plugin namespaced. Blindly overwriting it (the old behavior) broke every other + /// as one shared object used by every Millennium plugin, routed server-side by the pluginName argument. + /// It is NOT per-plugin namespaced. Blindly overwriting it (the old behavior) broke every other /// Millennium plugin on the page whenever both injectors were active, not just LuaTools. This only takes /// over "luatools" calls and passes every other pluginName through to the real callServerMethod /// unchanged, so this app's CDP injection can coexist with Millennium instead of requiring it be absent. /// _pending/_readyResponses are attached directly onto whichever object ends up as window.Millennium - /// (fake or real) under their original names — ProcessSingleTab's polling reads/writes those regardless + /// (fake or real) under their original names. ProcessSingleTab's polling reads/writes those regardless /// of which case this is. private string BuildInlinePolyfill() { diff --git a/src/LuaToolsGui/Services/CloudRedirectService.cs b/src/LuaToolsGui/Services/CloudRedirectService.cs index ead23f9..247eeb3 100644 --- a/src/LuaToolsGui/Services/CloudRedirectService.cs +++ b/src/LuaToolsGui/Services/CloudRedirectService.cs @@ -6,7 +6,7 @@ namespace LuaToolsGui.Services; /// -/// Downloads + launches CloudRedirect.exe — the user-facing CloudRedirect GUI manager (Selectively11). The +/// Downloads + launches CloudRedirect.exe. The user-facing CloudRedirect GUI manager (Selectively11). The /// exe is fetched once from the latest GitHub release (via , so it works in blocked /// regions) and cached under %AppData%\LuaToolsGui\cloudredirect. It self-updates, so we always grab the /// latest asset and don't track versions. The Mode page's "Manage" button (shown when CloudRedirect is the @@ -43,7 +43,7 @@ public class CloudRedirectService(GithubProxy gh) if (asset is null) return null; Directory.CreateDirectory(ToolDir); - await gh.DownloadAsync(asset.DownloadUrl, ExePath, progress, ct); // single exe — no zip to extract + await gh.DownloadAsync(asset.DownloadUrl, ExePath, progress, ct); // single exe, no zip to extract return File.Exists(ExePath) ? ExePath : null; } @@ -52,7 +52,7 @@ public class CloudRedirectService(GithubProxy gh) finally { _gate.Release(); } } - /// Download (if needed) and launch the CloudRedirect GUI. Fire-and-forget — its window opens and + /// Download (if needed), and launch the CloudRedirect GUI. Fire-and-forget. Its window opens and /// we don't wait for it. Returns false if the tool couldn't be downloaded or started. public async Task LaunchAsync(IProgress? progress, CancellationToken ct = default) { diff --git a/src/LuaToolsGui/Services/CoverCache.cs b/src/LuaToolsGui/Services/CoverCache.cs index 1d8fb9d..098366a 100644 --- a/src/LuaToolsGui/Services/CoverCache.cs +++ b/src/LuaToolsGui/Services/CoverCache.cs @@ -15,7 +15,7 @@ public class CoverCache Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "covers"); // Reject only truncated/empty bodies. We don't gate on a size threshold: some legit covers are - // tiny (e.g. Undertale 391540's header.jpg is ~5 KB — mostly black, highly compressible), and a + // tiny (e.g. Undertale 391540's header.jpg is ~5 KB. Mostly black, highly compressible), and a // size gate wrongly rejected them. Validity is decided by JPEG magic bytes instead (see IsJpeg). private const int MinValidBytes = 512; @@ -96,7 +96,7 @@ private static bool IsHeaderCapsulePlaceholder(byte[] b) // A 404 throws (caught below); a real cover is a JPEG. Reject error/HTML bodies that // slip through with a 200 by checking the magic bytes, not the size. if (bytes.Length < MinValidBytes || !IsJpeg(bytes)) return null; - // The predictable CDN URL can serve Steam's grey placeholder (a valid JPEG) — reject it so + // The predictable CDN URL can serve Steam's grey placeholder (a valid JPEG). Reject it so // the caller falls back to the real appdetails header_image. if (IsHeaderCapsulePlaceholder(bytes)) return null; @@ -114,7 +114,7 @@ private static bool IsHeaderCapsulePlaceholder(byte[] b) } catch { - return null; // 404 / offline / write error — caller decides on fallback + return null; // 404 / offline / write error: caller decides on fallback } finally { diff --git a/src/LuaToolsGui/Services/DonateKeysService.cs b/src/LuaToolsGui/Services/DonateKeysService.cs index 87fea07..af9c6db 100644 --- a/src/LuaToolsGui/Services/DonateKeysService.cs +++ b/src/LuaToolsGui/Services/DonateKeysService.cs @@ -38,7 +38,7 @@ public async Task SendPendingKeysIfEnabledAsync(CancellationToken ct = default) var pairs = ExtractKeys(File.ReadAllText(configPath)); if (pairs.Count == 0) return; - // Dedup against everything we've ever donated — the backend accepts a depot only once per + // Dedup against everything we've ever donated: the backend accepts a depot only once per // IP, so re-sending is wasted. Only genuinely-new keys (newly installed games) get sent. var donated = new HashSet(cache.GetDonatedAppIds()); var fresh = pairs.Where(p => !donated.Contains(p.appid)).ToList(); @@ -60,7 +60,7 @@ public async Task SendPendingKeysIfEnabledAsync(CancellationToken ct = default) } catch { - // Background task — never surface errors. Unsent keys retry on the next launch. + // Background task, never surface errors. Unsent keys retry on the next launch. } } @@ -88,7 +88,7 @@ void Walk(Dictionary node) } else { - Walk(child); // keep descending — DecryptionKey nests deep under depots/ + Walk(child); // keep descending. DecryptionKey nests deep under depots/ } } } diff --git a/src/LuaToolsGui/Services/GithubProxy.cs b/src/LuaToolsGui/Services/GithubProxy.cs index 4d80a18..90beea5 100644 --- a/src/LuaToolsGui/Services/GithubProxy.cs +++ b/src/LuaToolsGui/Services/GithubProxy.cs @@ -5,12 +5,12 @@ namespace LuaToolsGui.Services; /// /// Makes GitHub requests resilient in regions where github.com / api.github.com are blocked or throttled -/// (e.g. China). Every GitHub request — API JSON or a release-asset binary — is tried DIRECT first, and +/// (e.g. China). Every GitHub request (API JSON or a release-asset binary) is tried DIRECT first, and /// on failure (network error or non-success) re-tried through each capability-matched mirror (see /// / ) by prefixing /// the full GitHub URL: "https://github.com/...". The first usable response wins. /// -/// USE THIS FOR ALL GITHUB REQUESTS. Don't call api.github.com / github.com directly — route the URL +/// USE THIS FOR ALL GITHUB REQUESTS. Don't call api.github.com / github.com directly. Route the URL /// through (API/metadata) or (asset binaries) so the /// mirror fallback applies everywhere automatically. /// @@ -28,12 +28,12 @@ private static bool IsGithub(string url) => /// Candidate URLs to try in order: the direct URL, then each CAPABILITY-MATCHED mirror-prefixed /// variant. api.github.com URLs get the API mirror(s); everything else (github.com downloads, raw, - /// objects) gets the download mirror(s) — never the wrong class, which would be a guaranteed-wasted hop + /// objects) gets the download mirror(s), never the wrong class, which would be a guaranteed-wasted hop /// (an API mirror 400s a download; a download mirror 403s the API). Public so the Velopack auto-update /// downloader can reuse the same direct→mirror fallback. public static IEnumerable Candidates(string url) { - yield return url; // direct first — fastest when GitHub is reachable + yield return url; // direct first. Fastest when GitHub is reachable if (!IsGithub(url)) yield break; var mirrors = url.StartsWith("https://api.github.com/", StringComparison.OrdinalIgnoreCase) ? AppConfig.GithubApiMirrors @@ -64,11 +64,11 @@ public static IEnumerable Candidates(string url) } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - throw; // user cancellation, not a mirror failure — don't keep trying + throw; // user cancellation, not a mirror failure. Don't keep trying } catch { - // network error for this candidate — fall through to the next mirror + // network error for this candidate. Fall through to the next mirror } } return lastResponse; // null if every attempt threw; else the last failed response diff --git a/src/LuaToolsGui/Services/HardwareAppIdService.cs b/src/LuaToolsGui/Services/HardwareAppIdService.cs index 28152ac..1c9f6a2 100644 --- a/src/LuaToolsGui/Services/HardwareAppIdService.cs +++ b/src/LuaToolsGui/Services/HardwareAppIdService.cs @@ -34,7 +34,7 @@ public HardwareAppIdService(GithubProxy gh, CacheService cache) public bool IsBlacklisted(long appId) => _ids.Contains(appId); /// Ensure the blacklist is reasonably fresh: refetch from GitHub if the cache is stale or empty, - /// else no-op. Runs at most once per session. Never throws — on failure the seeded/stale set stays. + /// else no-op. Runs at most once per session. Never throws. On failure the seeded/stale set stays. public Task EnsureFreshAsync() => _loadTask ??= RefreshIfStaleAsync(); private async Task RefreshIfStaleAsync() diff --git a/src/LuaToolsGui/Services/HttpServerService.cs b/src/LuaToolsGui/Services/HttpServerService.cs index f2a55de..e307c0e 100644 --- a/src/LuaToolsGui/Services/HttpServerService.cs +++ b/src/LuaToolsGui/Services/HttpServerService.cs @@ -95,7 +95,7 @@ private void LoadApiSources() } } } - _log.LogWarning("api.json not found — using fallback sources"); + _log.LogWarning("api.json not found, using fallback sources"); _apiSources = new() { new("Ryuu", "http://167.235.229.108/", 200), @@ -111,7 +111,7 @@ public Task StartAsync(CancellationToken cancellationToken) try { _listener.Start(); } catch (HttpListenerException) { - _log.LogWarning("HttpListener could not start on 127.0.0.1:6767 — attempting netsh reservation"); + _log.LogWarning("HttpListener could not start on 127.0.0.1:6767, attempting netsh reservation"); try { var psi = new System.Diagnostics.ProcessStartInfo("netsh", "http add urlacl url=http://127.0.0.1:6767/ user=Everyone") @@ -173,7 +173,7 @@ private async Task HandleRequest(HttpListenerContext ctx) PluginLog.Log($"HTTP {req.HttpMethod} {path}"); (int status, string body) = path switch { - // Answer CORS preflight FIRST — otherwise it matches a POST route (the + // Answer CORS preflight FIRST. Otherwise it matches a POST route (the // matchers ignore method) and returns non-2xx, so the browser blocks the // real request (this is why JSON POSTs like /add-source did nothing). _ when req.HttpMethod == "OPTIONS" => (204, ""), @@ -243,7 +243,7 @@ private static bool MatchPost(string? path, string pattern, out string id) => // ── Steam-plugin add: drive + reflect the real DownloadViewModel ── - /// Trigger the fully headless add (PluginAddService — dynamic sources, Hubcap, key-gating, + /// Trigger the fully headless add (PluginAddService. Dynamic sources, Hubcap, key-gating, /// usage, FastFetch auto-download). Uses services only; the app window is never touched. private async Task<(int, string)> HandleAdd(long appId, HttpListenerRequest req) { @@ -325,7 +325,7 @@ private static bool MatchPost(string? path, string pattern, out string id) => private async Task<(int, string)> HandleCheckSources(long appId) { // Dynamic source list from the app's real manifest backend (same call the app's - // DownloadViewModel uses). Sources have no per-source URL — downloads go through + // DownloadViewModel uses). Sources have no per-source URL. Downloads go through // the app's authenticated proxy by source NAME (see HandleDownload). try { @@ -400,7 +400,7 @@ private static bool MatchPost(string? path, string pattern, out string id) => { state.Cts?.Cancel(); state.Status = "cancelled"; - state.Error = "Cancelled by user"; + state.Error = Resources.Strings.Err_CancelledByUser; _downloads[appId] = state; return (200, Json(new { success = true })); } @@ -519,7 +519,7 @@ private static bool MatchPost(string? path, string pattern, out string id) => { var ids = _cache.GetLoadedAppIds(); // Resolve appid → game name so the plugin's "Added Games" popup shows names, not just numbers - // (it renders item.name || item.appid). Names are best-effort — a missing one falls back to the id. + // (it renders item.name || item.appid). Names are best-effort. A missing one falls back to the id. var names = _services.GetRequiredService(); try { await names.EnsureLoadedAsync(); } catch { /* offline / not cached yet → ids only */ } var apps = ids.Select(id => new { appid = id, name = names.GetName(id) }).ToList(); @@ -620,7 +620,7 @@ private async Task DownloadAndInstallAsync(long appId, string source, Cancellati catch (OperationCanceledException) { state.Status = "cancelled"; - state.Error = "Cancelled by user"; + state.Error = Resources.Strings.Err_CancelledByUser; } catch (Exception ex) { diff --git a/src/LuaToolsGui/Services/HubcapService.cs b/src/LuaToolsGui/Services/HubcapService.cs index 26701dc..d318360 100644 --- a/src/LuaToolsGui/Services/HubcapService.cs +++ b/src/LuaToolsGui/Services/HubcapService.cs @@ -9,9 +9,9 @@ namespace LuaToolsGui.Services; /// -/// Talks to Hubcap (hubcapmanifest.com) DIRECTLY with the user's own API key — no lua.tools proxy. +/// Talks to Hubcap (hubcapmanifest.com) DIRECTLY with the user's own API key, no lua.tools proxy. /// Stats and manifest downloads authenticate via ?api_key={key}; the free status check uses a -/// Bearer header (per the Hubcap API). Stats/status calls never throw — they return null on any +/// Bearer header (per the Hubcap API). Stats/status calls never throw. They return null on any /// failure so the UI degrades gracefully; only the explicit download surfaces errors to the caller. /// public partial class HubcapService @@ -33,10 +33,10 @@ public partial class HubcapService [GeneratedRegex("^smm_[0-9a-f]{96}$")] private static partial Regex KeyFormatRegex(); - /// Local format check — Hubcap keys are "smm_" followed by 96 lowercase hex chars. + /// Local format check. Hubcap keys are "smm_" followed by 96 lowercase hex chars. public static bool IsValidKeyFormat(string? key) => key is not null && KeyFormatRegex().IsMatch(key); - /// Usage stats for a key. Null on any network/auth failure — never throws. + /// Usage stats for a key. Null on any network/auth failure, never throws. public async Task GetStatsAsync(string key, CancellationToken ct = default) { try @@ -73,10 +73,10 @@ public async Task DownloadManifestAsync( { string message = res.StatusCode switch { - HttpStatusCode.Unauthorized => "Your Hubcap key is invalid or expired.", - HttpStatusCode.TooManyRequests => "Your Hubcap daily limit has been reached.", - HttpStatusCode.NotFound => "No Hubcap manifest is available for this app.", - _ => $"Hubcap download failed ({(int)res.StatusCode}).", + HttpStatusCode.Unauthorized => Resources.Strings.Hubcap_Err_InvalidKey, + HttpStatusCode.TooManyRequests => Resources.Strings.Hubcap_Err_LimitReached, + HttpStatusCode.NotFound => Resources.Strings.Hubcap_Err_NoManifest, + _ => string.Format(Resources.Strings.Hubcap_Err_DownloadFailed, (int)res.StatusCode), }; throw new ApiException(message, res.StatusCode); } diff --git a/src/LuaToolsGui/Services/LuaEditor.cs b/src/LuaToolsGui/Services/LuaEditor.cs index 3cb751c..9070413 100644 --- a/src/LuaToolsGui/Services/LuaEditor.cs +++ b/src/LuaToolsGui/Services/LuaEditor.cs @@ -6,13 +6,13 @@ namespace LuaToolsGui.Services; /// Toggles individual depot lines in a lua's TEXT, for the Builds page's per-depot switches. /// /// -/// Both switches work by commenting a line in or out — that's the only mechanism the lua format has, +/// Both switches work by commenting a line in or out. That's the only mechanism the lua format has, /// and it's exactly what "Auto Update Apps" already does to manifest pins: /// /// -/// Lock — setManifestid(depot, "…"). Active = pinned to that manifest. Commented out = +/// Lock. setManifestid(depot, "…"). Active = pinned to that manifest. Commented out = /// Steam is free to update the depot. -/// Enable — addappid(depot, 1, "key"). Active = the decryption key applies. Commented +/// Enable. addappid(depot, 1, "key"). Active = the decryption key applies. Commented /// out = the depot isn't unlocked at all. /// /// @@ -27,12 +27,12 @@ public static class LuaEditor // preserving the original indentation. private const string CommentPrefix = @"^(?\s*)(?--\s*)?"; - /// Matches setManifestid(<depot>, … — commented or not. + /// Matches setManifestid(<depot>, …. Commented or not. private static Regex PinLine(long depotId) => new(CommentPrefix + @"(?setManifestid\s*\(\s*" + depotId + @"\s*[,)])", RegexOptions.IgnoreCase); - /// Matches addappid(<depot>… — commented or not, keyed or bare. + /// Matches addappid(<depot>…. Commented or not, keyed or bare. private static Regex AddAppIdLine(long depotId) => new(CommentPrefix + @"(?addappid\s*\(\s*" + depotId + @"\s*[,)])", RegexOptions.IgnoreCase); @@ -48,7 +48,7 @@ public static string SetDepotEnabled(string lua, long depotId, bool enabled) => /// /// Comment in/out every line matching . An id can legitimately appear on more /// than one line (e.g. a bare addappid(id) plus a keyed addappid(id, 1, "key")), and - /// leaving one of them behind would half-apply the toggle — so all matches are rewritten. + /// leaving one of them behind would half-apply the toggle, so all matches are rewritten. /// private static string Rewrite(string lua, Regex line, bool active) { diff --git a/src/LuaToolsGui/Services/LuaFileParser.cs b/src/LuaToolsGui/Services/LuaFileParser.cs index ec375a1..291e6c3 100644 --- a/src/LuaToolsGui/Services/LuaFileParser.cs +++ b/src/LuaToolsGui/Services/LuaFileParser.cs @@ -7,7 +7,7 @@ namespace LuaToolsGui.Services; /// One depot/app entry declared in a lua file via addappid / setManifestid. /// /// is an ACTIVE version pin. is a -/// pin that exists in the file but is commented out — which is the normal state after an install with +/// pin that exists in the file but is commented out, which is the normal state after an install with /// the "Auto Update Apps" setting on (see ). The two must stay distinct: a /// commented pin means "Steam keeps this updated", not "pinned to this manifest". /// @@ -17,7 +17,7 @@ public record LuaEntry(long Id, bool HasKey, string? ManifestId, string? Comment /// Parsed contents of a stplug-in lua file. /// /// is what the lua ACTIVELY declares. are -/// ids whose addappid line exists but is commented out — switched off from the Builds page. They're kept +/// ids whose addappid line exists but is commented out. Switched off from the Builds page. They're kept /// out of Entries so the install-time before/after diff still means "what this lua unlocks", but the /// Builds page needs them to keep showing a switched-off depot (otherwise disabling one would make its /// row vanish from "In lua", taking the switch with it). @@ -33,7 +33,7 @@ public record LuaContents( public int DepotCount => Entries.Count(e => e.HasKey); public int DlcCount => Entries.Count(e => !e.HasKey && e.Id != BaseAppId); - /// True when this lua pins at least one depot to a fixed manifest — i.e. it's locked to a + /// True when this lua pins at least one depot to a fixed manifest, i.e. it's locked to a /// specific build rather than tracking whatever Steam ships. public bool HasActivePins => ActivePins.Count > 0; } @@ -71,7 +71,7 @@ public static partial class LuaFileParser { if (string.IsNullOrWhiteSpace(raw)) return null; string s = raw.Trim(); - // Reject commented-out code (e.g. "setManifestid(...)", "addappid(...)") — not a human name. + // Reject commented-out code (e.g. "setManifestid(...)", "addappid(...)"), not a human name. if (s.Contains("setManifestid", StringComparison.OrdinalIgnoreCase) || s.Contains("addappid", StringComparison.OrdinalIgnoreCase) || s.Contains("addtoken", StringComparison.OrdinalIgnoreCase)) @@ -100,7 +100,7 @@ public static partial class LuaFileParser // distinct (a multiline regex with \s* was collapsing the whole file into one match). foreach (string rawLine in text.Split('\n')) { - // Trim both ends — a trailing '\r' (from CRLF files) would break the $-anchored regex. + // Trim both ends: a trailing '\r' (from CRLF files) would break the $-anchored regex. string line = rawLine.Trim(); bool commented = line.StartsWith("--"); @@ -111,14 +111,14 @@ public static partial class LuaFileParser if (pin.Success && long.TryParse(pin.Groups[1].Value, out long depot)) (commented ? commentedManifests : manifests)[depot] = pin.Groups[2].Value; - // A commented-out addappid is a DISABLED declaration, not an active one — matched against + // A commented-out addappid is a DISABLED declaration, not an active one. Matched against // the line with its "--" stripped so it can still be recognised and reported separately. var m = AddAppIdRegex().Match(commented ? line.TrimStart('-', ' ') : line); if (!m.Success || !long.TryParse(m.Groups[1].Value, out long id)) continue; bool hasKey = m.Groups[2].Success && !string.IsNullOrEmpty(m.Groups[2].Value); - // Keep the best (longest) trailing comment seen for this id — it's the human name + // Keep the best (longest) trailing comment seen for this id. It's the human name // ('addappid(2784471, …) -- Depot 2784471'). Captured for commented-out lines too, and // BEFORE the disabled branch below returns: switching a depot off must not also strip its // name off the row, leaving a bare "Depot". @@ -161,7 +161,7 @@ public static partial class LuaFileParser } catch { - return null; // unreadable / malformed — caller shows "couldn't read" + return null; // unreadable / malformed. Caller shows "couldn't read" } } diff --git a/src/LuaToolsGui/Services/LuaInstaller.cs b/src/LuaToolsGui/Services/LuaInstaller.cs index 7ea3721..e4801d4 100644 --- a/src/LuaToolsGui/Services/LuaInstaller.cs +++ b/src/LuaToolsGui/Services/LuaInstaller.cs @@ -23,7 +23,7 @@ public partial class LuaInstaller(SteamService steam, SettingsService settings, { private bool AutoUpdate => settings.AutoUpdateApps; - /// Raised (with the appid) whenever a lua is successfully installed — via any path (plugin, + /// Raised (with the appid) whenever a lua is successfully installed, via any path (plugin, /// drag-drop, Add page, Fixes). The UI subscribes to refresh the library live. May fire on a /// background thread, so handlers must marshal to the UI thread. public event Action? Installed; @@ -34,7 +34,7 @@ public partial class LuaInstaller(SteamService steam, SettingsService settings, /// /// The Steam build id a lua file name declares (<appid>_<buildid>.lua), or null for a plain - /// <appid>.lua. This is the ONLY place build identity comes from — the file name, whether that's a + /// <appid>.lua. This is the ONLY place build identity comes from. The file name, whether that's a /// staged download (which keeps the server's Content-Disposition name) or an entry inside a zip. /// public static string? BuildIdFromFileName(string path) @@ -45,18 +45,18 @@ public partial class LuaInstaller(SteamService steam, SettingsService settings, /// /// A lua named <appid>_<buildid>.lua must keep its manifest pins, even with "Auto Update Apps" - /// on. Those pins ARE the build — installing it de-pinned would hand the user whatever version Steam + /// on. Those pins ARE the build. Installing it de-pinned would hand the user whatever version Steam /// ships today while calling it the build they picked. Same reasoning as a Denuvo fix's forceLocked. /// private static bool KeepPinsFor(string? buildId, bool forceLocked) => forceLocked || buildId is not null; /// /// Store what was just written to stplug-in. Called with the INSTALLED file (not the staged source) so - /// the stored bytes are exactly what Steam reads — which is what makes this variant resolve as the + /// the stored bytes are exactly what Steam reads, which is what makes this variant resolve as the /// active one. /// /// - /// A build lua (<appid>_<buildid>.lua) is captured as its own variant — those + /// A build lua (<appid>_<buildid>.lua) is captured as its own variant. Those /// accumulate, that's the point of the switcher. A plain lua instead re-points the single Default slot /// via : capturing it would append a second "default" row /// every time a game was re-added from a different generator, which is exactly the bug this replaced. @@ -71,12 +71,12 @@ private void CaptureInstalled(long appId, string installedPath, string? buildId, else vault.SyncDefaultFromLive(appId); } - catch { /* the vault is a convenience — a hiccup here must never fail an install */ } + catch { /* the vault is a convenience. A hiccup here must never fail an install */ } } /// Record a just-installed appid in the "recently added" list (cache.json's LoadedAppIds), so - /// the store-page plugin can surface it in the "games added since last Steam restart" popup. Best-effort - /// — a caching hiccup must never fail an install. Also fires so the app's own + /// the store-page plugin can surface it in the "games added since last Steam restart" popup. Best-effort. + /// A caching hiccup must never fail an install. Also fires so the app's own /// Home/Manage library views refresh (this is the single chokepoint every lua install passes through). private void RecordLoaded(long appId) { @@ -86,7 +86,7 @@ private void RecordLoaded(long appId) catch { /* a subscriber blowing up must never fail an install */ } } - // setManifestid(depot, "manifestid", ...) — pins a depot to a fixed version. Commenting it out + // setManifestid(depot, "manifestid", ...). Pins a depot to a fixed version. Commenting it out // (and skipping the .manifest) lets Steam fetch the latest, so the app auto-updates. [GeneratedRegex(@"^(\s*)(setManifestid\s*\()", RegexOptions.IgnoreCase)] private static partial Regex SetManifestLineRegex(); @@ -138,7 +138,7 @@ private void WriteLua(string sourceLuaPath, string dest, bool forceLocked = fals public InstallResult InstallLua(string luaPath, long appId, bool forceLocked = false, string? source = null) { string? dir = steam.StPlugInDir; - if (dir is null) return InstallResult.Fail("Steam location not found — set it in Settings."); + if (dir is null) return InstallResult.Fail(Resources.Strings.Err_SteamNotFound); try { @@ -170,7 +170,7 @@ private static void StampNow(string path) File.SetCreationTime(path, now); File.SetLastWriteTime(path, now); } - catch { /* timestamp is cosmetic — never fail an install over it */ } + catch { /* timestamp is cosmetic, never fail an install over it */ } } /// @@ -222,7 +222,7 @@ public static bool IsInstallable(string path) AppIdFromFileName(entry.Name) is { } id) return id; } - catch { /* unreadable zip — caller handles null */ } + catch { /* unreadable zip. Caller handles null */ } return null; } @@ -237,7 +237,7 @@ public InstallResult InstallLuaFile(string luaPath, long appId, bool forceLocked public InstallResult InstallManifestFile(string manifestPath) { string? dir = steam.DepotCacheDir; - if (dir is null) return InstallResult.Fail("Steam location not found — set it in Settings."); + if (dir is null) return InstallResult.Fail(Resources.Strings.Err_SteamNotFound); try { Directory.CreateDirectory(dir); @@ -259,7 +259,7 @@ public InstallResult InstallManifestFile(string manifestPath) /// /// Extract a manifest zip into Steam: the .lua → stplug-in (renamed to <appid>.lua), - /// every *.manifest → depotcache. Zips may carry no manifests — that's fine. Best-effort. + /// every *.manifest → depotcache. Zips may carry no manifests. That's fine. Best-effort. /// keeps manifest pins (for Denuvo fixes). /// public InstallResult InstallZip(string zipPath, long appId, bool forceLocked = false, string? source = null) @@ -267,11 +267,11 @@ public InstallResult InstallZip(string zipPath, long appId, bool forceLocked = f string? plugDir = steam.StPlugInDir; string? depotDir = steam.DepotCacheDir; if (plugDir is null || depotDir is null) - return InstallResult.Fail("Steam location not found — set it in Settings."); + return InstallResult.Fail(Resources.Strings.Err_SteamNotFound); ZipArchive archive; try { archive = ZipFile.OpenRead(zipPath); } - catch (Exception ex) { return InstallResult.Fail($"Couldn't open the download: {ex.Message}"); } + catch (Exception ex) { return InstallResult.Fail(string.Format(Resources.Strings.Err_OpenDownloadFailed, ex.Message)); } bool luaInstalled = false; int manifestCount = 0; @@ -297,7 +297,7 @@ public InstallResult InstallZip(string zipPath, long appId, bool forceLocked = f : Path.Combine(depotDir, name); // Manifest filenames are content-addressed (the id is a hash of the content), so an - // existing one is byte-identical — skip it. Avoids needless work and, importantly, the + // existing one is byte-identical. Skip it. Avoids needless work and, importantly, the // "file in use" failure when Steam is running and already has that manifest open. if (isManifest && File.Exists(dest)) { diff --git a/src/LuaToolsGui/Services/LuaToolsApiClient.cs b/src/LuaToolsGui/Services/LuaToolsApiClient.cs index 01695c4..dc9e31d 100644 --- a/src/LuaToolsGui/Services/LuaToolsApiClient.cs +++ b/src/LuaToolsGui/Services/LuaToolsApiClient.cs @@ -34,7 +34,7 @@ public class LuaToolsApiClient(AuthService auth, SteamAppInfoCache appInfo, Cove public async Task> SearchAsync(string query, CancellationToken ct = default) { - // Calls Steam's public store-search directly (no lua.tools, no auth) — guests can search. + // Calls Steam's public store-search directly (no lua.tools, no auth). Guests can search. var url = $"{AppConfig.SteamStoreSearchUrl}?term={Uri.EscapeDataString(query)}&l=english&cc=US"; var res = await _http.GetAsync(url, ct); if (!res.IsSuccessStatusCode) return []; @@ -64,7 +64,7 @@ public async Task> SearchAsync(string query, Cancellatio var data = await ReadJsonAsync(res, ct); // Steam's featuredcategories genuinely repeats appids within a list (e.g. top_sellers returns - // the same game 2–3×), so DistinctBy the appid — keeps the first, preserving Steam's order. + // the same game 2–3×), so DistinctBy the appid. Keeps the first, preserving Steam's order. static List Clean(SteamFeaturedCategory? c) => (c?.Items ?? []) .Where(i => i.Type == 0 && i.Id > 0 && !string.IsNullOrEmpty(i.LargeCapsuleImage)) @@ -77,9 +77,9 @@ static List Clean(SteamFeaturedCategory? c) => catch { return ([], []); } } - /// Public endpoint — no auth required. + /// Public endpoint, no auth required. /// Game metadata straight from Steam's appdetails (cached to details\<appid>.json via the - /// throttle, interactive priority) — no lua.tools proxy. ANY fetch path funnels through here (normal / + /// throttle, interactive priority), no lua.tools proxy. ANY fetch path funnels through here (normal / /// DLC / fast / plugin add), so this is also where the header image gets warmed into covers\. public async Task GetDetailsAsync(string appid, CancellationToken ct = default) { @@ -93,7 +93,7 @@ static List Clean(SteamFeaturedCategory? c) => /// Source name → "available" | "unavailable" | other status. public async Task> CheckSourcesAsync(string appid, CancellationToken ct = default) { - // Calls the manifest backend directly (no lua.tools, no auth) — the backend is gated + // Calls the manifest backend directly (no lua.tools, no auth). The backend is gated // by a fixed User-Agent rather than a token, so guests can check availability. var req = new HttpRequestMessage(HttpMethod.Get, $"{AppConfig.ManifestBackendUrl}/check_apis?appid={appid}"); req.Headers.TryAddWithoutValidation("User-Agent", AppConfig.ManifestBackendUserAgent); @@ -104,7 +104,7 @@ public async Task> CheckSourcesAsync(string appid, Ca /// /// The standard lua.tools daily download usage (25/day), counted live from the user_downloads - /// table via Supabase REST — the same source the website reads. RLS scopes it to the signed-in + /// table via Supabase REST. The same source the website reads. RLS scopes it to the signed-in /// user, so no user id is needed. Null on failure / not signed in. /// public async Task GetStandardUsageAsync(CancellationToken ct = default) @@ -124,7 +124,7 @@ public async Task> CheckSourcesAsync(string appid, Ca using var res = await _http.SendAsync(req, ct); if (!res.IsSuccessStatusCode) return null; - // Content-Range: "0-24/25" (or "*/0" when empty) — the count is after the slash. + // Content-Range: "0-24/25" (or "*/0" when empty). The count is after the slash. string? range = res.Content.Headers.TryGetValues("Content-Range", out var v) ? v.FirstOrDefault() : (res.Headers.TryGetValues("Content-Range", out var hv) ? hv.FirstOrDefault() : null); int used = 0; @@ -134,7 +134,7 @@ public async Task> CheckSourcesAsync(string appid, Ca } catch { - return null; // decorative — never block on it + return null; // decorative, never block on it } } @@ -172,7 +172,7 @@ public Task GenerateDlcAsync( // ── Denuvo fixes ──────────────────────────────────────────────── - /// Public — every game that has at least one Denuvo fix, plus the tag catalogue. + /// Public. Every game that has at least one Denuvo fix, plus the tag catalogue. public async Task GetDenuvoListingsAsync(CancellationToken ct = default) { var res = await _http.GetAsync("/api/denuvo/listings", ct); @@ -180,7 +180,7 @@ public Task GenerateDlcAsync( return await ReadJsonAsync(res, ct); } - /// Public — one game's fixes (id/title/desc/tags + which download slots exist). + /// Public. One game's fixes (id/title/desc/tags + which download slots exist). public async Task GetDenuvoFixesAsync(string appid, CancellationToken ct = default) { var res = await _http.GetAsync($"/api/denuvo/fixes?appid={Uri.EscapeDataString(appid)}", ct); @@ -189,7 +189,7 @@ public Task GenerateDlcAsync( } /// - /// Auth — download a fix's "manifest" or "fix" slot. The endpoint returns a short-lived signed + /// Auth: download a fix's "manifest" or "fix" slot. The endpoint returns a short-lived signed /// R2 URL (counts toward 25/day); we then fetch the file from that URL. Caller must be signed in. /// public async Task DownloadDenuvoAsync( @@ -200,9 +200,9 @@ public async Task DownloadDenuvoAsync( $"/api/denuvo/download?fix={Uri.EscapeDataString(fixId)}&slot={Uri.EscapeDataString(slot)}", ct); var signed = await ReadJsonAsync(res, ct); if (string.IsNullOrWhiteSpace(signed?.Url)) - throw new ApiException("The download link was empty — try again."); + throw new ApiException(Resources.Strings.Api_Err_EmptyDownloadLink); - // 2. Fetch the file from R2 (no auth header — the signed URL carries its own credentials). + // 2. Fetch the file from R2 (no auth header: the signed URL carries its own credentials). return await DownloadFromUrlAsync(signed.Url, fallbackName, progress, ct); } @@ -219,15 +219,18 @@ private async Task SendAsync( var res = await _http.SendAsync(req, completion, ct); if (res.IsSuccessStatusCode) return res; - string message = $"Request failed ({(int)res.StatusCode})"; + string message = string.Format(Resources.Strings.Api_Err_RequestFailed, (int)res.StatusCode); try { var err = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); + // Deliberately NOT localized: this text comes from the lua.tools API, which serves English + // only. There is no key to look up, and a server message is more specific than our generic + // fallback, so it wins. The fallback above and the 401 case below are the localizable parts. if (!string.IsNullOrWhiteSpace(err?.Error)) message = err.Error; } catch { /* non-JSON error body */ } - if (res.StatusCode == HttpStatusCode.Unauthorized) message = "Session expired — please sign in again."; + if (res.StatusCode == HttpStatusCode.Unauthorized) message = Resources.Strings.Api_Err_SessionExpired; throw new ApiException(message, res.StatusCode); } @@ -249,7 +252,7 @@ private async Task DownloadFromUrlAsync( var req = new HttpRequestMessage(HttpMethod.Get, url); var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); if (!res.IsSuccessStatusCode) - throw new ApiException($"Download failed ({(int)res.StatusCode}).", res.StatusCode); + throw new ApiException(string.Format(Resources.Strings.Api_Err_DownloadFailed, (int)res.StatusCode), res.StatusCode); return await SaveResponseAsync(res, fallbackName, progress, ct); } diff --git a/src/LuaToolsGui/Services/LuaVault.cs b/src/LuaToolsGui/Services/LuaVault.cs index 2d151cc..34937bf 100644 --- a/src/LuaToolsGui/Services/LuaVault.cs +++ b/src/LuaToolsGui/Services/LuaVault.cs @@ -6,13 +6,13 @@ namespace LuaToolsGui.Services; -/// How a stored lua came to be. Stored VALUES are English keys used in switch/equality — -/// never localize them (the display label is localized separately). +/// How a stored lua came to be. Stored VALUES are English keys used in switch/equality. +/// Never localize them (the display label is localized separately). public static class LuaVariantKind { /// A plain <appid>.lua with no build identity (what everyone had before builds existed). public const string Default = "default"; - /// Came from a <appid>_<buildid>.lua — pinned to one Steam build. + /// Came from a <appid>_<buildid>.lua. Pinned to one Steam build. public const string Build = "build"; /// Hand-edited / saved by the user from the Builds page editor. public const string Preset = "preset"; @@ -20,7 +20,7 @@ public static class LuaVariantKind /// /// One stored lua for a game. is the SHA-256 of the file's bytes AND its filename in -/// the vault — so "which variant is live right now" is answered by hashing Steam's copy, never by a +/// the vault, so "which variant is live right now" is answered by hashing Steam's copy, never by a /// stored flag that could drift out of sync. /// public record LuaVariant( @@ -59,8 +59,8 @@ internal class VaultIndex /// /// The variant the live lua was last based on, recorded the moment an edit made it diverge. This is - /// what an in-place Save overwrites. Persisted (not just held in memory) so closing the page — or the - /// app — doesn't turn "Save" into "Save as new" behind the user's back. Null when the live lua + /// what an in-place Save overwrites. Persisted (not just held in memory), so closing the page, or the + /// app. Doesn't turn "Save" into "Save as new" behind the user's back. Null when the live lua /// matches a stored variant, i.e. there's nothing pending. /// public string? EditBaseHash { get; set; } @@ -71,13 +71,13 @@ internal class VaultIndex /// though Steam only ever reads ONE file per game (config\stplug-in\<appid>.lua). /// /// -/// Layout — files are content-addressed (the name IS the sha256 of the bytes): +/// Layout: files are content-addressed (the name IS the sha256 of the bytes): /// /// %AppData%\LuaToolsGui\luavault\<appid>\index.json /// %AppData%\LuaToolsGui\luavault\<appid>\<sha256>.lua /// -/// That gives dedupe for free, and makes a pure function of what's on disk -/// — so it stays correct even when Steam, another tool, or the user overwrites the live file behind +/// That gives dedupe for free, and makes a pure function of what's on disk, +/// so it stays correct even when Steam, another tool, or the user overwrites the live file behind /// our back. index.json holds only metadata; the bytes are always the source of truth. /// /// @@ -93,7 +93,7 @@ public LuaVault(SteamService steam) : this(() => steam.StPlugInDir, DefaultRoot) /// /// Test seam: point the vault at throwaway directories instead of %AppData% and the real Steam - /// install. Worth having — this class rewrites the files Steam loads, so "it destroys your luas" is + /// install. Worth having. This class rewrites the files Steam loads, so "it destroys your luas" is /// the failure mode, and that's not something to only find out by hand. /// internal LuaVault(Func stPlugInDir, string root) @@ -113,7 +113,7 @@ internal LuaVault(Func stPlugInDir, string root) private readonly object _gate = new(); /// Raised (with the appid) after any change to a game's variants, so the Builds page can - /// refresh. May fire on a background thread — handlers must marshal to the UI thread. + /// refresh. May fire on a background thread. Handlers must marshal to the UI thread. public event Action? VaultChanged; private string AppDir(long appId) => Path.Combine(_root, appId.ToString()); @@ -135,7 +135,7 @@ public IReadOnlyList GetVariants(long appId) /// /// The hash of the lua Steam is actually using right now, or null if there's no live file. Compare - /// against to find the active variant — a hash matching NOTHING means + /// against to find the active variant. A hash matching NOTHING means /// the live file has changed since the last (a fresh install, a /// hand edit, another tool), and that sync is what adopts those bytes as the Default. /// @@ -146,7 +146,7 @@ public IReadOnlyList GetVariants(long appId) } /// - /// Cheap "has this game been captured at all" check — a directory probe, no index parse. The game + /// Cheap "has this game been captured at all" check. A directory probe, no index parse. The game /// list calls this per game on every filter pass, so it must not read/hash anything. /// public bool HasVariants(long appId) => Directory.Exists(AppDir(appId)); @@ -160,7 +160,7 @@ public IReadOnlyList GetVariants(long appId) /// /// Loose <appid>_<buildid>.lua files sitting directly in stplug-in. Steam only ever reads - /// <appid>.lua, so these are INERT where they are — dropping one in the folder does nothing until + /// <appid>.lua, so these are INERT where they are. Dropping one in the folder does nothing until /// it's applied. Surfacing them lets the Builds page offer builds the user already has on disk. /// public IEnumerable<(long AppId, string BuildId, string Path)> EnumerateLooseBuildLuas() @@ -176,7 +176,7 @@ public IReadOnlyList GetVariants(long appId) } } - /// Store any loose build-named luas for this app as build variants (idempotent — the + /// Store any loose build-named luas for this app as build variants (idempotent. The /// content-addressed capture no-ops once they're already in). public void AdoptLooseBuildLuas(long appId) { @@ -188,15 +188,15 @@ public void AdoptLooseBuildLuas(long appId) /// /// Make a stored build live when the game has NO <appid>.lua at all, and return it. /// - /// Dropping <appid>_<buildid>.lua into stplug-in does nothing on its own — Steam only reads - /// <appid>.lua — so a game in that state is loading nothing, and applying the build is plainly what + /// Dropping <appid>_<buildid>.lua into stplug-in does nothing on its own. Steam only reads + /// <appid>.lua, so a game in that state is loading nothing, and applying the build is plainly what /// putting the file there meant. Deliberately does NOTHING when a live lua already exists: silently /// swapping which version of a game the user runs is not a decision to make on their behalf. /// /// public LuaVariant? ApplyBuildIfNothingLive(long appId) { - if (GetActiveHash(appId) is not null) return null; // something is already live — leave it alone + if (GetActiveHash(appId) is not null) return null; // something is already live. Leave it alone var build = GetVariants(appId).FirstOrDefault(v => v.BuildId is not null); // newest first if (build is null) return null; @@ -291,28 +291,28 @@ public string ReadLiveText(long appId) SaveIndex(appId, index); } - // Fired outside the lock — a handler that reads back (the Builds page does) must not be able + // Fired outside the lock: a handler that reads back (the Builds page does) must not be able // to re-enter the index mid-write. if (changed) VaultChanged?.Invoke(appId); return result; } catch { - return null; // the vault is a convenience — a failure here must never break an install + return null; // the vault is a convenience. A failure here must never break an install } } /// /// Point the Default SLOT at whatever Steam is running. Unlike builds and presets, of which there can - /// be many, a game has exactly one Default: it's the working copy — the live lua when that lua isn't + /// be many, a game has exactly one Default: it's the working copy. The live lua when that lua isn't /// one of the saved ones. /// /// Four branches, in order: /// - /// no live file — nothing to do. - /// live matches a stored build/preset — THAT one is active; the Default keeps its own bytes so + /// no live file. Nothing to do. + /// live matches a stored build/preset. THAT one is active; the Default keeps its own bytes so /// switching back to it still works. - /// an in-place edit is pending () — the diverged bytes belong to the + /// an in-place edit is pending (). The diverged bytes belong to the /// build being edited, not to the Default. Without this, editing a build would fold the edit into the /// Default and "Save to <build>" would have nothing left to write back to. /// otherwise the live bytes ARE the Default: the previous default's entry AND file are dropped @@ -321,7 +321,7 @@ public string ReadLiveText(long appId) /// /// /// That last branch is destructive by design (the user's call): the Default follows stplug-in, so - /// anything overwriting the live lua — this app, SteamTools, a hand edit — replaces it with no undo. + /// anything overwriting the live lua (this app, SteamTools, a hand edit) replaces it with no undo. /// Builds and presets are never touched, so saving a preset is how a lua is kept. /// /// @@ -363,7 +363,7 @@ private bool SyncDefaultFromLiveCore(long appId) Directory.CreateDirectory(AppDir(appId)); File.Copy(live, VariantPath(appId, liveHash), overwrite: true); - // Steam's copy is always .lua, so its name can't carry a build id — this is + // Steam's copy is always .lua, so its name can't carry a build id. This is // only ever "default", even when the bytes happen to be pinned. index.Variants.Add(Describe(appId, live, liveHash, LuaVariantKind.Default, null, null)); changed = true; @@ -375,7 +375,7 @@ private bool SyncDefaultFromLiveCore(long appId) } catch { - return false; // the vault is a convenience — never break an install or a page load over it + return false; // the vault is a convenience, never break an install or a page load over it } } @@ -407,7 +407,7 @@ private void DeleteVariantFile(long appId, string hash) } /// - /// Make a stored variant the one Steam uses. Copies the bytes VERBATIM — deliberately NOT through + /// Make a stored variant the one Steam uses. Copies the bytes VERBATIM. Deliberately NOT through /// , whose "Auto Update Apps" transform comments out setManifestid lines. /// Running that here would un-pin the very build the user just chose. /// @@ -420,7 +420,7 @@ public bool Apply(long appId, LuaVariant variant) try { // Capture the outgoing lua FIRST. Switching builds overwrites the live file, and if what was - // sitting there was an unsaved working copy this is its only chance to be kept — "the Builds + // sitting there was an unsaved working copy this is its only chance to be kept. "the Builds // page probably refreshed and synced already" is not a guarantee to hang a destructive // overwrite on. Core variant so the whole swap raises one VaultChanged, not two. SyncDefaultFromLiveCore(appId); @@ -428,7 +428,7 @@ public bool Apply(long appId, LuaVariant variant) Directory.CreateDirectory(Path.GetDirectoryName(live)!); File.Copy(source, live, overwrite: true); StampNow(live); - SetEditBase(appId, null); // live matches a stored variant again — no pending edit + SetEditBase(appId, null); // live matches a stored variant again, no pending edit VaultChanged?.Invoke(appId); return true; } @@ -487,7 +487,7 @@ public bool WriteLive(long appId, string text) /// /// Remember which variant the live lua is being edited from. Call this BEFORE the first write that - /// makes it diverge — once it has, the base is no longer derivable from the bytes. + /// makes it diverge. Once it has, the base is no longer derivable from the bytes. /// public void SetEditBase(long appId, string? hash) { @@ -501,7 +501,7 @@ public void SetEditBase(long appId, string? hash) } /// - /// Overwrite an existing variant with new text, KEEPING its identity — label, kind and build id all + /// Overwrite an existing variant with new text, KEEPING its identity. Label, kind and build id all /// travel across. The stored file is content-addressed, so its name changes with its bytes: this /// writes the new file, repoints the index entry, and drops the old one. That's why "save to the /// build I'm on" isn't a plain file write. @@ -519,11 +519,11 @@ public void SetEditBase(long appId, string? hash) { var index = LoadIndex(appId); int i = index.Variants.FindIndex(v => v.Hash == oldHash); - if (i < 0) return null; // the base is gone (deleted?) — caller falls back to a new preset + if (i < 0) return null; // the base is gone (deleted?). Caller falls back to a new preset var old = index.Variants[i]; - // The edited bytes already exist under another entry — keep that one rather than creating + // The edited bytes already exist under another entry. Keep that one rather than creating // a duplicate, and retire the entry we were editing. int existing = index.Variants.FindIndex(v => v.Hash == newHash); if (existing >= 0 && existing != i) @@ -574,7 +574,7 @@ public void Rename(long appId, string hash, string? label) } /// Forget a variant (index entry + file). Refuses to delete the one Steam is currently - /// using — that would leave the live lua orphaned with no way back. + /// using. That would leave the live lua orphaned with no way back. public bool Delete(long appId, string hash) { if (GetActiveHash(appId) == hash) return false; diff --git a/src/LuaToolsGui/Services/PluginAddService.cs b/src/LuaToolsGui/Services/PluginAddService.cs index a7d39e5..817fcd7 100644 --- a/src/LuaToolsGui/Services/PluginAddService.cs +++ b/src/LuaToolsGui/Services/PluginAddService.cs @@ -6,8 +6,8 @@ namespace LuaToolsGui.Services; /// /// Headless add pipeline for the Steam store plugin. Mirrors the logic of -/// DownloadViewModel.FetchAsync/DownloadFromSourceAsync — dynamic sources, synthesized -/// Hubcap/Sadie source, key-gating, usage badges, FastFetch auto-download — but uses only the underlying +/// DownloadViewModel.FetchAsync/DownloadFromSourceAsync. Dynamic sources, synthesized +/// Hubcap/Sadie source, key-gating, usage badges, FastFetch auto-download, but uses only the underlying /// SERVICES (never the UI view model), so the app window stays silent. The plugin polls state via the /// HTTP server; when FastFetch is off it picks a source with . /// @@ -119,7 +119,7 @@ private async Task CheckAsync(long appId, AddState state) var nameTask = string.IsNullOrEmpty(state.GameName) ? SafeGetGameNameAsync(appId) : null; string? key = settings.HubcapApiKey; - // Hubcap/Sadie is the priority source. When a key is set, check ITS availability first — and in + // Hubcap/Sadie is the priority source. When a key is set, check ITS availability first, and in // FastFetch, if Hubcap can serve this game, download straight from it and skip the lua.tools // CheckSources call entirely (a lua.tools source would never be picked over Hubcap anyway). bool hubcapAvailable = false; @@ -192,7 +192,7 @@ private async Task CheckAsync(long appId, AddState state) PublishSources(state, rows, appId); var best = rows.FirstOrDefault(r => r.CanDownload); - if (best is null) { state.Error = "No available source for this game."; PluginLog.Log($"PluginAdd.Check appid={appId} FastFetch: no downloadable source"); return; } + if (best is null) { state.Error = Resources.Strings.Err_NoSourceAvailable; PluginLog.Log($"PluginAdd.Check appid={appId} FastFetch: no downloadable source"); return; } await DownloadAsync(appId, state, best); return; } @@ -226,7 +226,7 @@ private void PublishSources(AddState state, List rows, long appId) /// Fill the usage-counter badges on the (already-published) rows, best-effort and /// concurrently: the Hubcap "12/25" badge + real lock state, and the standard "X/25" badge. A failure - /// leaves the affected rows without a badge — the picker stays fully usable. Row fields are mutated in + /// leaves the affected rows without a badge. The picker stays fully usable. Row fields are mutated in /// place; the status poll re-serializes them, so the badges appear on the next tick. private async Task FillBadgesAsync(List rows, List keyRows, string? key) { @@ -291,7 +291,7 @@ private async Task DownloadAsync(long appId, AddState state, SourceRow row) ? await hubcap.DownloadManifestAsync(appId.ToString(), settings.HubcapApiKey ?? "", progress) : await api.DownloadManifestAsync(appId.ToString(), row.Name, state.GameName, progress); - // Some sources (e.g. Luie) return a BARE .lua, not a zip — sniff the bytes and install + // Some sources (e.g. Luie) return a BARE .lua, not a zip. Sniff the bytes and install // accordingly (same as DownloadViewModel.InstallZipAndReport). Trusting the extension / // always unzipping throws "End of Central Directory record could not be found". var result = IsZip(dl.FilePath) diff --git a/src/LuaToolsGui/Services/PluginInstallerService.cs b/src/LuaToolsGui/Services/PluginInstallerService.cs index 6a0d50d..4d3d6fc 100644 --- a/src/LuaToolsGui/Services/PluginInstallerService.cs +++ b/src/LuaToolsGui/Services/PluginInstallerService.cs @@ -20,12 +20,12 @@ public sealed record PluginStatus( bool UpdateAvailable, bool MillenniumPresent, bool Offline, // couldn't reach GitHub - bool Port8080Busy); // something other than Steam's own CDP server is listening on CDP's fixed port — warn only, see IsPort8080BusyAsync + bool Port8080Busy); // something other than Steam's own CDP server is listening on CDP's fixed port. Warn only, see IsPort8080BusyAsync /// /// Installs / updates / removes the LuaTools store-page plugin from GitHub releases (the app is the plugin -/// MANAGER — it doesn't bundle the frontend). Each release of madoiscool/LTSP carries -/// plugin.zip (the frontend, extracted to %AppData%\LuaToolsGui\plugin — where CefInjectorService +/// MANAGER: it doesn't bundle the frontend). Each release of madoiscool/LTSP carries +/// plugin.zip (the frontend, extracted to %AppData%\LuaToolsGui\plugin, where CefInjectorService /// reads it) plus one loader DLL per entry, dropped into the Steam install root /// (steam.exe loads it). Modeled on : fetch release JSON, download assets /// via (mirror fallback), verify each by its sha256 digest, then place. The DLL @@ -33,10 +33,10 @@ public sealed record PluginStatus( /// and relaunches it if it was up. /// /// CDP itself (the debug bridge connects through) is NOT opened by the -/// DLL anymore — install/uninstall also manages a `.cef-enable-remote-debugging` NTFS junction next to +/// DLL anymore: install/uninstall also manages a `.cef-enable-remote-debugging` NTFS junction next to /// Steam's exe, which makes Steam self-enable CDP on its own (fixed port 8080, confirmed not -/// configurable; verified on both Windows 10 and 11). That leaves the DLL with one remaining job — -/// "launch LuaTools.exe when Steam opens" — with no CDP hook, no load-timing race, and no dual-slot +/// configurable; verified on both Windows 10 and 11). That leaves the DLL with one remaining job: +/// "launch LuaTools.exe when Steam opens", with no CDP hook, no load-timing race, and no dual-slot /// redundancy needed anymore. /// public class PluginInstallerService(SteamService steam, GithubProxy gh, CefInjectorService injector) @@ -47,8 +47,8 @@ public class PluginInstallerService(SteamService steam, GithubProxy gh, CefInjec /// The one DLL-proxy slot the loader ships as. winmm.dll is loaded dynamically (audio) /// by steam.exe, is never a KnownDLL on Win10 or Win11, and isn't claimed by Millennium (wsock32/ - /// version) or OpenSteamTool (dwmapi/xinput). Its old weakness — load timing isn't guaranteed relative - /// to steamwebhelper's launch — no longer matters now that CDP is opened by the junction instead of a + /// version), or OpenSteamTool (dwmapi/xinput). Its old weakness (load timing isn't guaranteed relative + /// to steamwebhelper's launch) no longer matters now that CDP is opened by the junction instead of a /// hook this DLL installs: there's no launch to catch a deadline for anymore, just "eventually load /// while Steam is running." Other slots were each dead ends: bcrypt is KnownDLLs-forced on Win10, /// and psapi and dbghelp don't load reliably enough. @@ -75,8 +75,8 @@ private sealed record LoaderSlot(string DllAsset, string RealName, string System // `.cef-enable-remote-debugging` next to Steam's exe, created as an NTFS JUNCTION (not a plain file) // pointing at a target that's deliberately chosen to never exist. Steam's own internal logic treats // its mere presence as "self-enable CDP debugging" (fixed port 8080) without needing to resolve the - // target — but Millennium's cleanup code (health_check.cc) calls `std::filesystem::exists()` first, - // which DOES try to resolve the junction, fails since the target is nonexistent, and reports false — + // target, but Millennium's cleanup code (health_check.cc) calls `std::filesystem::exists()` first, + // which DOES try to resolve the junction, fails since the target is nonexistent, and reports false, // so Millennium's removal code never even runs. A plain file does not survive that cleanup; the // junction does. Verified live this session on both Windows 10 and 11, including with a real // Millennium instance loaded. `mklink /j` is shelled out to because directory junctions (unlike @@ -85,14 +85,14 @@ private sealed record LoaderSlot(string DllAsset, string RealName, string System // of just severing the link. private const string CdpMarkerName = ".cef-enable-remote-debugging"; private const string CdpMarkerJunctionTarget = @"C:\fuckass\folder\that\shall\never\exist\die\millennium"; - private const int CdpPort = 8080; // Steam's own fixed default — confirmed not configurable via the marker's content. + private const int CdpPort = 8080; // Steam's own fixed default: confirmed not configurable via the marker's content. private string? CdpMarkerPath => SteamDir is { } s ? Path.Combine(s, CdpMarkerName) : null; - /// True only if is already a real reparse point (junction/symlink) — + /// True only if is already a real reparse point (junction/symlink). /// NOT just "something exists there". A bare Exists check used to treat a stale plain file (from before /// this session's junction fix) or a leftover plain directory (e.g. a partially-failed rmdir) as /// "already present" and permanently skip creating a real junction, silently leaving CDP broken for - /// that install forever (nothing else ever re-touches the marker once the plugin's up to date — see + /// that install forever (nothing else ever re-touches the marker once the plugin's up to date; see /// CreateCdpMarkerJunction's own doc comment). private static bool IsReparsePoint(string path) { @@ -105,9 +105,9 @@ private static bool IsReparsePoint(string path) } /// Idempotent, self-healing: safe to call on every status check, not just on install. If a - /// REAL junction is already there, no-op. If something else is there — a stale plain marker file from + /// REAL junction is already there, no-op. If something else is there (a stale plain marker file from /// before this session's junction fix, or any other leftover cruft that isn't actually a reparse - /// point — it's cleared first, since a plain file at this path doesn't survive Millennium's cleanup and + /// point), it's cleared first, since a plain file at this path doesn't survive Millennium's cleanup and /// silently breaks CDP forever otherwise. private static void CreateCdpMarkerJunction(string path) { @@ -117,7 +117,7 @@ private static void CreateCdpMarkerJunction(string path) if (Directory.Exists(path)) Directory.Delete(path, recursive: true); else if (File.Exists(path)) File.Delete(path); } - catch { /* best effort — mklink below just no-ops/fails harmlessly if this didn't clear it */ } + catch { /* best effort; mklink below just no-ops/fails harmlessly if this didn't clear it */ } var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c mklink /j \"{path}\" \"{CdpMarkerJunctionTarget}\"") { @@ -149,12 +149,12 @@ private static void RemoveCdpMarkerJunction(string path) /// Best-effort check for whether CDP's fixed port is occupied by something OTHER than Steam's /// own CDP server. A bare bind-test can't make that distinction: once the junction is doing its job and /// CDP is actually up, Steam itself is the one holding the port, so a bind fails for the exact same - /// reason a hostile squatter would cause it to fail — the two are indistinguishable to + /// reason a hostile squatter would cause it to fail. The two are indistinguishable to /// . This produced a live false positive (warning fired /// against Steam's own working CDP server). Fixed by, on a bind failure, asking whatever's on the port - /// for /json — CDP answers with a JSON array of debug targets; only treat it as "busy" in the + /// for /json. CDP answers with a JSON array of debug targets; only treat it as "busy" in the /// user-facing sense if that probe does NOT look like CDP. The port can't be changed regardless - /// (confirmed: file content has no effect on it), so this stays detection-only — a false + /// (confirmed: file content has no effect on it), so this stays detection-only. A false /// positive/negative here never blocks install/uninstall. private static async Task IsPort8080BusyAsync() { @@ -255,20 +255,20 @@ public bool IsInstalledLocally() => public async Task GetStatusAsync(bool force = false, CancellationToken ct = default) { bool frontend = File.Exists(LuatoolsJsPath); - // "installed" if AT LEAST ONE slot's proxy is present — a partial/mid-migration state still counts + // "installed" if AT LEAST ONE slot's proxy is present. A partial/mid-migration state still counts // as installed and eligible for auto-update, rather than showing "not installed". An OLD loader - // (psapi/dbghelp) also still counts — otherwise a user who hasn't migrated shows "not installed" + // (psapi/dbghelp) also still counts. Otherwise a user who hasn't migrated shows "not installed" // and the auto-update gate (UpdateAvailable) never fires, stranding them on the dead loader. bool anySlotPresent = Slots.Any(slot => SlotPath(slot) is { } p && File.Exists(p)); bool legacy = LegacyDllPaths.Any(File.Exists); bool loader = anySlotPresent || legacy; // Self-heal the CDP junction on every status check, not just when InstallAsync happens to run. - // InstallAsync only fires on a fresh install or when a version bump makes UpdateAvailable true — once + // InstallAsync only fires on a fresh install or when a version bump makes UpdateAvailable true. Once // the plugin is fully up to date, nothing else ever re-touches the marker. If it's ever removed after // that point (inconsistent Millennium-version cleanup, AV quarantining the reparse point, a Steam // repair, manual deletion), CDP silently stops working and the ONLY thing that used to fix it was a - // full uninstall+reinstall (forces InstallAsync unconditionally) — exactly the workaround users have + // full uninstall+reinstall (forces InstallAsync unconditionally). Exactly the workaround users have // been reporting. GetStatusAsync runs on every Steam-open poke, so checking here closes that gap // continuously instead of only at version-bump time. Cheap when already correct (single attribute // check, no shellout) and only touches the loader DLL is actually installed. @@ -351,19 +351,19 @@ public async Task GetStatusAsync(bool force = false, CancellationT if (!File.Exists(LuatoolsJsPath)) return (false, Resources.Strings.Plugin_Err_NoLuatoolsJs); - // Get the frontend live in THIS running process immediately — don't wait on the Steam restart + // Get the frontend live in THIS running process immediately. Don't wait on the Steam restart // below. A relaunched LuaTools.exe would hit the single-instance mutex against this very // process (the one the user is using right now to click Install) and exit quietly without ever // taking over, so nothing would otherwise pick up the new file until a manual app restart. await injector.ReloadPluginFilesAsync(); - // 2) Loader DLLs → Steam root — but ONLY when at least one slot actually changed, OR a legacy + // 2) Loader DLLs → Steam root, but ONLY when at least one slot actually changed, OR a legacy // slot is still present and must be removed. Both slots are always installed/updated // together (never partially out of date relative to each other). The DLLs are locked while // Steam runs, so either condition means stopping+restarting Steam; a frontend-only update // (the common case) skips all of that and applies with zero Steam disruption. // Testing switch: when `.luatools-dll-update-disabled` is present, never touch any on-disk DLL - // (so hand-placed test builds aren't clobbered) — and thus never stop/restart Steam for it either. + // (so hand-placed test builds aren't clobbered), and thus never stop/restart Steam for it either. bool legacyPresent = LegacyDllPaths.Any(File.Exists); bool anySlotNeedsUpdate = Slots.Any(slot => SlotPath(slot) is not { } cur || !File.Exists(cur) || Sha256OfFile(cur) != slotShas[slot]); @@ -377,17 +377,17 @@ public async Task GetStatusAsync(bool force = false, CancellationT foreach (var slot in Slots) { File.Copy(slotDlPaths[slot], Path.Combine(steamDir, slot.DllAsset), overwrite: true); - // Each proxy forwards to _real.dll — a copy of the machine's own matching + // Each proxy forwards to _real.dll. A copy of the machine's own matching // System32 file. Refresh it on every DLL update so it always matches the current OS build. if (SlotRealPath(slot) is { } real && File.Exists(slot.SystemSourcePath)) File.Copy(slot.SystemSourcePath, real, overwrite: true); } - // Remove any legacy slot (psapi/dbghelp + their _real) — leaving one would run the loader + // Remove any legacy slot (psapi/dbghelp + their _real). Leaving one would run the loader // payload an extra time (double LuaTools launch / CDP hook). foreach (var legacy in LegacyDllPaths) if (File.Exists(legacy)) { try { File.Delete(legacy); } catch { /* locked/again next time */ } } - // 3) A live Millennium luatools plugin would inject the frontend redundantly — disable it in + // 3) A live Millennium luatools plugin would inject the frontend redundantly. Disable it in // Millennium's config (reversibly) so LuaLoader is the sole injector (leaves the Millennium // mod itself alone). Steam is stopped here, so the edit can't be clobbered and takes effect // on the restart below. Also migrate away any leftover folder-rename from older builds. @@ -400,7 +400,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT if (wasRunning) steam.StartSteam(); } - // Ensure the CDP marker junction exists — independent of whether the DLL itself changed (a + // Ensure the CDP marker junction exists: independent of whether the DLL itself changed (a // filesystem-only op, doesn't need Steam stopped). Needed here for the very first install (before // any GetStatusAsync call would see `loader` true); GetStatusAsync's own check is what keeps this // self-healing on every subsequent Steam-open, not just at install/update time. See its @@ -423,7 +423,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT /// Silent auto-update: if an update is available for an already-installed plugin, apply it /// (frontend silently; DLL change stops/swaps/restarts Steam). Returns true if an update was applied. - /// Fire-and-forget safe — swallows offline/errors. Called from the app's Steam-open update flow. + /// Fire-and-forget safe: swallows offline/errors. Called from the app's Steam-open update flow. public async Task AutoUpdateAsync(CancellationToken ct = default) { try @@ -443,7 +443,7 @@ public async Task AutoUpdateAsync(CancellationToken ct = default) { try { - // Read the manifest BEFORE the FrontendDir delete below wipes it — it holds the exact + // Read the manifest BEFORE the FrontendDir delete below wipes it. It holds the exact // enabledPlugins entries we stripped from Millennium's config at install time. var manifest = ReadManifest(); @@ -461,7 +461,7 @@ public async Task AutoUpdateAsync(CancellationToken ct = default) if (CdpMarkerPath is { } markerPath) RemoveCdpMarkerJunction(markerPath); if (Directory.Exists(FrontendDir)) Directory.Delete(FrontendDir, recursive: true); - // Give Millennium its luatools plugin back — we're the ones who disabled it. Steam is + // Give Millennium its luatools plugin back: we're the ones who disabled it. Steam is // stopped here, so the edit sticks and applies on the restart below. if (MillenniumPresent) SetMillenniumLuatoolsEnabled(enable: true, restore: manifest?.DisabledMillenniumEntries); @@ -486,7 +486,7 @@ public async Task AutoUpdateAsync(CancellationToken ct = default) /// and serializing those through custom options without a resolver throws /// "JsonSerializerOptions instance must specify a TypeInfoResolver". Removal-only writes happen to work /// without it (all surviving nodes are JsonElement-backed from Parse), so the failure would only ever - /// surface on uninstall — silently, inside the best-effort catch. + /// surface on uninstall. Silently, inside the best-effort catch. private static readonly JsonSerializerOptions ConfigWriteOpts = new() { WriteIndented = true, @@ -510,7 +510,7 @@ private IEnumerable MillenniumConfigPaths() /// True if an enabledPlugins entry refers to our plugin. The entry's leading space-delimited /// token is the plugin id; Millennium versions store it as luatools, luatools LUA - /// (name + backend), or — from the old rename — luatools.disabled-by-luatools[ LUA]. + /// (name + backend), or (from the old rename) luatools.disabled-by-luatools[ LUA]. private static bool IsLuatoolsEntry(string entry) { string token = entry.Split(' ', 2)[0]; @@ -542,7 +542,7 @@ private Dictionary> SetMillenniumLuatoolsEnabled( if (!enable) { // Record only the nested (real) array's removals for restore; the flat dotted key is a - // stray duplicate some builds write — scrub it, but never restore into it. + // stray duplicate some builds write. Scrub it, but never restore into it. var removed = new List(); if (NestedEnabled(root) is { } nested) for (int i = nested.Count - 1; i >= 0; i--) @@ -580,7 +580,7 @@ private Dictionary> SetMillenniumLuatoolsEnabled( if (changed) File.WriteAllText(path, root.ToJsonString(ConfigWriteOpts)); } - catch { /* best effort per file — a locked/invalid config must not fail install/uninstall */ } + catch { /* best effort per file. A locked/invalid config must not fail install/uninstall */ } } return removedMap; } diff --git a/src/LuaToolsGui/Services/ProxiedFileDownloader.cs b/src/LuaToolsGui/Services/ProxiedFileDownloader.cs index b3481da..77661fe 100644 --- a/src/LuaToolsGui/Services/ProxiedFileDownloader.cs +++ b/src/LuaToolsGui/Services/ProxiedFileDownloader.cs @@ -7,7 +7,7 @@ namespace LuaToolsGui.Services; /// in blocked/throttled regions (e.g. China). Velopack calls this for BOTH the release feed (the API, via /// DownloadString/DownloadBytes) and the package download (the .nupkg, via DownloadFile). Each call tries /// the DIRECT GitHub URL first, then falls through the same mirrors as -/// () — so the in-app self-update works where github.com is blocked. +/// (), so the in-app self-update works where github.com is blocked. /// Wraps Velopack's stock , only swapping the URL per attempt. /// public class ProxiedFileDownloader : IFileDownloader diff --git a/src/LuaToolsGui/Services/SteamAppInfoCache.cs b/src/LuaToolsGui/Services/SteamAppInfoCache.cs index f61f75b..09f5aed 100644 --- a/src/LuaToolsGui/Services/SteamAppInfoCache.cs +++ b/src/LuaToolsGui/Services/SteamAppInfoCache.cs @@ -42,7 +42,7 @@ public record AppOverview( public bool HasDeveloper => Developers.Count > 0; /// - /// Developer and publisher are the SAME studio for roughly 40% of apps — showing both lines then + /// Developer and publisher are the SAME studio for roughly 40% of apps. Showing both lines then /// just reads as a duplicated typo, so the publisher line is only worth rendering when it differs. /// public bool HasDistinctPublisher => @@ -62,7 +62,7 @@ public class SteamAppInfoCache { private static readonly string Dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui"); - // Full raw appdetails 'data' blob per appid — the single on-disk source of truth. Name + header image + // Full raw appdetails 'data' blob per appid: the single on-disk source of truth. Name + header image // (the fast in-RAM index below) are derived from these on demand; there is no separate appinfo.json. private static readonly string DetailsDir = Path.Combine(Dir, "details"); @@ -133,14 +133,14 @@ public static string GuessHeaderImageUrl(long appid) => } /// Fetch an app's name + header image from Steam (throttled, retries on 429/403). Null on - /// failure. Pulls the FULL appdetails payload and caches the whole blob (for filters) — name/header - /// are derived from it — so each app is only ever fetched once. + /// failure. Pulls the FULL appdetails payload and caches the whole blob (for filters). Name/header + /// are derived from it, so each app is only ever fetched once. public async Task ResolveAsync(long appid, CancellationToken ct = default) { if (_cache.TryGetValue(appid, out var cached)) return cached; - // cc=us so region-blocked games (e.g. titles banned in the user's country) still resolve — - // this is metadata only, not purchasing, so a neutral region is correct. + // cc=us so region-blocked games (e.g. titles banned in the user's country) still resolve. + // This is metadata only, not purchasing, so a neutral region is correct. var url = $"https://store.steampowered.com/api/appdetails?appids={appid}&cc=us&l=english"; const int maxAttempts = 3; // initial + 2 retries @@ -174,7 +174,7 @@ public static string GuessHeaderImageUrl(long appid) => var info = new SteamAppInfo(name, image); _cache[appid] = info; // warm the session RAM index - // Persist the whole 'data' blob (this is the full payload) — the single on-disk source of + // Persist the whole 'data' blob (this is the full payload). The single on-disk source of // truth; name + header image are re-derived from it (no separate appinfo.json). _ = SaveFullDetailsAsync(appid, data.GetRawText()); return info; @@ -266,7 +266,7 @@ public static string GuessHeaderImageUrl(long appid) => // Adult content via Steam's official content_descriptors taxonomy (region-independent). // Only the genuinely-adult tier counts: id 3 = "Adult Only Sexual Content", 4 = "Frequent // Nudity or Sexual Content". We deliberately EXCLUDE id 1 ("Some Nudity or Sexual Content") - // because that's the mainstream-M tier — it flags AAA games like Ghost of Tsushima / Ready or + // because that's the mainstream-M tier. It flags AAA games like Ghost of Tsushima / Ready or // Not that aren't "adult". (id 2 = violence/gore, 5 = general mature also excluded.) Absent → non-adult. bool isAdult = false; if (d.TryGetProperty("content_descriptors", out var cd) && @@ -288,7 +288,7 @@ public static string GuessHeaderImageUrl(long appid) => /// /// Blurb + studio + genres from the cached blob, for the Manage flyout. Null when the app has no - /// cached details yet, or the blob is the "{}" delisted marker — the caller collapses the section + /// cached details yet, or the blob is the "{}" delisted marker. The caller collapses the section /// rather than rendering an empty shell. /// public AppOverview? GetOverview(long appid) @@ -346,7 +346,7 @@ private static List ReadDescriptions(JsonElement d, string property) } /// Fetch (if needed) + build the app's straight from Steam's - /// appdetails — replacing the old lua.tools/api/steam/details proxy, which was just this endpoint with + /// appdetails. Replacing the old lua.tools/api/steam/details proxy, which was just this endpoint with /// 7 fields mapped. Interactive priority (beats the Manage backfill). Null if delisted/unavailable. public async Task ResolveGameDetailsAsync(long appid, CancellationToken ct = default) { @@ -402,8 +402,8 @@ public async Task EnsureFullDetailsAsync(long appid, CancellationToken ct { if (HasFullDetails(appid)) return true; - // cc=us so region-blocked games (e.g. titles banned in the user's country) still resolve — - // this is metadata only, not purchasing, so a neutral region is correct. + // cc=us so region-blocked games (e.g. titles banned in the user's country) still resolve. + // This is metadata only, not purchasing, so a neutral region is correct. var url = $"https://store.steampowered.com/api/appdetails?appids={appid}&cc=us&l=english"; const int maxAttempts = 3; for (int attempt = 0; attempt < maxAttempts; attempt++) @@ -482,8 +482,8 @@ private async Task SaveFullDetailsAsync(long appid, string rawJson) private int _interactiveWaiting; /// Burst up to MaxPerWindow, then pace at the edge of the window (never trips 429). Interactive - /// callers (default) take priority; callers yield the next slot — and the - /// gate during a cap-wait — to any waiting interactive request. + /// callers (default) take priority; callers yield the next slot, and the + /// gate during a cap-wait. To any waiting interactive request. private async Task ThrottleAsync(CancellationToken ct, bool background = false) { if (!background) Interlocked.Increment(ref _interactiveWaiting); diff --git a/src/LuaToolsGui/Services/SteamAppListCache.cs b/src/LuaToolsGui/Services/SteamAppListCache.cs index d92d2d9..1cdb9be 100644 --- a/src/LuaToolsGui/Services/SteamAppListCache.cs +++ b/src/LuaToolsGui/Services/SteamAppListCache.cs @@ -53,7 +53,7 @@ await TryDownloadAsync(SteamUrl, flatArray: false)) return; } - // Both sources failed — fall back to a stale cache if we have one. + // Both sources failed: fall back to a stale cache if we have one. try { if (_names.IsEmpty && File.Exists(CacheFile)) @@ -62,7 +62,7 @@ await TryDownloadAsync(SteamUrl, flatArray: false)) if (map is not null) foreach (var (k, v) in map) _names[k] = v; } } - catch { /* give up — names fall back to lua-parsed / appid */ } + catch { /* give up. Names fall back to lua-parsed / appid */ } } /// Download one source. Morrenus is a flat [{appid,name}]; Steam nests under applist.apps. diff --git a/src/LuaToolsGui/Services/SteamDepotInfo.cs b/src/LuaToolsGui/Services/SteamDepotInfo.cs index abeeebe..50f67e4 100644 --- a/src/LuaToolsGui/Services/SteamDepotInfo.cs +++ b/src/LuaToolsGui/Services/SteamDepotInfo.cs @@ -7,7 +7,7 @@ namespace LuaToolsGui.Services; /// /// One depot of an app. If DlcAppId is set, this depot ships files for that DLC. IsShared marks a /// redistributable pulled from another app (VC++, DirectX). Os/Language only present when declared. -/// PublicManifestId is the manifest Steam currently ships on the public branch — compare it against a +/// PublicManifestId is the manifest Steam currently ships on the public branch. Compare it against a /// lua's setManifestid pin to tell "pinned to the latest build" from "pinned to an older one". /// public record ContentDepot(long Id, long Size, long? DlcAppId, bool IsShared, string? Os, string? Language, @@ -19,8 +19,8 @@ public record ContentDepot(long Id, long Size, long? DlcAppId, bool IsShared, st /// /// Authoritative depot list for an app, from api.steamcmd.net (same data SteamDB shows). Includes /// shared redistributables (flagged IsShared) so the UI can bucket them separately. DlcIds is the -/// app's full declared DLC list (extended.listofdlc) — many have no depot (store-only entitlements). -/// LaunchExes are the main Windows game executables (relative paths) from config.launch — the exact +/// app's full declared DLC list (extended.listofdlc). Many have no depot (store-only entitlements). +/// LaunchExes are the main Windows game executables (relative paths) from config.launch. The exact /// exe(s) Steam runs, used to target Steamless DRM removal (empty if the manifest has no launch config). /// PublicBuildId is the build the public branch is on right now, so the Builds page can show how far /// behind a pinned lua is (null when the app declares no branches). @@ -31,7 +31,7 @@ public record AppDepotInfo(long AppId, IReadOnlyList Depots, IRead public class SteamDepotInfo { /// - /// How long a FAILED lookup is remembered. Successes are kept for the whole session — depot manifests + /// How long a FAILED lookup is remembered. Successes are kept for the whole session. Depot manifests /// don't move often enough to re-fetch per navigation, and covers the case /// where they have. A failure is different: caching it forever meant one blip or one 15s timeout gave /// that game a permanent "couldn't load depot info", with no way back short of restarting the app. @@ -64,7 +64,7 @@ internal SteamDepotInfo(HttpMessageHandler handler) => // FetchAsync drops its own de-dupe entry in a finally, which is correct ONLY when it actually // suspends: GetOrAdd stores the task after the factory returns, so a fetch that completes // synchronously (a stubbed handler, a socket that had the response ready) runs that finally - // before the entry exists, and the completed task then sits in _inFlight forever — served in + // before the entry exists, and the completed task then sits in _inFlight forever. Served in // place of every future fetch. Sweep it here, where the entry is known to be stored. if (task.IsCompleted) _inFlight.TryRemove(appId, out _); return task; @@ -102,7 +102,7 @@ public void Invalidate(long appId) if (entry.Value.ValueKind != JsonValueKind.Object) continue; var v = entry.Value; - // Shared redistributable (VC++, DirectX, …) — belongs to another app. Keep it, flagged. + // Shared redistributable (VC++, DirectX, …). Belongs to another app. Keep it, flagged. bool isShared = v.TryGetProperty("depotfromapp", out _); long? dlcAppId = v.TryGetProperty("dlcappid", out var dlcEl) && long.TryParse(dlcEl.GetString(), out long dlc) @@ -132,7 +132,7 @@ public void Invalidate(long appId) ? buildEl.GetString() : buildEl.ToString(); - // Full declared DLC list — includes store-only DLC with no depot of their own. + // Full declared DLC list: includes store-only DLC with no depot of their own. var dlcIds = new List(); if (app.TryGetProperty("extended", out var ext) && ext.TryGetProperty("listofdlc", out var listEl) && diff --git a/src/LuaToolsGui/Services/SteamLibraryService.cs b/src/LuaToolsGui/Services/SteamLibraryService.cs index bf18ade..f33d432 100644 --- a/src/LuaToolsGui/Services/SteamLibraryService.cs +++ b/src/LuaToolsGui/Services/SteamLibraryService.cs @@ -44,7 +44,7 @@ public partial class SteamLibraryService(SteamService steam) if (Directory.Exists(full)) return full; } } - catch { /* unreadable VDF/ACF or odd path — treat as not found */ } + catch { /* unreadable VDF/ACF or odd path. Treat as not found */ } return null; } diff --git a/src/LuaToolsGui/Services/SteamLinkParser.cs b/src/LuaToolsGui/Services/SteamLinkParser.cs index f10899b..2aafcc4 100644 --- a/src/LuaToolsGui/Services/SteamLinkParser.cs +++ b/src/LuaToolsGui/Services/SteamLinkParser.cs @@ -3,14 +3,14 @@ namespace LuaToolsGui.Services; /// -/// Pulls a Steam appid out of a link — what makes dragging a SteamDB/store URL onto the drop box install +/// Pulls a Steam appid out of a link: what makes dragging a SteamDB/store URL onto the drop box install /// that game. /// public static partial class SteamLinkParser { /// /// Appids are well under this. steam://rungameid/ encodes a COMPOSITE 64-bit id for non-Steam - /// shortcuts and mods, which is not an appid — without this guard one of those would be handed to the + /// shortcuts and mods, which is not an appid. Without this guard one of those would be handed to the /// installer as a game id. /// private const long MaxAppId = 2_000_000_000; @@ -44,7 +44,7 @@ public static partial class SteamLinkParser foreach (var regex in new[] { SteamDbRegex(), StoreRegex(), CommunityRegex(), SteamProtocolRegex() }) { - // Dragged text can carry more than one URL (a selection, a multi-line paste) — first wins. + // Dragged text can carry more than one URL (a selection, a multi-line paste). First wins. var match = regex.Match(text); if (match.Success && long.TryParse(match.Groups[1].Value, out long appId) && appId > 0 && appId < MaxAppId) diff --git a/src/LuaToolsGui/Services/SteamService.cs b/src/LuaToolsGui/Services/SteamService.cs index 3b9254c..58fbec7 100644 --- a/src/LuaToolsGui/Services/SteamService.cs +++ b/src/LuaToolsGui/Services/SteamService.cs @@ -56,7 +56,7 @@ public static void RevealInExplorer(string filePath) => /// Kill any running steam.exe (and its tree) and wait for it to exit. Safe to call when /// Steam isn't running. Use before changing Steam's files so they aren't locked. - /// True while a Steam client process is running — appinfo.vdf can't be edited under it. + /// True while a Steam client process is running. Appinfo.vdf can't be edited under it. public static bool IsSteamRunning() { var procs = Process.GetProcessesByName("steam"); @@ -115,13 +115,13 @@ public bool RestartSteam() } catch { - // Inaccessible key — try the next one + // Inaccessible key: try the next one } } return null; } - /// Registry values vary (forward vs back slashes, casing) — canonicalize to a Windows path. + /// Registry values vary (forward vs back slashes, casing). Canonicalize to a Windows path. private static string Normalize(string path) { try { return Path.GetFullPath(path.Trim().Replace('/', '\\')); } diff --git a/src/LuaToolsGui/Services/SteamlessService.cs b/src/LuaToolsGui/Services/SteamlessService.cs index 20d1608..eb6bbf5 100644 --- a/src/LuaToolsGui/Services/SteamlessService.cs +++ b/src/LuaToolsGui/Services/SteamlessService.cs @@ -62,7 +62,7 @@ public class SteamlessService(GithubProxy gh, SteamLibraryService library, Steam string zipPath = Path.Combine(ToolDir, "steamless.zip"); await gh.DownloadAsync(asset.DownloadUrl, zipPath, progress, ct); - // Extract the WHOLE zip — the CLI needs its plugin DLLs alongside it. + // Extract the WHOLE zip: the CLI needs its plugin DLLs alongside it. ZipFile.ExtractToDirectory(zipPath, ToolDir, overwriteFiles: true); try { File.Delete(zipPath); } catch { /* leftover zip is harmless */ } @@ -99,7 +99,7 @@ public async Task PatchGameAsync(long appId, IProgress string unpacked = exe + ".unpacked.exe"; if (File.Exists(unpacked)) { - // Back the original up (only once — never clobber a known-good .bak), then swap in. + // Back the original up (only once, never clobber a known-good .bak), then swap in. string bak = exe + ".bak"; if (!File.Exists(bak)) File.Copy(exe, bak); File.Delete(exe); @@ -108,7 +108,7 @@ public async Task PatchGameAsync(long appId, IProgress } else { - unchanged++; // no SteamStub DRM, or Steamless declined — leave it as-is + unchanged++; // no SteamStub DRM, or Steamless declined. Leave it as-is } } catch (OperationCanceledException) { throw; } diff --git a/src/LuaToolsGui/Services/ToastService.cs b/src/LuaToolsGui/Services/ToastService.cs index 6b1da18..7def665 100644 --- a/src/LuaToolsGui/Services/ToastService.cs +++ b/src/LuaToolsGui/Services/ToastService.cs @@ -6,7 +6,7 @@ namespace LuaToolsGui.Services; /// -/// App-wide bottom-right toast feedback. Thin wrapper over Wpf.Ui's SnackbarService — the presenter +/// App-wide bottom-right toast feedback. Thin wrapper over Wpf.Ui's SnackbarService. The presenter /// is attached once from MainWindow (see App.OnStartup). Safe to call from any thread. /// public class ToastService @@ -39,7 +39,7 @@ void Post() => _snackbar.Show( /// /// Show a PERSISTENT toast with an action button (no auto-dismiss; user-closable). Used for the - /// "update ready" prompt — the action runs (e.g. restart). + /// "update ready" prompt: the action runs (e.g. restart). /// public void ShowAction(string title, string message, string actionLabel, Action onAction, bool error = false) { @@ -54,7 +54,7 @@ void Post() Title = title, Appearance = error ? ControlAppearance.Caution : ControlAppearance.Secondary, Icon = new SymbolIcon(SymbolRegular.ArrowSync24), - // No "infinite" sentinel exists — Timeout is how long it's VISIBLE (Zero = dismiss + // No "infinite" sentinel exists: Timeout is how long it's VISIBLE (Zero = dismiss // instantly), so use a very large value to effectively persist until acted on / closed. Timeout = TimeSpan.FromDays(1), IsCloseButtonEnabled = true, diff --git a/src/LuaToolsGui/Services/UpdateService.cs b/src/LuaToolsGui/Services/UpdateService.cs index c096f2e..c9a1db6 100644 --- a/src/LuaToolsGui/Services/UpdateService.cs +++ b/src/LuaToolsGui/Services/UpdateService.cs @@ -10,7 +10,7 @@ namespace LuaToolsGui.Services; /// Resilience is two-layered: (1) routes each repo's feed + package /// downloads through GitHub mirrors for blocked/throttled regions (e.g. China); (2) it tries each repo in /// in order, so if the PRIMARY repo is gone entirely -/// (banned / DMCA'd / account removed — something the mirrors can't fix) it falls through to a backup repo. +/// (banned / DMCA'd / account removed. Something the mirrors can't fix) it falls through to a backup repo. /// /// public class UpdateService @@ -24,7 +24,7 @@ public class UpdateService downloader: new ProxiedFileDownloader()))) .ToArray(); - // The manager whose repo actually produced the staged update — apply against this same one. + // The manager whose repo actually produced the staged update. Apply against this same one. private UpdateManager? _stagedMgr; private UpdateInfo? _staged; @@ -46,7 +46,7 @@ public async Task CheckAndStageAsync() try { var info = await mgr.CheckForUpdatesAsync(); - // A reachable repo returning null means we're already up to date — STOP. Don't fall + // A reachable repo returning null means we're already up to date. STOP. Don't fall // through to a backup (it may lag behind the primary and would offer no/older update). // Backups exist for an UNreachable primary, which surfaces as an exception below. if (info is null) return; @@ -55,14 +55,14 @@ public async Task CheckAndStageAsync() _stagedMgr = mgr; _staged = info; UpdateReady?.Invoke(); - return; // staged from this repo — done + return; // staged from this repo. Done } catch { - // This repo is unreachable/gone (or a download failed) — fall through to the next backup. + // This repo is unreachable/gone (or a download failed). Fall through to the next backup. } } - // Every repo failed (offline, or all repos down) — fail silently, retry next launch. + // Every repo failed (offline, or all repos down). Fail silently, retry next launch. } /// Apply the staged update now and relaunch into the new version. diff --git a/src/LuaToolsGui/ViewModels/BuildsViewModel.cs b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs index f2ce984..0d86971 100644 --- a/src/LuaToolsGui/ViewModels/BuildsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs @@ -22,11 +22,11 @@ public record DepotRow( /// /// The lock switch needs a setManifestid line to comment in/out. Without one there's nothing to - /// toggle — we'd have to invent a manifest id, which is not something to guess at. + /// toggle. We'd have to invent a manifest id, which is not something to guess at. /// public bool CanLock => CanToggle && (ManifestId is not null || CommentedManifestId is not null); - /// The enable switch is offered for anything the lua declares, except the base app — turning + /// The enable switch is offered for anything the lua declares, except the base app. Turning /// THAT off doesn't disable a depot, it breaks the whole file. public bool CanEnable => CanToggle && !IsBaseApp; @@ -72,8 +72,8 @@ public bool Matches(string query) /// Greyed out when the pin exists but isn't in force (commented out). public bool IsPinActive => ManifestId is not null; - /// Shown only when this lua is pinned to something OTHER than what Steam ships now — - /// that's the whole point of picking an older build, so it's worth calling out. + /// Shown only when this lua is pinned to something OTHER than what Steam ships now. + /// That's the whole point of picking an older build, so it's worth calling out. public string? OutdatedLabel => ManifestId is not null && PublicManifestId is not null && ManifestId != PublicManifestId ? string.Format(Resources.Strings.Builds_ManifestLatest, PublicManifestId) @@ -83,7 +83,7 @@ public bool Matches(string query) } /// -/// One row in the build switcher — always a stored . There is no "unsaved +/// One row in the build switcher, always a stored . There is no "unsaved /// changes" row any more: live bytes matching no saved build or preset ARE the Default, captured by /// before this list is built. /// @@ -94,14 +94,14 @@ public partial class VariantRowViewModel : ObservableObject public string Hash => Variant.Hash; public string? BuildId => Variant.BuildId; - /// True when this row is what Steam is using right now — including when the live lua is + /// True when this row is what Steam is using right now, including when the live lua is /// this variant plus an unsaved edit (see ). [ObservableProperty] private bool _isActive; /// /// True when the live lua is this variant with changes that haven't been saved back to it. Without /// surfacing this, a build mid-edit is indistinguishable from a saved one and switching away - /// discards the work silently — the header's "Save to <build>" button being the only hint. + /// discards the work silently. The header's "Save to <build>" button being the only hint. /// public bool HasPendingEdit { get; init; } @@ -131,7 +131,7 @@ public string Meta /// Pick a game on the left, switch which stored lua is live on the right. /// /// -/// Named "Depots" in the UI, and its stored luas are "presets" there — the type names here +/// Named "Depots" in the UI, and its stored luas are "presets" there. The type names here /// (Builds*, , Builds_* resource keys) predate that and were /// deliberately left alone: the codebase already has and /// for actual Steam depots, so a DepotsViewModel beside them would be worse than @@ -185,8 +185,8 @@ public BuildsViewModel(SteamService steam, LuaVault vault, SteamAppListCache app // dropdown blank. Snap it to the default and persist, rather than showing an empty box. if (!PageSizeOptions.Contains(SelectedPageSize)) SelectedPageSize = "10"; - // Any install (Add page, drag-drop, plugin) captures into the vault on a background thread — - // refresh so a new build shows up without the user leaving the page. + // Any install (Add page, drag-drop, plugin) captures into the vault on a background thread. + // Refresh so a new build shows up without the user leaving the page. _vault.VaultChanged += appId => OnUi(() => { if (ActiveGame?.AppId == appId) RefreshVariants(); @@ -194,8 +194,8 @@ public BuildsViewModel(SteamService steam, LuaVault vault, SteamAppListCache app } // Paging (Items/PageSize/CurrentPage/PrevPage/NextPage/…), IsLoading, EmptyMessage and IsEmpty are - // inherited from PagedListViewModel. The Builds pager renders only prev/label/next - // — PageNumbers exists on the base but is deliberately not bound. + // inherited from PagedListViewModel. The Builds pager renders only prev/label/next. + // PageNumbers exists on the base but is deliberately not bound. protected override void SavePageSizeSetting(int size) => _settings.BuildsPageSize = size; // ── Game list (left) ──────────────────────────────────────────── @@ -204,7 +204,7 @@ public BuildsViewModel(SteamService steam, LuaVault vault, SteamAppListCache app partial void OnSearchTextChanged(string value) => ApplyGameFilter(); /// - /// The row highlighted in the list. This goes NULL on every page change — the base hands `Items` a + /// The row highlighted in the list. This goes NULL on every page change. The base hands `Items` a /// brand-new collection, and the ListBox pushes null back through the binding when the selected item /// isn't in it. So it can't be what the right-hand panel reads; see . /// @@ -223,7 +223,7 @@ public BuildsViewModel(SteamService steam, LuaVault vault, SteamAppListCache app partial void OnSelectedGameChanged(LuaTileViewModel? value) { - if (value is null) return; // a page change, not a deselection — keep showing the current game + if (value is null) return; // a page change, not a deselection. Keep showing the current game ActiveGame = value; } @@ -277,9 +277,9 @@ private async Task LoadCoreAsync() var games = await Task.Run(() => { // Three sources, because a game can be manageable here without Steam currently loading it: - // 1. installed — a live .lua Steam reads - // 2. loose — _.lua sitting in stplug-in, inert until applied - // 3. vaulted — stored builds whose live lua has since been deleted + // 1. installed: a live .lua Steam reads + // 2. loose: _.lua sitting in stplug-in, inert until applied + // 3. vaulted: stored builds whose live lua has since been deleted var installed = LuaInstaller.EnumerateInstalled(dir).ToDictionary(f => f.AppId, f => f.Path); var appIds = new HashSet(installed.Keys); foreach (var (appId, _, _) in _vault.EnumerateLooseBuildLuas()) appIds.Add(appId); @@ -317,7 +317,7 @@ private async Task LoadCoreAsync() } } - /// Filtering only — badges are computed by , not here. This + /// Filtering only. Badges are computed by , not here. This /// runs on every search keystroke, and badging hashes files off disk. Search spans the WHOLE library, /// not just the visible page; the base re-slices and resets to page 1. private void ApplyGameFilter() @@ -327,7 +327,7 @@ private void ApplyGameFilter() } /// Recompute every game's "currently running" badge off the UI thread (one file hash + index - /// read per vaulted game — fine once per load, far too slow per keystroke). + /// read per vaulted game. Fine once per load, far too slow per keystroke). private async Task RefreshBadgesAsync() { var games = _allGames.ToList(); @@ -337,7 +337,7 @@ private async Task RefreshBadgesAsync() } /// - /// The label shown next to a game in the list — what it's currently running. A game with nothing + /// The label shown next to a game in the list: what it's currently running. A game with nothing /// captured yet is untracked, not mislabelled, so it gets no badge; the same goes for a live hash the /// vault hasn't seen, which only happens before this game's first /// and resolves to "Default" the moment it runs. @@ -380,10 +380,10 @@ public async Task SelectAppAsync(long appId) SelectedGame = Items.FirstOrDefault(g => g.AppId == appId); // highlights the row } - /// Called by the view as a game row scrolls into view — resolves its cover (cached after). + /// Called by the view as a game row scrolls into view. Resolves its cover (cached after). public void ResolveGame(LuaTileViewModel game) => _ = game.EnsureResolvedAsync(_appInfo, _covers); - /// Set by App — the reverse of Manage's "Manage Build": open this game on the Manage page. + /// Set by App. The reverse of Manage's "Manage Build": open this game on the Manage page. public Action? NavigateToManage { get; set; } [RelayCommand] @@ -393,7 +393,7 @@ private void OpenInManage() } /// - /// Reload the page. Drops the selected game's cached depot info first — that's a session-long memory + /// Reload the page. Drops the selected game's cached depot info first. That's a session-long memory /// cache, so without this Refresh rebuilt the list and re-read the vault but still showed the "latest /// build on Steam" figures fetched the first time the game was opened. Only the selected game's data /// is on screen, so there's nothing to gain from clearing the rest. @@ -409,7 +409,7 @@ private Task Refresh() public ObservableCollection Variants { get; } = []; - /// The row the user has highlighted. Selecting only STAGES a switch — Apply performs it, so + /// The row the user has highlighted. Selecting only STAGES a switch. Apply performs it, so /// a stray click can't silently swap which version of a game Steam downloads. [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanApply))] @@ -418,7 +418,7 @@ private Task Refresh() private VariantRowViewModel? _selectedVariant; /// Set while a depot switch rebuilds the variant list, so the reselection it causes doesn't - /// fire its own (spinner-showing) depot reload — the caller does one quiet reload instead. + /// fire its own (spinner-showing) depot reload. The caller does one quiet reload instead. private bool _suppressDepotReload; partial void OnSelectedVariantChanged(VariantRowViewModel? value) @@ -429,7 +429,7 @@ partial void OnSelectedVariantChanged(VariantRowViewModel? value) } /// - /// True when the selected row IS the live lua — including when the live file is that preset plus + /// True when the selected row IS the live lua, including when the live file is that preset plus /// unsaved changes ( keeps such a row active). /// /// @@ -437,7 +437,7 @@ partial void OnSelectedVariantChanged(VariantRowViewModel? value) /// both have to answer "is the thing on screen the live file?", and when they /// disagreed the table described a preset's pre-edit bytes while edits went to the live file: a depot /// toggle wrote, the table reloaded from the stored copy, the switch sprang back, and the next click - /// asked for the state it already thought it had — which EditLive then discarded as a no-op. + /// asked for the state it already thought it had, which EditLive then discarded as a no-op. /// Two clicks, one write, no error. /// /// @@ -451,7 +451,7 @@ partial void OnSelectedVariantChanged(VariantRowViewModel? value) /// /// Re-entrancy guard. The capture calls below raise , whose handler - /// calls straight back into here — the inner pass would fill the list, then the outer pass would + /// calls straight back into here. The inner pass would fill the list, then the outer pass would /// resume past its own Clear() and append everything a second time (every build listed twice). /// private bool _refreshingVariants; @@ -473,27 +473,27 @@ private void RefreshVariantsCore() if (ActiveGame is not { } game) return; // Point the single Default slot at the live lua before showing anything, so a lua that matches no - // saved build — a fresh install, a hand edit, another tool — is listed as the Default rather than + // saved build (a fresh install, a hand edit, another tool) is listed as the Default rather than // going unrepresented. _vault.SyncDefaultFromLive(game.AppId); - // Pick up any _.lua the user already dropped in stplug-in — Steam ignores those + // Pick up any _.lua the user already dropped in stplug-in. Steam ignores those // files, so without this they'd sit there looking installed while doing nothing. _vault.AdoptLooseBuildLuas(game.AppId); - // A dropped-in build lua is inert until it's the .lua Steam reads — see the method's docs + // A dropped-in build lua is inert until it's the .lua Steam reads. See the method's docs // for why this only fires when the game has nothing live at all. _vault.ApplyBuildIfNothingLive(game.AppId); string? liveHash = _vault.GetActiveHash(game.AppId); var stored = _vault.GetVariants(game.AppId); - // Mid-edit the live bytes match nothing stored — they're a divergence FROM a variant, and that + // Mid-edit the live bytes match nothing stored. They're a divergence FROM a variant, and that // variant is what the user is looking at. Resolve it so the row stays active and selected. string? editBase = _vault.GetEditBase(game.AppId); string? activeHash = ResolveActiveHash(liveHash, stored, editBase); string? pendingHash = activeHash != liveHash ? activeHash : null; - // Live matches a stored variant exactly, so nothing is pending — drop any edit base still on + // Live matches a stored variant exactly, so nothing is pending. Drop any edit base still on // record. Only Apply/SaveText/UpdateVariant clear it otherwise, so undoing an edit by hand (toggle // a depot off, then on) left the header offering "Save to " with nothing left to save. if (pendingHash is null && editBase is not null) _vault.SetEditBase(game.AppId, null); @@ -523,10 +523,10 @@ private void RefreshVariantsCore() /// Usually just "the one whose bytes match the live lua". The case worth naming is an edit in /// progress: writing to the live file makes it match NOTHING (the variant it came from still holds /// its pre-edit bytes under its old content hash), so the answer has to be the variant it diverged - /// FROM — recorded as the edit base. Returning null there instead meant no row was active, the + /// FROM: recorded as the edit base. Returning null there instead meant no row was active, the /// selection fell through to whatever sorted first (the Default, which /// re-captures so it usually is), and the user was moved - /// off the build they were editing mid-edit — taking the next depot toggle with them, into the + /// off the build they were editing mid-edit. Taking the next depot toggle with them, into the /// Default's stored copy. /// /// @@ -538,7 +538,7 @@ private void RefreshVariantsCore() if (liveHash is null) return null; // nothing installed if (stored.Any(v => v.Hash == liveHash)) return liveHash; // ordinary case - // Diverged. Fall back to the live hash when there's no usable base — no row matches, which is + // Diverged. Fall back to the live hash when there's no usable base, no row matches, which is // the honest answer, and it must not throw. After a SyncDefaultFromLive this shouldn't arise. return editBase is not null && stored.Any(v => v.Hash == editBase) ? editBase : liveHash; } @@ -588,7 +588,7 @@ private void StartRename() IsRenaming = true; } - /// Save the new name. Only sets the label — the build id and file are untouched, so a + /// Save the new name. Only sets the label. The build id and file are untouched, so a /// renamed build still knows which build it is. [RelayCommand] private void CommitRename() @@ -607,7 +607,7 @@ private void CommitRename() [ObservableProperty] private bool _isEditing; [ObservableProperty] private string _editorText = ""; - /// Edit the LIVE lua (what Steam actually reads), not the selected variant — editing a + /// Edit the LIVE lua (what Steam actually reads), not the selected variant. Editing a /// stored copy the user isn't running would be a confusing no-op. [RelayCommand] private void Edit() @@ -624,7 +624,7 @@ private void CancelEdit() EditorText = ""; } - /// Write the editor's text to stplug-in and store it — updating the build it came from + /// Write the editor's text to stplug-in and store it. Updating the build it came from /// when there is one, else letting the Default slot pick the new bytes up (see /// ). [RelayCommand] @@ -646,7 +646,7 @@ private void SaveEdit() /// /// Put the live lua back into the variant it was edited from, keeping that variant's name and build - /// id. With no base — the edit started from the Default — there is nothing to save: the Default + /// id. With no base (the edit started from the Default) there is nothing to save: the Default /// tracks the live lua, so syncing the slot IS the save. "Save as preset" stays available as its own /// button for when the user wants to keep a named copy. /// @@ -658,7 +658,7 @@ private void SaveInPlace() string? baseHash = _vault.GetEditBase(game.AppId); if (baseHash is null) { _vault.SyncDefaultFromLive(game.AppId); return; } - // Saving doesn't change a single byte of the live lua — it just gives it a home — so the depot + // Saving doesn't change a single byte of the live lua (it just gives it a home), so the depot // table is already correct. UpdateVariant raises VaultChanged, so suppress across it too. LuaVariant? updated; _suppressDepotReload = true; @@ -673,7 +673,7 @@ private void SaveInPlace() } finally { _suppressDepotReload = false; } - if (updated is null) { SaveAsPreset(); return; } // base vanished — don't lose the edit + if (updated is null) { SaveAsPreset(); return; } // base vanished. Don't lose the edit _ = LoadDepotsAsync(quiet: true); _toast.Show(Resources.Strings.Builds_Title, @@ -688,7 +688,7 @@ private void SaveInPlace() public bool HasEditBase => EditBaseVariant is not null; - /// e.g. Save to “Build 24410208” — names the target so an overwrite is never a surprise. + /// e.g. Save to “Build 24410208”. Names the target so an overwrite is never a surprise. public string SaveInPlaceLabel => EditBaseVariant is { } v ? string.Format(Resources.Strings.Builds_Action_SaveTo, v.DisplayLabel) : Resources.Strings.Builds_Action_SaveAsPreset; @@ -721,12 +721,12 @@ private void SaveAsPreset() [ObservableProperty] private string? _depotError; /// True while inspecting a build that ISN'T the one Steam is running. The switches still - /// work — they edit that build in place — but the change lands differently (saved immediately, Steam + /// work (they edit that build in place), but the change lands differently (saved immediately, Steam /// unaffected until Apply), so it's worth saying so. [ObservableProperty] private bool _editingInactiveBuild; - // Unfiltered rows as BuildRows produced them. The public lists below are the SEARCHED view of these - // — filtering must never re-run BuildRows, which re-parses the lua and re-reads steamcmd. + // Unfiltered rows as BuildRows produced them. The public lists below are the SEARCHED view of these. + // Filtering must never re-run BuildRows, which re-parses the lua and re-reads steamcmd. private IReadOnlyList _allInLua = []; private IReadOnlyList _allMissing = []; private IReadOnlyList _allUnknown = []; @@ -744,7 +744,7 @@ partial void OnDepotSearchTextChanged(string value) OnPropertyChanged(nameof(HasNoDepotMatches)); } - /// True when a search is active and matched nothing anywhere — drives a "no matches" line so + /// True when a search is active and matched nothing anywhere. Drives a "no matches" line so /// a filtered-to-empty table doesn't just look broken. public bool HasNoDepotMatches => !string.IsNullOrWhiteSpace(DepotSearchText) && !IsLoadingDepots @@ -797,14 +797,14 @@ partial void OnUnknownChanged(IReadOnlyList value) [RelayCommand] private static void OpenSteamDb(DepotRow row) => SteamService.OpenUrl(row.SteamDbUrl); - /// Pin/unpin one depot — comments its setManifestid line in or out. + /// Pin/unpin one depot. Comments its setManifestid line in or out. [RelayCommand] private void ToggleLock(DepotRow row) { if (row.CanLock) EditLive(row, text => LuaEditor.SetDepotLocked(text, row.ToggleId, !row.IsLocked)); } - /// Switch one depot on/off — comments its addappid (decryption key) line in or out. + /// Switch one depot on/off. Comments its addappid (decryption key) line in or out. [RelayCommand] private void ToggleEnabled(DepotRow row) { @@ -818,25 +818,25 @@ private void ToggleEnabled(DepotRow row) /// Two routes, because there are two different things on screen: /// /// - /// The live lua (the row Steam is running) — write the file Steam reads. If that row is + /// The live lua (the row Steam is running). Write the file Steam reads. If that row is /// the Default, the edit lands in the Default slot immediately: skips /// the Default, so no edit base is parked, and the inside /// the refresh below adopts the new bytes. If it's a BUILD or preset, an edit base IS parked, the /// header's "Save to <name>" appears, and that row stays selected and badged ACTIVE with an - /// "unsaved" marker — see , which exists because the row silently + /// "unsaved" marker: see , which exists because the row silently /// losing its ACTIVE state here is what used to dump the user back on the Default mid-edit. - /// A build that isn't applied — write straight into that stored variant, keeping its + /// A build that isn't applied. Write straight into that stored variant, keeping its /// name and build id. There's no live file to park a pending edit in (the live lua is a *different* /// variant), so this saves immediately; Steam is untouched until the user hits Apply. /// /// /// - /// Editing must never be routed to the live file while a non-live variant is displayed — the table + /// Editing must never be routed to the live file while a non-live variant is displayed. The table /// on screen and the file being changed would be two different luas. /// /// /// - /// Two deliberate omissions, both reviewed — they look like oversights and aren't: + /// Two deliberate omissions, both reviewed. They look like oversights and aren't: /// /// /// No Save step, and no undo, for a toggle on the Default. The Default is the working @@ -860,10 +860,10 @@ private void EditLive(DepotRow row, Func edit) if (string.IsNullOrEmpty(before)) return; string after = edit(before); - if (after == before) return; // already in that state — don't churn the file or the UI + if (after == before) return; // already in that state. Don't churn the file or the UI // Suppress from BEFORE the write: both write paths raise VaultChanged, whose handler refreshes - // the variants and reselects — which would fire its own spinner-showing reload before we got to + // the variants and reselects, which would fire its own spinner-showing reload before we got to // guard it. Only the redundant depot reloads are held off; one quiet pass runs at the end. bool ok; string? newHash = null; @@ -902,11 +902,11 @@ private void EditLive(DepotRow row, Func edit) /// /// Record which variant the live lua is about to diverge FROM, so Save can put the edit back where it /// came from. Must run BEFORE the write: afterwards the bytes match nothing and the origin is lost. - /// A no-op once already diverged — the base is the variant the run of edits started at, not the last + /// A no-op once already diverged: the base is the variant the run of edits started at, not the last /// one touched. /// /// - /// The DEFAULT is deliberately never a base. It isn't a saved thing you write back to — it's the + /// The DEFAULT is deliberately never a base. It isn't a saved thing you write back to. It's the /// working copy, and it follows the live lua on its own (). /// Setting it as a base would both offer a pointless "Save to Default" button and, worse, park an /// EditBaseHash that makes the sync skip its own slot. @@ -925,7 +925,7 @@ private void RememberEditBase(long appId) /// /// /// True when re-reading after a depot switch. The rows are already on screen and every input is - /// cached in memory — blanking the table and showing "Loading depot info…" because a checkbox + /// cached in memory. Blanking the table and showing "Loading depot info…" because a checkbox /// changed just makes the list flash. Leaves the current rows visible and swaps them at the end. /// private async Task LoadDepotsAsync(bool quiet = false) @@ -933,7 +933,7 @@ private async Task LoadDepotsAsync(bool quiet = false) long token = ++_depotLoadToken; if (ActiveGame is not { } game) { - // Clear the unfiltered rows too — leaving them would let a later filter pass resurrect the + // Clear the unfiltered rows too: leaving them would let a later filter pass resurrect the // previous game's depots. _allInLua = []; _allMissing = []; _allUnknown = []; InLua = []; Missing = []; Unknown = []; DepotError = null; IsLoadingDepots = false; @@ -956,7 +956,7 @@ private async Task LoadDepotsAsync(bool quiet = false) bool editingLive = SelectionDescribesLive; EditingInactiveBuild = !editingLive; - // Read from wherever the edits GO. For a preset that isn't applied that's its stored copy — the + // Read from wherever the edits GO. For a preset that isn't applied that's its stored copy. The // point of the switcher, inspecting one before committing to it. For the live row it must be the // live file: mid-edit the stored copy is the PRE-edit bytes, and describing those put the switches // back the way they were before the user touched them (see SelectionDescribesLive). @@ -1003,7 +1003,7 @@ private async Task LoadDepotsAsync(bool quiet = false) /// /// Every id the lua declares, active or switched off, keyed by id. Disabled entries have to be /// included or a depot the user just switched off would drop out of "In lua" and take its switch - /// with it — the row has to stay put so it can be switched back on. + /// with it. The row has to stay put so it can be switched back on. /// private static Dictionary Declarations(LuaContents? lua) { @@ -1030,7 +1030,7 @@ private static Dictionary Declarations(LuaContents? lua) /// /// Split depots three ways: In-lua (declared), Missing (real depots the lua lacks), and Unknown - /// (noise — unnamed DLC, 0-byte/broken depots, shared redists). DLC named from the caches. + /// (noise. Unnamed DLC, 0-byte/broken depots, shared redists). DLC named from the caches. /// private void BuildRows(AppDepotInfo info, LuaContents? lua) { @@ -1071,7 +1071,7 @@ DepotRow Row(ContentDepot d) : $"https://steamdb.info/depot/{d.Id}/"; // The switches act on the id the lua actually DECLARES. For a DLC that's the DLC app id, not - // the depot id — toggling the depot id would rewrite a line that doesn't exist. + // the depot id. Toggling the depot id would rewrite a line that doesn't exist. long declId = declared.ContainsKey(d.Id) ? d.Id : d.DlcAppId ?? d.Id; declared.TryGetValue(declId, out var entry); bool inLua = entry is not null; @@ -1086,7 +1086,7 @@ DepotRow Row(ContentDepot d) } // In lua = the lua declares this id (a keyed depot OR a keyless DLC entitlement) or its DLC app - // id — including declarations the user has switched off, so they can switch them back on. + // id, including declarations the user has switched off, so they can switch them back on. bool IsInLua(ContentDepot d) => declared.ContainsKey(d.Id) || (d.DlcAppId is { } a && declared.ContainsKey(a)); // Unknown = noise to tuck away: shared redists, unnamed DLC, or 0-byte/broken depots. diff --git a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs index c077d11..805f590 100644 --- a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs @@ -23,11 +23,11 @@ public partial class SourceRowViewModel : ObservableObject public bool IsAvailable => Status == "available"; public string StatusLabel => Status.ToUpperInvariant(); - /// Hide the status badge when availability is unknown (e.g. a Hubcap row with no key set — - /// we can't check, so we don't show a misleading badge; the "needs key" hint covers it instead). + /// Hide the status badge when availability is unknown (e.g. a Hubcap row with no key set. + /// We can't check, so we don't show a misleading badge; the "needs key" hint covers it instead). public bool ShowStatus => Status != "unknown"; - /// Hubcap key not configured — show a hint instead of a download button. + /// Hubcap key not configured. Show a hint instead of a download button. [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanDownload))] private bool _isLocked; @@ -105,7 +105,7 @@ public partial class DownloadViewModel : ObservableObject public bool HasTopSellers => TopSellers.Count > 0; public bool HasNewReleases => NewReleases.Count > 0; - /// Show the featured strips only when the user hasn't started anything — no text typed, no + /// Show the featured strips only when the user hasn't started anything, no text typed, no /// results open, no game loaded, no install banner. Once they search or pick a game, the strips hide. public bool ShowFeatured => (TopSellers.Count > 0 || NewReleases.Count > 0) @@ -198,7 +198,7 @@ public async Task ProtocolInstall(long appId, Action? onComplete = await FetchCommand.ExecuteAsync(null); // In FastFetch the chain (fetch → download → install) completes inline by the time we get here, - // and the overwrite overlay is skipped in silent mode — so the outcome is fully settled. Report + // and the overwrite overlay is skipped in silent mode, so the outcome is fully settled. Report // InstallStatus on success/handled-failure, else whatever Error the chain left behind. if (onComplete is not null) { @@ -215,7 +215,7 @@ public async Task ProtocolInstall(long appId, Action? onComplete = /// FetchAsync pipeline the app UI uses (dynamic sources + Hubcap synth + key-gating + usage + /// FastFetch auto-download). The plugin polls state via the HTTP server and, when FastFetch is /// off, picks a source with . Unlike ProtocolInstall this - /// does NOT force FastFetch — it respects the user's setting so the plugin popup matches the app. + /// does NOT force FastFetch. It respects the user's setting so the plugin popup matches the app. public async Task StartPluginAddAsync(long appId) { _silentInstall = true; // headless: no surfaced window to confirm an overwrite on @@ -239,7 +239,7 @@ public async Task StartPluginAddAsync(long appId) if (HasDetails) await FetchCommand.ExecuteAsync(null); } - /// Plugin picked a source by name (FastFetch-off path) — download+install it headlessly. + /// Plugin picked a source by name (FastFetch-off path). Download+install it headlessly. public Task DownloadSourceByNameAsync(string name) { _silentInstall = true; @@ -288,7 +288,7 @@ public Task DownloadSourceByNameAsync(string name) [ObservableProperty] private bool _fastFetch; partial void OnFastFetchChanged(bool value) => _settings.FastFetch = value; - /// Re-sync the FastFetch toggle from the saved setting when the Add view appears — the Settings + /// Re-sync the FastFetch toggle from the saved setting when the Add view appears. The Settings /// page exposes the same toggle, and both VMs are singletons that otherwise only read it at startup. public void SyncFastFetch() => FastFetch = _settings.FastFetch; @@ -313,7 +313,7 @@ public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsSe /// /// Pre-fill from the Manage page "Update" action and fetch the app's details directly. The appid - /// is authoritative here, so resolve it straight away — don't route through search (bare numbers + /// is authoritative here, so resolve it straight away. Don't route through search (bare numbers /// are now treated as title queries, not appids, so the text path would search instead of resolve). /// public void SeedSearch(long appId) @@ -388,7 +388,7 @@ private async Task SearchDebouncedAsync(string query) } catch (OperationCanceledException) { /* superseded by newer input */ } catch (ApiException ex) { Error = ex.Message; } - catch { /* network hiccup during typing — ignore */ } + catch { /* network hiccup during typing. Ignore */ } finally { if (_searchCts == cts) IsSearching = false; @@ -496,7 +496,7 @@ private async Task FetchAsync() } DlcInfo = await _api.GetDlcInfoAsync(Details.AppId.ToString(), Details.BaseAppId); DlcDepots.Clear(); - // Included depots first, then missing — mirrors the website ordering + // Included depots first, then missing: mirrors the website ordering foreach (var d in DlcInfo?.Depots.OrderByDescending(d => d.Included) ?? Enumerable.Empty()) DlcDepots.Add(d); } @@ -504,7 +504,7 @@ private async Task FetchAsync() { var statuses = await _api.CheckSourcesAsync(Details.AppId.ToString()); - // The manifest backend no longer reports the Hubcap/Morrenus source — synthesize it + // The manifest backend no longer reports the Hubcap/Morrenus source. Synthesize it // ourselves from a direct Hubcap status check (or show it locked if no key is set). await AddHubcapSourceAsync(statuses, Details.AppId.ToString()); @@ -660,7 +660,7 @@ public async Task DownloadFromSourceAsync(SourceRowViewModel source) if (source.NeedsKey) await ApplyHubcapStateAsync(); // refresh the key's X/Y usage badge else await RefreshStandardUsageAsync(); // non-Hubcap usage just changed (counts toward 25/day) - // If a lua for this game is already installed, confirm with a before/after diff first — unless + // If a lua for this game is already installed, confirm with a before/after diff first, unless // this is a silent install, which has no surfaced window and just overwrites. string? existing = _installer.ReadInstalledLua(appId); if (!_silentInstall && existing is not null && await ShowOverwriteConfirmAsync(existing, download.FilePath, appId)) @@ -684,7 +684,7 @@ public async Task DownloadFromSourceAsync(SourceRowViewModel source) } } - /// DLC lua: download and install silently (it's just an unlock — no confirm). + /// DLC lua: download and install silently (it's just an unlock, no confirm). [RelayCommand] private async Task GenerateDlcAsync() { @@ -702,7 +702,7 @@ private async Task GenerateDlcAsync() var result = _installer.InstallLua(download.FilePath, appId); ReportInstall(result); - DeleteStaged(download.FilePath); // installed — drop the temp staging copy + DeleteStaged(download.FilePath); // installed. Drop the temp staging copy await RefreshStandardUsageAsync(); // DLC generate counts toward 25/day } catch (ApiException ex) @@ -726,7 +726,7 @@ private async Task ShowOverwriteConfirmAsync(string oldLuaPath, string new { var oldLua = LuaFileParser.Parse(oldLuaPath, appId); var newLua = ExtractLuaFromZip(newZipPath, appId); - if (newLua is null) { InstallZipAndReport(newZipPath, appId); return false; } // can't diff — just install + if (newLua is null) { InstallZipAndReport(newZipPath, appId); return false; } // can't diff. Just install var diff = LuaFileParser.Diff(oldLua, newLua); @@ -769,7 +769,7 @@ private void ConfirmOverwrite() private void CancelOverwrite() { IsConfirmingOverwrite = false; - if (_pendingInstall is { } p) DeleteStaged(p.zipPath); // not installing — don't leave it in temp + if (_pendingInstall is { } p) DeleteStaged(p.zipPath); // not installing. Don't leave it in temp _pendingInstall = null; InstallStatus = Resources.Strings.Add_Status_Cancelled; InstallFailed = false; @@ -779,12 +779,12 @@ private void InstallZipAndReport(string zipPath, long appId) { _installedAppId = appId; // remember for the banner's Reveal, even after the search is cleared // The download is always saved as ".zip", but some sources return a BARE .lua (no zip - // wrapper) — unzipping that throws "End of Central Directory record could not be found". Sniff + // wrapper). Unzipping that throws "End of Central Directory record could not be found". Sniff // the bytes instead of trusting the extension: real zips start with "PK\x03\x04". ReportInstall(IsZip(zipPath) ? _installer.InstallZip(zipPath, appId) : _installer.InstallLua(zipPath, appId)); - DeleteStaged(zipPath); // installed into Steam — the temp staging copy is no longer needed + DeleteStaged(zipPath); // installed into Steam. The temp staging copy is no longer needed } /// Best-effort delete of a staged download after it's been installed, so nothing piles @@ -949,7 +949,7 @@ private void ResetResults() /// /// Extract an appid from a Steam/SteamDB store URL (…/app/<id>), or from a bare number of 5+ - /// digits. Short numbers go through normal title search instead — game titles like "007", "500" + /// digits. Short numbers go through normal title search instead. Game titles like "007", "500" /// or "1942" are numbers too. Tradeoff: very old games with short appids (e.g. 500 = Left 4 Dead) /// won't auto-resolve from a bare number and must be searched or pasted as a URL. /// diff --git a/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs b/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs index 3648b02..e8e7537 100644 --- a/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs @@ -140,11 +140,11 @@ private async Task ProcessQueueAsync() private async Task ConfirmIfOverwriteAsync(string path, long appId, bool isZip) { string? existing = _installer.ReadInstalledLua(appId); - if (existing is null) return false; // fresh install — no confirm + if (existing is null) return false; // fresh install, no confirm var oldLua = LuaFileParser.Parse(existing, appId); var newLua = isZip ? ExtractLuaFromZip(path, appId) : LuaFileParser.Parse(path, appId); - if (newLua is null) return false; // can't diff — just install (caller proceeds) + if (newLua is null) return false; // can't diff. Just install (caller proceeds) var diff = LuaFileParser.Diff(oldLua, newLua); await _appList.EnsureLoadedAsync(); diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index 97891a4..c16be97 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -202,7 +202,7 @@ private void ApplyFilter() if (SelectedTagId is { } tag) shown = shown.Where(g => g.TagIds.Contains(tag)); 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 + // Hand the filtered list to the base: it slices the visible page and (via OnPageSliced) warms // that page's covers. SetFiltered(shown); } @@ -246,8 +246,8 @@ private async Task OpenGame(FixGameCardVm game) { _allFixes = data.Fixes.Select(f => new FixItemVm(f)).ToList(); - // 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). + // 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). var distinct = _allFixes.SelectMany(f => f.Tags) .GroupBy(t => t.Id).Select(g => g.First()) .OrderBy(t => t.Name).ToList(); @@ -258,7 +258,7 @@ private async Task OpenGame(FixGameCardVm game) ApplyFixFilter(); } } - catch { /* leave empty — flyout shows "no fixes" */ } + catch { /* leave empty. Flyout shows "no fixes" */ } finally { IsLoadingFixes = false; } } @@ -340,7 +340,7 @@ private void InstallManifest(DownloadedFile file, long appId, string gameName) var result = isZip ? installer.InstallZip(file.FilePath, appId, forceLocked: true) : installer.InstallLuaFile(file.FilePath, appId, forceLocked: true); - DeleteStaged(file.FilePath); // consumed by the install — drop the temp staging copy + DeleteStaged(file.FilePath); // consumed by the install. Drop the temp staging copy if (result.AnyFailed) { @@ -356,7 +356,7 @@ private void InstallManifest(DownloadedFile file, long appId, string gameName) : string.Format(Resources.Strings.Fixes_Toast_FixInstalled_Restart, gameName)); } - /// Fix slot: only applicable if the game is installed — extract the zip into its folder. + /// Fix slot: only applicable if the game is installed. Extract the zip into its folder. private void ApplyFix(DownloadedFile file, long appId, string gameName) { string? installDir = library.GetInstallDir(appId); @@ -395,7 +395,7 @@ private void ApplyFix(DownloadedFile file, long appId, string gameName) } finally { - DeleteStaged(file.FilePath); // archive is disposed by now — drop the temp staging copy + DeleteStaged(file.FilePath); // archive is disposed by now. Drop the temp staging copy } } diff --git a/src/LuaToolsGui/ViewModels/HomeViewModel.cs b/src/LuaToolsGui/ViewModels/HomeViewModel.cs index 42d27f7..d074318 100644 --- a/src/LuaToolsGui/ViewModels/HomeViewModel.cs +++ b/src/LuaToolsGui/ViewModels/HomeViewModel.cs @@ -127,7 +127,7 @@ private async Task InstallPlugin() } } - /// Called when the page is shown — refresh everything. + /// Called when the page is shown. Refresh everything. public async Task LoadAsync() { RefreshSteam(); @@ -191,7 +191,7 @@ public async Task RefreshLibraryAsync() long appid = long.Parse(f.name); var info = new FileInfo(f.path); string? name = _appList.GetName(appid) ?? _appInfo.GetCached(appid)?.Name; - // Base = when added to the folder; if edited since (LastWrite later), use that — newer is more relevant. + // Base = when added to the folder; if edited since (LastWrite later), use that. Newer is more relevant. var added = info.LastWriteTime > info.CreationTime ? info.LastWriteTime : info.CreationTime; return new LuaTileViewModel(appid, f.path, added, name ?? string.Format(Resources.Strings.Common_AppFallback, appid), name is null); }) diff --git a/src/LuaToolsGui/ViewModels/LaunchOptionsViewModel.cs b/src/LuaToolsGui/ViewModels/LaunchOptionsViewModel.cs index 31cf597..339f777 100644 --- a/src/LuaToolsGui/ViewModels/LaunchOptionsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/LaunchOptionsViewModel.cs @@ -9,7 +9,7 @@ namespace LuaToolsGui.ViewModels; -/// Bindable wrapper around a — the model is a plain POCO because it +/// Bindable wrapper around a . The model is a plain POCO because it /// also gets serialized into the mod store. public partial class LaunchEntryViewModel : ObservableObject { @@ -79,7 +79,7 @@ public partial class LaunchOptionsViewModel : ObservableObject private LaunchState? _state; /// What the entry list looked like when it was loaded, in the exact shape a save would - /// produce — so comparing against it answers "would saving change anything?". + /// produce, so comparing against it answers "would saving change anything?". private List _baseline = []; /// Entries we've hooked on, so field edits (not just @@ -94,7 +94,7 @@ public partial class LaunchOptionsViewModel : ObservableObject ["windows", "macos", "linux", ""]; /// Common branch names, by frequency across 3,575 real entries that use BetaKey. It's a - /// free-text BRANCH NAME (1,794 distinct values in the wild), not a yes/no — hence an editable + /// free-text BRANCH NAME (1,794 distinct values in the wild), not a yes/no, hence an editable /// combo rather than a checkbox. public ObservableCollection BranchOptions { get; } = ["", "beta", "default", "development", "test", "legacy", "steam_legacy", "experimental"]; @@ -117,7 +117,7 @@ public partial class LaunchOptionsViewModel : ObservableObject public bool HasSelection => Selected is not null; - /// True once this game has a stored edit — enables Restore. + /// True once this game has a stored edit. Enables Restore. [ObservableProperty] private bool _isModded; /// True when the entry list no longer matches . @@ -189,7 +189,7 @@ public async Task LoadAsync(long appId, string gameName) InstallPath = state.InstallDir; IsModded = state.IsModded; - // Show the staged edit if there is one — that's what the user last asked for, even if Steam + // Show the staged edit if there is one: that's what the user last asked for, even if Steam // has since overwritten the live file. var source = _launch.Store.Get((int)appId)?.Desired ?? state.Current; Entries.Clear(); @@ -197,7 +197,7 @@ public async Task LoadAsync(long appId, string gameName) Selected = Entries.FirstOrDefault(); // Baseline comes back out through ToOption() rather than from `source` directly, so it's - // identical to what a save would write — otherwise trimming alone would read as an edit. + // identical to what a save would write. Otherwise trimming alone would read as an edit. _baseline = Entries.Select(e => e.ToOption()).ToList(); LaunchOption.Renumber(_baseline); IsDirty = false; @@ -250,7 +250,7 @@ private void DeleteEntry() /// /// Reorder within the list. This only becomes durable because renumbers the keys - /// 0,1,2… in list order — Steam takes the running order from the config.launch KEY, not from + /// 0,1,2… in list order. Steam takes the running order from the config.launch KEY, not from /// the order entries happen to sit in the file, so moving an entry while keeping its original key /// would change nothing outside this dialog. (SteamEdit does the same: it reads entries /// OrderBy(key) and rewrites them as Count.ToString().) @@ -267,7 +267,7 @@ private void Move(int delta) // ── save / restore ────────────────────────────────────────────── - /// Save is only offered when there's an actual change to write — applying closes Steam, so + /// Save is only offered when there's an actual change to write. Applying closes Steam, so /// a no-op save is worse than useless. public bool CanSave => _state is not null && Error is null && IsDirty; @@ -318,7 +318,7 @@ private void Restore() private void Cancel() => CloseWith?.Invoke(false); /// - /// Write into appinfo.vdf. Steam is closed first — it owns the file and regenerates it from its own + /// Write into appinfo.vdf. Steam is closed first. It owns the file and regenerates it from its own /// in-memory state, so writing underneath a running client achieves nothing. /// private void ApplyToSteam(IReadOnlyDictionary> edits) diff --git a/src/LuaToolsGui/ViewModels/MainViewModel.cs b/src/LuaToolsGui/ViewModels/MainViewModel.cs index fd5c7d0..c00aaca 100644 --- a/src/LuaToolsGui/ViewModels/MainViewModel.cs +++ b/src/LuaToolsGui/ViewModels/MainViewModel.cs @@ -19,7 +19,7 @@ public partial class MainViewModel : ObservableObject private static string ReadVersion() { - // InformationalVersion carries the csproj (may have a "+commit" suffix — trim it). + // InformationalVersion carries the csproj (may have a "+commit" suffix. Trim it). var info = Assembly.GetExecutingAssembly() .GetCustomAttribute()?.InformationalVersion; var ver = info ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "?"; diff --git a/src/LuaToolsGui/ViewModels/ManageViewModel.cs b/src/LuaToolsGui/ViewModels/ManageViewModel.cs index 9d3f2f3..3100f72 100644 --- a/src/LuaToolsGui/ViewModels/ManageViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ManageViewModel.cs @@ -16,7 +16,7 @@ public partial class LuaTileViewModel : ObservableObject public string FilePath { get; } public DateTime AddedAt { get; } // Invariant culture so the month is always the 3-letter abbreviation ("Jun", not "June" or a - // localized long form), 2-digit year ("'26") — keeps the combined "Added … • Released …" line + // localized long form), 2-digit year ("'26"). Keeps the combined "Added … • Released …" line // short enough to fit the card. public string AddedLabel => "Added " + AddedAt.ToString(@"MMM d, \'yy", System.Globalization.CultureInfo.InvariantCulture); @@ -71,7 +71,7 @@ public LuaTileViewModel(long appId, string filePath, DateTime addedAt, string na AddedAt = addedAt; _name = name; _nameIsPlaceholder = nameIsPlaceholder; - // Cover stays blank until resolved — avoids flashing Steam's "Header Capsule" placeholder. + // Cover stays blank until resolved: avoids flashing Steam's "Header Capsule" placeholder. } /// @@ -80,7 +80,7 @@ public LuaTileViewModel(long appId, string filePath, DateTime addedAt, string na /// public async Task EnsureResolvedAsync(SteamAppInfoCache appInfo, CoverCache covers) { - // Refresh the release label whenever a card is (re)shown — its details may have backfilled + // Refresh the release label whenever a card is (re)shown. Its details may have backfilled // since the last ApplyFilter pass. Cheap (reads the memoized filter cache). if (string.IsNullOrEmpty(ReleaseLabel)) OnUi(() => UpdateReleaseLabel(appInfo)); @@ -112,7 +112,7 @@ public async Task EnsureResolvedAsync(SteamAppInfoCache appInfo, CoverCache cove /// /// Ensure the cover image FILE exists on disk (no decode, no UI). Used by the background prefetch /// so the visible page can decode instantly. Order: disk → CDN guess → appdetails header_image. - /// Only flags "no cover" when Steam definitively has none — never on a transient rate-limit. + /// Only flags "no cover" when Steam definitively has none, never on a transient rate-limit. /// public static async Task ResolveCoverFileAsync( long appId, SteamAppInfoCache appInfo, CoverCache covers, Action? onName = null) @@ -133,7 +133,7 @@ public async Task EnsureResolvedAsync(SteamAppInfoCache appInfo, CoverCache cove local = await covers.EnsureAsync(appId, info!.HeaderImage!); // info != null means appdetails answered (so an empty header = genuinely no cover). - // info == null means we couldn't reach it (rate-limited/offline) — don't give up, retry later. + // info == null means we couldn't reach it (rate-limited/offline). Don't give up, retry later. if (local is null && info is not null) covers.MarkMissing(appId); return local; } @@ -146,7 +146,7 @@ public async Task EnsureResolvedAsync(SteamAppInfoCache appInfo, CoverCache cove var bmp = new BitmapImage(); bmp.BeginInit(); bmp.CacheOption = BitmapCacheOption.OnLoad; // load fully, release the file handle - bmp.DecodePixelWidth = 248; // tile width — faster decode, less memory + bmp.DecodePixelWidth = 248; // tile width. Faster decode, less memory bmp.UriSource = new Uri(path, UriKind.Absolute); bmp.EndInit(); bmp.Freeze(); @@ -160,7 +160,7 @@ public async Task EnsureResolvedAsync(SteamAppInfoCache appInfo, CoverCache cove private void SetName(string name) { - _nameIsPlaceholder = false; // got a real name — don't re-resolve + _nameIsPlaceholder = false; // got a real name. Don't re-resolve OnUi(() => Name = name); } @@ -233,7 +233,7 @@ public partial class ManageViewModel : PagedListViewModel // How many library apps still lack cached details while a filter is active (can't be filtered yet). // The text updates as the count ticks down. HasPendingDetails (which drives the spinner's - // Visibility) is a SEPARATE bool that only flips at the 0/non-0 boundary — so a count change + // Visibility) is a SEPARATE bool that only flips at the 0/non-0 boundary, so a count change // doesn't re-fire the visibility binding and restart the ProgressRing animation. [ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterPendingText))] @@ -289,12 +289,12 @@ public ManageViewModel(SteamService steam, SteamAppListCache appList, SteamAppIn partial void OnSearchTextChanged(string value) => ApplyFilter(); - /// Called by the view as a tile scrolls into view — resolves its cover (cached after). + /// Called by the view as a tile scrolls into view. Resolves its cover (cached after). public void ResolveTile(LuaTileViewModel tile) => _ = tile.EnsureResolvedAsync(_appInfo, _covers); /// /// Open a game's detail flyout by appid (Home "recently added" / Add page "Reveal"). Re-scans if - /// the tile isn't found — the library may be stale (e.g. the game was removed then re-added, or + /// the tile isn't found. The library may be stale (e.g. the game was removed then re-added, or /// this VM hasn't loaded yet). If it's genuinely gone, tell the user instead of failing silently. /// public async Task OpenDetailForAppIdAsync(long appId) @@ -328,7 +328,7 @@ private async Task OpenDetailAsync(LuaTileViewModel tile) // was never resolved and the pullout image is blank. Ensure it here (idempotent). await tile.EnsureResolvedAsync(_appInfo, _covers); - // No cached blob yet (the background backfill hasn't reached this game) — fetch it at + // No cached blob yet (the background backfill hasn't reached this game). Fetch it at // interactive priority, then fill the section in. Re-checked afterwards because the user can // close the flyout or switch games while that request is in flight. if (Overview is null && await _appInfo.EnsureFullDetailsAsync(tile.AppId) && SelectedTile == tile) @@ -346,7 +346,7 @@ private void CloseDetail() [RelayCommand] private void ManageBuild(LuaTileViewModel tile) => NavigateToBuilds?.Invoke(tile.AppId); - /// Set by App — opens the launch-option editor for a game (appid, name). + /// Set by App. Opens the launch-option editor for a game (appid, name). public Action? OpenLaunchOptions { get; set; } /// Edit this game's Steam launch options (the entries behind the Play button). @@ -493,7 +493,7 @@ private void DeleteSelected() } else failed++; } - // Rebuild the (filtered) grid in one pass — reliable refresh vs. per-item Tiles.Remove on the + // Rebuild the (filtered) grid in one pass: reliable refresh vs. per-item Tiles.Remove on the // virtualized panel, which didn't always visually update when filters were active. ApplyFilter(); SelectedCount = _all.Count(t => t.IsSelected); @@ -540,7 +540,7 @@ private void RemoveTile(LuaTileViewModel tile) tile.SelectionChanged = null; _all.Remove(tile); if (SelectedTile == tile) CloseDetail(); - ApplyFilter(); // rebuild the (filtered) grid in one pass — reliable virtualized refresh + ApplyFilter(); // rebuild the (filtered) grid in one pass. Reliable virtualized refresh } /// Scan config\stplug-in for <appid>.lua files. Called when the page is shown. @@ -568,7 +568,7 @@ public async Task LoadAsync() return; } - // Bulk game-name list (downloaded once, cached) — gives every game a name with no rate limit. + // Bulk game-name list (downloaded once, cached). Gives every game a name with no rate limit. await _appList.EnsureLoadedAsync(); var tiles = await Task.Run(() => @@ -578,7 +578,7 @@ public async Task LoadAsync() var info = new FileInfo(f.Path); string? name = _appList.GetName(f.AppId) ?? ParseLuaName(f.Path) ?? _appInfo.GetCached(f.AppId)?.Name; bool placeholder = name is null; - // Base = when added to the folder; if edited since (LastWrite later), use that — newer is more relevant. + // Base = when added to the folder; if edited since (LastWrite later), use that. Newer is more relevant. var added = info.LastWriteTime > info.CreationTime ? info.LastWriteTime : info.CreationTime; return new LuaTileViewModel(f.AppId, f.Path, added, name ?? string.Format(Resources.Strings.Common_AppFallback, f.AppId), placeholder); }) @@ -601,7 +601,7 @@ public async Task LoadAsync() } /// - /// Warm cover FILES for the whole library in the background (download only — no decode, no UI), + /// Warm cover FILES for the whole library in the background (download only, no decode, no UI), /// so when you jump to any page it decodes instantly from disk. /// private void StartCoverPrefetch(IReadOnlyList tiles) @@ -666,8 +666,8 @@ private Task Refresh() => RefreshWithCooldownAsync(async () => private void RefreshPendingCount() => PendingDetailsCount = _all.Count(t => _appInfo.GetFilterData(t.AppId) is null); - /// True (default) for a user-initiated filter/search/sort/page-size change — - /// jump back to page 1. False for passive re-renders (backfill completing) so the user stays on + /// True (default) for a user-initiated filter/search/sort/page-size change. + /// Jump back to page 1. False for passive re-renders (backfill completing) so the user stays on /// their current page; the page is still clamped into range below. private void ApplyFilter(bool resetPage = true) { @@ -679,7 +679,7 @@ private void ApplyFilter(bool resetPage = true) // Text search (name / appid). if (!string.IsNullOrEmpty(q)) result = result.Where(t => t.Matches(q)); - // Detail-based filters. A tile with no cached details yet can't be confirmed to match — keep + // Detail-based filters. A tile with no cached details yet can't be confirmed to match. Keep // it visible while details are still being fetched. result = result.Where(t => { @@ -696,7 +696,7 @@ private void ApplyFilter(bool resetPage = true) var list = result.ToList(); list = SortTiles(list); - // How many library apps still lack details — shown always while backfilling (even with no + // How many library apps still lack details: shown always while backfilling (even with no // filters), since on startup nothing's filtered but details are still loading. PendingDetailsCount = _all.Count(t => _appInfo.GetFilterData(t.AppId) is null); @@ -781,7 +781,7 @@ private void PopulateFilterOptions() RebuildOptionList(YearOptions, years.OrderByDescending(y => y).Select(y => y.ToString())); } - /// Sync a dropdown's options to WITHOUT a full Clear() — clearing + /// Sync a dropdown's options to WITHOUT a full Clear(). Clearing /// drops the ComboBox's bound SelectedItem (which made Type/Genre/Year render blank). "Any" stays /// at index 0; we add new values and remove stale ones in place. private static void RebuildOptionList(ObservableCollection target, IEnumerable values) @@ -816,7 +816,7 @@ private static void RebuildOptionList(ObservableCollection target, IEnum return string.IsNullOrWhiteSpace(name) ? null : name; } } - catch { /* unreadable — fall back to appid */ } + catch { /* unreadable. Fall back to appid */ } return null; } } diff --git a/src/LuaToolsGui/ViewModels/PagedListViewModel.cs b/src/LuaToolsGui/ViewModels/PagedListViewModel.cs index 39ba6e8..b2594e2 100644 --- a/src/LuaToolsGui/ViewModels/PagedListViewModel.cs +++ b/src/LuaToolsGui/ViewModels/PagedListViewModel.cs @@ -18,14 +18,14 @@ public abstract partial class PagedListViewModel : ObservableObject private DateTime _lastRefresh; // refresh-button cooldown private int _filteredCount; // count of the filtered list (drives TotalPages) - // When true, a CurrentPage change won't re-slice — used while SetFiltered resets/clamps the page, + // When true, a CurrentPage change won't re-slice. Used while SetFiltered resets/clamps the page, // since it does its own single slice at the end (avoids slicing the stale _filtered list twice). private bool _suppressPageSlice; /// Set by the view to scroll the grid back to the top (on page change). public Action? ScrollToTop { get; set; } - /// The current page of filtered items — shown in the virtualized grid. (When the page size + /// The current page of filtered items. Shown in the virtualized grid. (When the page size /// is "All" this holds the entire filtered list, i.e. a single infinite-scroll behaviour.) [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasItems))] @@ -42,7 +42,7 @@ public abstract partial class PagedListViewModel : ObservableObject [ObservableProperty] private string _emptyMessage = ""; // Single source of truth: the grid shows iff there are items; "empty" only when a load finished - // with no results. Cards render only when not loading — keeps the spinner from co-rendering over + // with no results. Cards render only when not loading. Keeps the spinner from co-rendering over // the grid. public bool HasItems => Items.Count > 0; public bool ShowItems => HasItems && !IsLoading; @@ -83,7 +83,7 @@ public abstract partial class PagedListViewModel : ObservableObject partial void OnSelectedPageSizeChanged(string value) { SavePageSizeSetting(PageSize); - // Page size changed the pagination, not the filter — reset to page 1 and re-slice the existing + // Page size changed the pagination, not the filter. Reset to page 1 and re-slice the existing // _filtered list (no re-filter needed). _suppressPageSlice = true; CurrentPage = 1; @@ -120,13 +120,13 @@ protected void InitPageSize(int persisted) => /// Persist the chosen page size (0 = "All"). Overridden by subclasses that have settings. protected virtual void SavePageSizeSetting(int size) { } - /// Hook fired after the visible page is (re)built — e.g. to warm the slice's cover images. + /// Hook fired after the visible page is (re)built, e.g. to warm the slice's cover images. /// Default is a no-op. protected virtual void OnPageSliced(IReadOnlyList slice) { } /// Replace the filtered+sorted master list and render its current page. This is the single /// entry point subclasses call after (re)building their filter result. - /// True (default) for a user-initiated filter/search/sort change — jump back + /// True (default) for a user-initiated filter/search/sort change. Jump back /// to page 1. False for passive re-renders so the user stays on their current page; the page is still /// clamped into range. protected void SetFiltered(IEnumerable filtered, bool resetPage = true) @@ -145,7 +145,7 @@ protected void SetFiltered(IEnumerable filtered, bool resetPage = true) } /// Render the current page from into . With page - /// size "All" this shows the whole filtered list. Re-slices only — never re-filters — so it's cheap + /// size "All" this shows the whole filtered list. Re-slices only (never re-filters), so it's cheap /// to call on page changes. private void ApplyPageSlice() { diff --git a/src/LuaToolsGui/ViewModels/PluginViewModel.cs b/src/LuaToolsGui/ViewModels/PluginViewModel.cs index b35d79f..2eb358d 100644 --- a/src/LuaToolsGui/ViewModels/PluginViewModel.cs +++ b/src/LuaToolsGui/ViewModels/PluginViewModel.cs @@ -5,7 +5,7 @@ namespace LuaToolsGui.ViewModels; /// -/// "Plugin" page: the store-page plugin MANAGER. The app no longer bundles the frontend — it installs, +/// "Plugin" page: the store-page plugin MANAGER. The app no longer bundles the frontend. It installs, /// updates, and removes the LuaTools plugin (the "Add via LuaTools" button on Steam store pages) by /// downloading it from GitHub releases via . One install path /// (LuaLoader); if the Millennium mod is present it just coexists (and install disables Millennium's own @@ -27,7 +27,7 @@ public PluginViewModel(PluginInstallerService installer, ToastService toast) [ObservableProperty] private string _frontendStatus = Resources.Strings.Plugin_Checking; [ObservableProperty] private string _dllStatus = Resources.Strings.Plugin_Checking; - // Per-component status flags — drive the colored status icons in the view (green check / amber + // Per-component status flags: drive the colored status icons in the view (green check / amber // warning / grey dismiss). The *Status strings above stay the row label text. [ObservableProperty] private bool _frontendInstalled; [ObservableProperty] private bool _dllOk; @@ -47,14 +47,14 @@ public PluginViewModel(PluginInstallerService installer, ToastService toast) [NotifyPropertyChangedFor(nameof(ShowUpToDate))] private bool _updateAvailable; - /// True when the install button should be the loud green primary CTA — only when there's an + /// True when the install button should be the loud green primary CTA. Only when there's an /// actionable state (fresh install or an update). A healthy up-to-date "Reinstall" stays secondary. public bool InstallIsPrimary => !IsInstalled || UpdateAvailable; - /// Green "Up to date" pill on the version line — only when installed and nothing to update. + /// Green "Up to date" pill on the version line. Only when installed and nothing to update. public bool ShowUpToDate => IsInstalled && !UpdateAvailable; - /// True when the Millennium mod is detected — shown as a "coexisting" info line, not a card. + /// True when the Millennium mod is detected. Shown as a "coexisting" info line, not a card. [ObservableProperty] private bool _millenniumCoexisting; [ObservableProperty] diff --git a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs index bdd9020..8d76948 100644 --- a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs @@ -42,7 +42,7 @@ public partial class SettingsViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowBotLinkBanner))] private bool _isBotProvisioned; - /// Session-only — resets next launch so the banner re-checks on every startup. + /// Session-only. Resets next launch so the banner re-checks on every startup. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowBotLinkBanner))] private bool _botBannerDismissed; @@ -93,7 +93,7 @@ partial void OnStartWithWindowsChanged(bool value) else key.DeleteValue(RunValueName, throwOnMissingValue: false); } - catch { /* registry write blocked — setting is still saved, just not applied this run */ } + catch { /* registry write blocked. Setting is still saved, just not applied this run */ } } /// Minimize to the system tray instead of the taskbar. Persisted via SettingsService. @@ -159,7 +159,7 @@ partial void OnSelectedLanguageChanged(LanguageOption value) } // ── Hubcap API key ────────────────────────────────────────────── - /// The key the user is typing/pasting. Starts blank — the saved key is never shown back. + /// The key the user is typing/pasting. Starts blank. The saved key is never shown back. [ObservableProperty] private string _hubcapKeyInput = ""; /// Status line under the key box (usage/expiry on success, or an error). Null = hidden. @@ -201,7 +201,7 @@ partial void OnSelectedLanguageChanged(LanguageOption value) HubcapStats is { DailyLimit: > 0 } ? (double)HubcapStats.DailyUsage / HubcapStats.DailyLimit : 0; /// Show the usage card whenever a key is configured. It renders a loading state until stats - /// arrive — never blank — so the section can't look broken while the fetch is in flight or after a + /// arrive (never blank), so the section can't look broken while the fetch is in flight or after a /// transient failure (the key input is collapsed once configured, so this card is the only content). public bool ShowHubcapStats => HubcapIsKeyConfigured; @@ -217,7 +217,7 @@ partial void OnSelectedLanguageChanged(LanguageOption value) /// Set by App: actually relaunch the app (used after a language change). public Action? RequestRestart { get; set; } - /// Show the "language changed — restart now?" toast. Wired here so the VM stays UI-agnostic; + /// Show the "language changed. Restart now?" toast. Wired here so the VM stays UI-agnostic; /// App provides the toast + restart action. public Action? RequestRestartPrompt { get; set; } @@ -233,7 +233,7 @@ public SettingsViewModel(SettingsService settings, AuthService auth, SteamServic _autoUpdateApps = settings.AutoUpdateApps; // init from saved value (default ON) without triggering Save _fastFetch = settings.FastFetch; _donateKeys = settings.DonateKeys; - _startWithWindows = settings.StartWithWindows; // default OFF — init without triggering the registry write + _startWithWindows = settings.StartWithWindows; // default OFF. Init without triggering the registry write _minimizeToTray = settings.MinimizeToTray; _hubcapIsKeyConfigured = !string.IsNullOrEmpty(settings.HubcapApiKey); @@ -279,7 +279,7 @@ private async Task SignInAsync() /// The 6-char code the user typed from the Discord /login DM. [ObservableProperty] private string _codeInput = ""; - /// True while redeeming — disables the Redeem button via . + /// True while redeeming. Disables the Redeem button via . [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanRedeemCode))] private bool _isRedeemingCode; @@ -356,7 +356,7 @@ private void OpenHubcap() => // ── Hubcap key management ─────────────────────────────────────── - /// Called by the View when loaded — auto-refreshes stats if a key is saved. + /// Called by the View when loaded. Auto-refreshes stats if a key is saved. public void OnViewLoaded() { if (HubcapIsKeyConfigured) @@ -407,7 +407,7 @@ private async Task ValidateAndSaveHubcapKeyAsync() var stats = await _hubcap.GetStatsAsync(key); if (stats is null) { - // Could be a bad/expired key (401) or a network problem — both surface as null. + // Could be a bad/expired key (401), or a network problem. Both surface as null. ShowHubcapStatus(Resources.Strings.Settings_HubcapKeyError, isError: true); return; } @@ -441,7 +441,7 @@ private void ShowHubcapStatus(string text, bool isError) HubcapKeyStatusColor = isError ? "#f87171" : "#22c55e"; } - /// "4/10 today" — appends "· expires yyyy-MM-dd" when the key has an expiry. + /// "4/10 today". Appends "· expires yyyy-MM-dd" when the key has an expiry. private static string FormatHubcapStats(HubcapStats stats) { string usage = string.Format(Resources.Strings.Settings_HubcapKeyOk, stats.DailyUsage, stats.DailyLimit); diff --git a/src/LuaToolsGui/Views/BuildsView.xaml b/src/LuaToolsGui/Views/BuildsView.xaml index d3c5d96..2eabf58 100644 --- a/src/LuaToolsGui/Views/BuildsView.xaml +++ b/src/LuaToolsGui/Views/BuildsView.xaml @@ -158,7 +158,7 @@ - + windowed PageNumbers (as Manage renders), but there's no room for them here, and + the list is a picker, not a browsing grid. Always shown (even on a single page), so the results-per-page control stays reachable; the arrows just disable. --> Dispatcher.BeginInvoke(() => return null; } - // Both fire as a virtualized game row is realized/scrolled into view — resolve its cover. + // Both fire as a virtualized game row is realized/scrolled into view. Resolve its cover. // EnsureResolvedAsync is idempotent (skips if a cover is already loaded), so double-firing is safe. private void GameRow_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) { diff --git a/src/LuaToolsGui/Views/ConfirmOverlay.xaml b/src/LuaToolsGui/Views/ConfirmOverlay.xaml index e57e149..5a5ec04 100644 --- a/src/LuaToolsGui/Views/ConfirmOverlay.xaml +++ b/src/LuaToolsGui/Views/ConfirmOverlay.xaml @@ -89,7 +89,7 @@ + NOT a Popup. A Popup gets a system drop-shadow around its rectangular surface ("boxed glow"). --> diff --git a/src/LuaToolsGui/Views/DownloadView.xaml.cs b/src/LuaToolsGui/Views/DownloadView.xaml.cs index fc6ce50..2180345 100644 --- a/src/LuaToolsGui/Views/DownloadView.xaml.cs +++ b/src/LuaToolsGui/Views/DownloadView.xaml.cs @@ -17,7 +17,7 @@ public DownloadView(DownloadViewModel viewModel) } /// The featured strips scroll horizontally, but they're nested inside the page's vertical - /// ScrollViewer — so a normal mouse wheel would bubble up and scroll the page instead. Translate the + /// ScrollViewer, so a normal mouse wheel would bubble up and scroll the page instead. Translate the /// vertical wheel delta into horizontal scroll while the pointer is over a strip, and mark it handled /// so the outer ScrollViewer doesn't also move. private void FeaturedStrip_PreviewMouseWheel(object sender, MouseWheelEventArgs e) diff --git a/src/LuaToolsGui/Views/DropZone.xaml b/src/LuaToolsGui/Views/DropZone.xaml index 78f4f61..da59ca8 100644 --- a/src/LuaToolsGui/Views/DropZone.xaml +++ b/src/LuaToolsGui/Views/DropZone.xaml @@ -51,8 +51,7 @@ FontWeight="SemiBold" Foreground="#e5e7eb" Text="{x:Static res:Strings.Drop_Title}" /> - 0) return s; - // These two arrive as a MemoryStream rather than a string, and are null-terminated — decode and + // These two arrive as a MemoryStream rather than a string, and are null-terminated. Decode and // cut at the terminator, or the appid regexes run against whatever trails it. foreach (var (format, encoding) in new[] { @@ -100,7 +100,7 @@ private async void OnDrop(object sender, DragEventArgs e) return null; } - // "Browse files…" — a non-drag way to add files. Feeds the same VM install path as a drop. + // "Browse files…". A non-drag way to add files. Feeds the same VM install path as a drop. private async void OnBrowseClick(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog diff --git a/src/LuaToolsGui/Views/FixesView.xaml b/src/LuaToolsGui/Views/FixesView.xaml index f7776b3..f00ad3f 100644 --- a/src/LuaToolsGui/Views/FixesView.xaml +++ b/src/LuaToolsGui/Views/FixesView.xaml @@ -427,7 +427,7 @@ PreviewMouseWheel="Markdown_PreviewMouseWheel" Visibility="{Binding Description, Converter={StaticResource NullToCollapsed}}"> - + + + + + + + + + + diff --git a/src/LuaToolsGui/Views/DownloadsView.xaml b/src/LuaToolsGui/Views/DownloadsView.xaml index 6c19379..bbb7eae 100644 --- a/src/LuaToolsGui/Views/DownloadsView.xaml +++ b/src/LuaToolsGui/Views/DownloadsView.xaml @@ -81,6 +81,14 @@ Text="{x:Static res:Strings.Downloads_ActionRequired}" /> + + + + + - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - diff --git a/tests/LuaToolsGui.Tests/LuaVaultTests.cs b/tests/LuaToolsGui.Tests/LuaVaultTests.cs index 5e54bee..f2b8e17 100644 --- a/tests/LuaToolsGui.Tests/LuaVaultTests.cs +++ b/tests/LuaToolsGui.Tests/LuaVaultTests.cs @@ -611,3 +611,4 @@ public void Variants_SurviveAcrossVaultInstances() Assert.Contains(reopened.AppsWithVariants(), id => id == AppId); } } + \ No newline at end of file From 6ee8996fc0d46fb532c3d070f373f7704656e6ae Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Sat, 29 Aug 2026 08:34:26 +1000 Subject: [PATCH 16/29] Add AssetHash, tool caching, and depot download flow Introduce AssetHash helper for SHA-256 verification and remove duplicated hashing/parsing code across services. Add tool-version fingerprints and checked timestamps to CacheService with atomic writes to avoid data loss. Change DepotDownloader repo to mendy-tools/DepotDownloaderMod and implement throttled release checks, digest verification, size-aware progress reporting, and fallbacks so a working tool is never disabled by network failures. SteamlessService updated with the same caching/check logic. Rework ManifestJobFactory into phases (fetch tool once, resolve manifests & sizes up-front, free-space check, then download) and improve progress/detail messages. Add GithubAsset.Size and new localized strings (Downloads_Depots_Preparing / Downloads_Depots_GettingTool) across all languages. Misc: replace ad-hoc SHA256 helpers with AssetHash usages in PluginInstallerService, UnlockerService, and others. --- src/LuaToolsGui/AppConfig.cs | 17 ++-- src/LuaToolsGui/Models/ModeModels.cs | 4 + src/LuaToolsGui/Resources/Strings.Designer.cs | 2 + src/LuaToolsGui/Resources/Strings.ar.resx | 2 + src/LuaToolsGui/Resources/Strings.bg.resx | 2 + src/LuaToolsGui/Resources/Strings.cs.resx | 2 + src/LuaToolsGui/Resources/Strings.da.resx | 2 + src/LuaToolsGui/Resources/Strings.de.resx | 2 + src/LuaToolsGui/Resources/Strings.el.resx | 2 + src/LuaToolsGui/Resources/Strings.es-419.resx | 2 + src/LuaToolsGui/Resources/Strings.es.resx | 2 + src/LuaToolsGui/Resources/Strings.fi.resx | 2 + src/LuaToolsGui/Resources/Strings.fr.resx | 2 + src/LuaToolsGui/Resources/Strings.hu.resx | 2 + src/LuaToolsGui/Resources/Strings.id.resx | 2 + src/LuaToolsGui/Resources/Strings.it.resx | 2 + src/LuaToolsGui/Resources/Strings.ja.resx | 2 + src/LuaToolsGui/Resources/Strings.ko.resx | 2 + src/LuaToolsGui/Resources/Strings.nb.resx | 2 + src/LuaToolsGui/Resources/Strings.nl.resx | 2 + src/LuaToolsGui/Resources/Strings.pl.resx | 2 + src/LuaToolsGui/Resources/Strings.pt-BR.resx | 2 + src/LuaToolsGui/Resources/Strings.pt-PT.resx | 2 + src/LuaToolsGui/Resources/Strings.resx | 2 + src/LuaToolsGui/Resources/Strings.ro.resx | 2 + src/LuaToolsGui/Resources/Strings.ru.resx | 2 + src/LuaToolsGui/Resources/Strings.sv.resx | 2 + src/LuaToolsGui/Resources/Strings.th.resx | 2 + src/LuaToolsGui/Resources/Strings.tr.resx | 2 + src/LuaToolsGui/Resources/Strings.uk.resx | 2 + src/LuaToolsGui/Resources/Strings.vi.resx | 2 + .../Resources/Strings.zh-Hans.resx | 2 + .../Resources/Strings.zh-Hant.resx | 2 + src/LuaToolsGui/Services/AssetHash.cs | 40 ++++++++ src/LuaToolsGui/Services/CacheService.cs | 73 +++++++++++++- .../Services/DepotDownloaderService.cs | 86 ++++++++++++++-- .../Services/Downloads/ManifestJobFactory.cs | 97 +++++++++++++------ .../Services/PluginInstallerService.cs | 25 ++--- src/LuaToolsGui/Services/SteamlessService.cs | 65 +++++++++++-- src/LuaToolsGui/Services/UnlockerService.cs | 41 +++----- 40 files changed, 403 insertions(+), 107 deletions(-) create mode 100644 src/LuaToolsGui/Services/AssetHash.cs diff --git a/src/LuaToolsGui/AppConfig.cs b/src/LuaToolsGui/AppConfig.cs index 780e472..494e37c 100644 --- a/src/LuaToolsGui/AppConfig.cs +++ b/src/LuaToolsGui/AppConfig.cs @@ -53,13 +53,16 @@ public static class AppConfig // Powers the Depots page "Download" action. // // This is a RE-PACK we host ourselves, NOT the upstream SteamAutoCracks/DepotDownloaderMod repo, for two - // reasons: upstream ships its assets as `Release.rar` (System.IO.Compression cannot read RAR, and we're - // not taking a dependency just for that), and its build is framework-dependent net9.0 while this app is - // net8.0-windows, so users without the .NET 9 runtime couldn't run it. The asset here must be a - // SELF-CONTAINED, ZIPPED publish: - // dotnet publish -c Release -r win-x64 --self-contained - // TODO: create this repo and upload the first release before the Depots download button ships. - public const string DepotDownloaderRepo = "madoiscool/DepotDownloaderMod"; + // reasons: upstream ships its assets as `Release.rar` (System.IO.Compression cannot read RAR), and its + // build is framework-dependent net9.0 while this app is net8.0-windows, so users without the .NET 9 + // runtime couldn't run it. Note that adding a RAR reader would fix only the FIRST of those. + // + // The re-pack is produced automatically by the `repack.yml` workflow in that repo: on each upstream + // release it rebuilds their source at that tag as a SELF-CONTAINED win-x64 publish and uploads a FLAT + // zip. Both properties are load-bearing for DepotDownloaderService.EnsureToolAsync, which takes the + // first `.zip` asset, extracts it straight into ToolDir, and then expects DepotDownloaderMod.exe at the + // archive ROOT (unlike PluginInstallerService, it has no single-top-level-folder hoisting). + public const string DepotDownloaderRepo = "mendy-tools/DepotDownloaderMod"; public const string ManifestBackendUrl = "http://167.235.229.108"; public const string ManifestBackendUserAgent = "secretgoonpoon"; diff --git a/src/LuaToolsGui/Models/ModeModels.cs b/src/LuaToolsGui/Models/ModeModels.cs index eeefd97..308f58a 100644 --- a/src/LuaToolsGui/Models/ModeModels.cs +++ b/src/LuaToolsGui/Models/ModeModels.cs @@ -77,6 +77,10 @@ public sealed class GithubAsset [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = ""; [JsonPropertyName("digest")] public string? Digest { get; set; } // "sha256:" + + /// Asset size in bytes. Lets a tool download report real byte counts instead of a bare + /// fraction, since GithubProxy.DownloadAsync only reports 0..1. + [JsonPropertyName("size")] public long Size { get; set; } } /// Queried state for one mode. What a Mode-page card binds to. diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 62e4a38..6951e4e 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -559,4 +559,6 @@ public static class Strings public static string Builds_Select_ChooseFolder => Get(nameof(Builds_Select_ChooseFolder)); public static string Builds_Select_SaveTo => Get(nameof(Builds_Select_SaveTo)); public static string Builds_Select_Space => Get(nameof(Builds_Select_Space)); + public static string Downloads_Depots_Preparing => Get(nameof(Downloads_Depots_Preparing)); + public static string Downloads_Depots_GettingTool => Get(nameof(Downloads_Depots_GettingTool)); } diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 0972576..5375d88 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -600,4 +600,6 @@ سجّل الدخول لتنزيل المستودعات. جارٍ جلب الملف تعذّر الحصول على ملف manifest لهذا المستودع. + جارٍ التحضير · {0} من {1} + جارٍ إحضار أداة التنزيل diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index d489146..6734941 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -574,4 +574,6 @@ Влез, за да изтегляш депота. зарежда manifest Manifest файлът за това депо не можа да бъде получен. + Подготовка · {0} от {1} + Изтегляне на инструмента diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index adcc191..8fc91c3 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -574,4 +574,6 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Pro stahování depotů se přihlas. načítání manifestu Nepodařilo se získat manifest tohoto depotu. + Příprava · {0} z {1} + Získávání nástroje diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index b827352..3f88d0a 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -574,4 +574,6 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Log ind for at hente depoter. henter manifest Kunne ikke hente dette depots manifest. + Forbereder · {0} af {1} + Henter downloaderen diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index 9039ae8..6e67546 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -574,4 +574,6 @@ Dies löscht die .lua-Dateien aus Steam\config\stplug-in. Zum Laden von Depots anmelden. Manifest wird geholt Das Manifest dieses Depots konnte nicht geladen werden. + Vorbereiten · {0} von {1} + Downloader wird geholt diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index ebc4fda..4c6812e 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -574,4 +574,6 @@ Συνδέσου για λήψη depot. λήψη manifest Δεν ήταν δυνατή η λήψη του manifest αυτού του depot. + Προετοιμασία · {0} από {1} + Λήψη του εργαλείου diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index a98553b..e139e50 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -574,4 +574,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. Iniciá sesión para descargar depósitos. obteniendo manifest No se pudo obtener el manifest de este depósito. + Preparando · {0} de {1} + Obteniendo el descargador diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index 925e2cb..b9d19f5 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -574,4 +574,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. Inicia sesión para descargar depósitos. obteniendo manifest No se pudo obtener el manifest de este depósito. + Preparando · {0} de {1} + Obteniendo el descargador diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index edc01a5..a622d2a 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -574,4 +574,6 @@ Tämä poistaa .lua-tiedostot kansiosta Steam\config\stplug-in. Kirjaudu sisään ladataksesi depoteja. haetaan manifestia Tämän depotin manifestia ei saatu haettua. + Valmistellaan · {0}/{1} + Haetaan lataustyökalua diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 75170ff..5f386a5 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -574,4 +574,6 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Connectez-vous pour télécharger des dépôts. récupération du manifest Impossible de récupérer le manifest de ce dépôt. + Préparation · {0} sur {1} + Récupération du téléchargeur diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index fb398d0..f2f72db 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -574,4 +574,6 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Jelentkezz be a depotok letöltéséhez. manifest letöltése Nem sikerült megszerezni a depot manifestjét. + Előkészítés · {0} / {1} + Letöltő beszerzése diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index 22dc99b..d9981ad 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -574,4 +574,6 @@ Ini menghapus berkas .lua dari Steam\config\stplug-in. Masuk untuk mengunduh depot. mengambil manifest Tidak bisa mendapatkan manifest depot ini. + Menyiapkan · {0} dari {1} + Mengambil pengunduh diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index c6e01c6..147a78a 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -574,4 +574,6 @@ Questo elimina i file .lua da Steam\config\stplug-in. Accedi per scaricare i depot. recupero manifest Impossibile ottenere il manifest di questo depot. + Preparazione · {0} di {1} + Recupero del downloader diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index e201e7a..afa06eb 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -574,4 +574,6 @@ Steam\config\stplug-in から .lua ファイルを削除します。デポのダウンロードにはサインインが必要です。 manifest を取得中 このデポの manifest を取得できませんでした。 + 準備中 · {0} / {1} + ダウンローダーを取得中 diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index b52f70e..b6e6987 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -574,4 +574,6 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 디포를 다운로드하려면 로그인하세요. manifest 가져오는 중 이 디포의 manifest를 가져오지 못했습니다. + 준비 중 · {0} / {1} + 다운로더 가져오는 중 diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index d80ceb8..c2a3dda 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -574,4 +574,6 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Logg inn for å laste ned depoter. henter manifest Fikk ikke tak i manifestet for dette depotet. + Forbereder · {0} av {1} + Henter nedlasteren diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index a4a4d27..6095308 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -574,4 +574,6 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Meld je aan om depots te downloaden. manifest ophalen Kon het manifest van dit depot niet ophalen. + Voorbereiden · {0} van {1} + Downloader ophalen diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index a880e3f..71fa38a 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -574,4 +574,6 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Zaloguj się, aby pobierać depoty. pobieranie manifestu Nie udało się pobrać manifestu tego depotu. + Przygotowywanie · {0} z {1} + Pobieranie narzędzia diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index a89ea42..2fed9f5 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -574,4 +574,6 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Entre para baixar depots. obtendo manifest Não deu para obter o manifest deste depot. + Preparando · {0} de {1} + Obtendo o downloader diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index b67a3f3..4af6349 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -574,4 +574,6 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Inicie sessão para transferir depots. a obter manifest Não foi possível obter o manifest deste depot. + A preparar · {0} de {1} + A obter o transferidor diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 86c9976..b7d83e5 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -609,4 +609,6 @@ This deletes the .lua files from Steam\config\stplug-in. Sign in to download depots. fetching manifest Couldn't get this depot's manifest. + Preparing · {0} of {1} + Getting the downloader diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 39d0557..5d0755c 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -574,4 +574,6 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Autentifică-te ca să descarci depouri. se obține manifestul Nu s-a putut obține manifestul acestui depou. + Se pregătește · {0} din {1} + Se obține descărcătorul diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index f736d02..3858ebc 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -574,4 +574,6 @@ Войдите, чтобы скачивать депо. получение manifest Не удалось получить manifest этого депо. + Подготовка · {0} из {1} + Загрузка загрузчика diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index 5e36b13..91ce3d8 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -574,4 +574,6 @@ Detta raderar .lua-filerna från Steam\config\stplug-in. Logga in för att ladda ner depåer. hämtar manifest Kunde inte hämta depåns manifest. + Förbereder · {0} av {1} + Hämtar nedladdaren diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index 36d87e1..a2965aa 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -574,4 +574,6 @@ ลงชื่อเข้าใช้เพื่อดาวน์โหลด depot กำลังดึง manifest ไม่สามารถดึง manifest ของ depot นี้ได้ + กำลังเตรียม · {0} จาก {1} + กำลังรับตัวดาวน์โหลด diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 1558e3c..829c967 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -574,4 +574,6 @@ Bu, .lua dosyalarını Steam\config\stplug-in konumundan siler. Depot indirmek için giriş yap. manifest alınıyor Bu depotun manifest dosyası alınamadı. + Hazırlanıyor · {0} / {1} + İndirici alınıyor diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index afd0214..36bac9a 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -574,4 +574,6 @@ Увійдіть, щоб завантажувати депо. отримання manifest Не вдалося отримати manifest цього депо. + Підготовка · {0} з {1} + Завантаження завантажувача diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index dbfc427..86b8951 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -574,4 +574,6 @@ Thao tác này xóa các tệp .lua khỏi Steam\config\stplug-in.Đăng nhập để tải depot. đang lấy manifest Không lấy được manifest của depot này. + Đang chuẩn bị · {0} trên {1} + Đang lấy trình tải diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index db275af..3da962c 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -594,4 +594,6 @@ 登录后可下载仓库。 正在获取 manifest 无法获取该仓库的 manifest。 + 准备中 · {0} / {1} + 正在获取下载器 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 05cd47e..5adf7aa 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -574,4 +574,6 @@ 登入後可下載倉庫。 正在取得 manifest 無法取得這個倉庫的 manifest。 + 準備中 · {0} / {1} + 正在取得下載器 diff --git a/src/LuaToolsGui/Services/AssetHash.cs b/src/LuaToolsGui/Services/AssetHash.cs new file mode 100644 index 0000000..40be929 --- /dev/null +++ b/src/LuaToolsGui/Services/AssetHash.cs @@ -0,0 +1,40 @@ +using System.IO; +using System.Security.Cryptography; + +namespace LuaToolsGui.Services; + +/// +/// SHA-256 verification of downloaded GitHub release assets. +/// +/// +/// These two helpers existed as byte-identical private statics in both UnlockerService and +/// PluginInstallerService. Every service that downloads an executable and then runs it needs +/// them, so they live in one place rather than being copied a third and fourth time. +/// +internal static class AssetHash +{ + /// Lowercase hex SHA-256 of a file's contents. + public static string OfFile(string path) + { + using var s = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(s)).ToLowerInvariant(); + } + + /// Strip the "sha256:" prefix GitHub puts on asset digests; null if absent. + public static string? ParseDigest(string? digest) + { + if (string.IsNullOrWhiteSpace(digest)) return null; + int colon = digest.IndexOf(':'); + return (colon >= 0 ? digest[(colon + 1)..] : digest).Trim().ToLowerInvariant(); + } + + /// + /// True when the file matches the asset's advertised digest. Also true when the asset advertises no + /// digest at all, so an older release without one is not treated as corrupt. + /// + public static bool Matches(string path, string? assetDigest) + { + if (ParseDigest(assetDigest) is not { } want) return true; // nothing to check against + return OfFile(path).Equals(want, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/LuaToolsGui/Services/CacheService.cs b/src/LuaToolsGui/Services/CacheService.cs index 0b2aac7..0607038 100644 --- a/src/LuaToolsGui/Services/CacheService.cs +++ b/src/LuaToolsGui/Services/CacheService.cs @@ -39,6 +39,15 @@ public class CacheData // Finished downloads shown in the Downloads tab, newest first, capped. Records only: a queued job // holds delegates and can't be serialized, so nothing here is resumable. public List DownloadHistory { get; set; } = []; + + // ── Downloaded tool fingerprints (DepotDownloader, Steamless) ───── + // The release tag last installed, plus when we last asked GitHub. Without these the tools were + // fetched once by a bare File.Exists check and then pinned forever, which matters now that the + // DepotDownloader re-pack republishes on every upstream release. CheckedAtMs is Unix-ms; 0 = never. + public string? DepotDownloaderVersion { get; set; } + public long DepotDownloaderCheckedAtMs { get; set; } + public string? SteamlessVersion { get; set; } + public long SteamlessCheckedAtMs { get; set; } } /// @@ -51,6 +60,20 @@ public class CacheService private static readonly string Dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui"); private static readonly string FilePath = Path.Combine(Dir, "cache.json"); + private static readonly string TmpPath = FilePath + ".tmp"; + + /// + /// Serializes every read-modify-write of the cache. + /// + /// + /// Writers now come from several threads at once: the download queue persists history from the + /// dispatcher whenever an item finishes, while DepotDownloaderService and SteamlessService record + /// tool versions from background threads mid-download. Two unsynchronized writers could interleave + /// the file write or lose each other's field, and silently resets to an empty + /// cache on a parse failure — which would quietly wipe DonatedAppIds, a list that is permanent by + /// design because the backend accepts each depot only once per IP. + /// + private readonly object _gate = new(); private CacheData _cache = new(); @@ -132,6 +155,36 @@ public void SaveDownloadHistory(IEnumerable rec Save(); } + // ── Downloaded tool fingerprints ───────────────────────────────── + + /// Release tag of the installed DepotDownloader, or null if never recorded. + public string? DepotDownloaderVersion + { + get => _cache.DepotDownloaderVersion; + set { _cache.DepotDownloaderVersion = string.IsNullOrWhiteSpace(value) ? null : value; Save(); } + } + + /// Unix-ms of the last successful DepotDownloader release check; 0 = never. + public long DepotDownloaderCheckedAtMs + { + get => _cache.DepotDownloaderCheckedAtMs; + set { _cache.DepotDownloaderCheckedAtMs = value; Save(); } + } + + /// Release tag of the installed Steamless, or null if never recorded. + public string? SteamlessVersion + { + get => _cache.SteamlessVersion; + set { _cache.SteamlessVersion = string.IsNullOrWhiteSpace(value) ? null : value; Save(); } + } + + /// Unix-ms of the last successful Steamless release check; 0 = never. + public long SteamlessCheckedAtMs + { + get => _cache.SteamlessCheckedAtMs; + set { _cache.SteamlessCheckedAtMs = value; Save(); } + } + /// Clear the loaded-apps notification list (ReadLoadedApps → DismissLoadedApps). public void ClearLoadedAppIds() { @@ -169,6 +222,11 @@ private void Load() } private void Save() + { + lock (_gate) { SaveLocked(); } + } + + private void SaveLocked() { bool empty = _cache.OpenSteamToolsInstalledVersion is null && _cache.OpenSteamToolsInstalledZipDigest is null @@ -178,7 +236,11 @@ private void Save() && _cache.HardwareAppIdsFetchedAtMs == 0 && _cache.LoadedAppIds.Count == 0 && !_cache.OnboardingComplete - && _cache.DownloadHistory.Count == 0; + && _cache.DownloadHistory.Count == 0 + && _cache.DepotDownloaderVersion is null + && _cache.DepotDownloaderCheckedAtMs == 0 + && _cache.SteamlessVersion is null + && _cache.SteamlessCheckedAtMs == 0; if (empty) { try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { /* best effort */ } @@ -186,6 +248,13 @@ private void Save() } Directory.CreateDirectory(Dir); - File.WriteAllText(FilePath, JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true })); + + // Write-then-move, the same shape SettingsService uses. A bare WriteAllText that is interrupted + // (crash, power loss, or a second writer) leaves truncated JSON, and Load() answers that by + // resetting to an empty cache — silently discarding everything, including the permanent + // DonatedAppIds list. File.Move over an existing file is atomic on NTFS. + string json = JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(TmpPath, json); + File.Move(TmpPath, FilePath, overwrite: true); } } diff --git a/src/LuaToolsGui/Services/DepotDownloaderService.cs b/src/LuaToolsGui/Services/DepotDownloaderService.cs index 06600d3..ae5c3e4 100644 --- a/src/LuaToolsGui/Services/DepotDownloaderService.cs +++ b/src/LuaToolsGui/Services/DepotDownloaderService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; using Microsoft.Extensions.Logging; namespace LuaToolsGui.Services; @@ -59,6 +60,7 @@ public partial class DepotDownloaderService( GithubProxy gh, SteamService steam, AuthService auth, + CacheService cache, ILogger log) { /// @@ -106,22 +108,52 @@ public partial class DepotDownloaderService( // ── Tool acquisition ───────────────────────────────────────────── - /// Ensure the tool is on disk (downloads + extracts once). Null if it couldn't be obtained. - public async Task EnsureToolAsync(IProgress? progress, CancellationToken ct = default) + /// How long an up-to-date check is trusted before we ask GitHub again. + /// Matches the re-pack workflow's own poll interval; checking more often can't find + /// anything newer. Also keeps a 10-depot game from making 10 API calls, since RunAsync calls this + /// once per depot. + private static readonly TimeSpan ToolCheckInterval = TimeSpan.FromHours(6); + + private static bool CheckedRecently(long lastMs) => + lastMs > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastMs < (long)ToolCheckInterval.TotalMilliseconds; + + /// + /// Ensure the tool is on disk and reasonably current. Null only if it couldn't be obtained at all. + /// + /// + /// This used to be a bare File.Exists, which pinned whoever downloaded once to that build + /// forever. The re-pack repo now republishes on every upstream release, so the installed release tag + /// is recorded and re-checked at most every . + /// + /// A failed check never disables a working tool. Every failure path below falls back to + /// an existing ExePath, so an offline user who already has the tool keeps downloading depots + /// exactly as before. Returning null is reserved for "there is no usable tool at all". + /// + public async Task EnsureToolAsync(IProgress? progress, CancellationToken ct = default) { - if (File.Exists(ExePath)) return ExePath; + if (File.Exists(ExePath) && CheckedRecently(cache.DepotDownloaderCheckedAtMs)) return ExePath; await _toolGate.WaitAsync(ct); + bool have = false; try { - if (File.Exists(ExePath)) return ExePath; // won the race elsewhere + have = File.Exists(ExePath); + if (have && CheckedRecently(cache.DepotDownloaderCheckedAtMs)) return ExePath; // won the race + + // A failed lookup still counts as "we looked". Without this the throttle only advances on + // success, and since RunAsync calls this once PER DEPOT, an offline 10-depot game would make + // ten lookups — each walking every GithubProxy mirror — where the old File.Exists check made + // none. Backing off costs at most one interval of update latency. + void RecordAttempt() => + cache.DepotDownloaderCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string url = $"https://api.github.com/repos/{AppConfig.DepotDownloaderRepo}/releases/latest"; using var res = await gh.SendAsync(url, ct); if (res is null || !res.IsSuccessStatusCode) { log.LogDebug("DepotDownloader release lookup failed: {Status}", res?.StatusCode); - return null; + if (have) RecordAttempt(); + return have ? ExePath : null; } var release = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); @@ -129,24 +161,58 @@ public partial class DepotDownloaderService( if (asset is null) { log.LogDebug("DepotDownloader release has no .zip asset"); - return null; + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + // Already on the published build: record that we looked and skip the ~37 MB download. + if (have && !string.IsNullOrEmpty(release!.TagName) + && string.Equals(release.TagName, cache.DepotDownloaderVersion, StringComparison.Ordinal)) + { + cache.DepotDownloaderCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return ExePath; } Directory.CreateDirectory(ToolDir); string zipPath = Path.Combine(ToolDir, "depotdownloader.zip"); - await gh.DownloadAsync(asset.DownloadUrl, zipPath, progress, ct); + // GithubProxy only reports a 0..1 fraction; the release tells us the real size, so convert + // here rather than making the caller show a meaningless "45 of 100". + var sink = progress is null ? null : new ProgressRelay(f => + progress.Report(new DownloadProgress( + (long)((f ?? 0) * asset.Size), asset.Size > 0 ? asset.Size : null))); + await gh.DownloadAsync(asset.DownloadUrl, zipPath, sink, ct); + + // Verify before extracting over a working install: this is an executable we then run, and + // both UnlockerService and PluginInstallerService check the same way. + if (!AssetHash.Matches(zipPath, asset.Digest)) + { + log.LogDebug("DepotDownloader asset digest mismatch; keeping the existing tool"); + try { File.Delete(zipPath); } catch { } + if (have) RecordAttempt(); // don't re-download a bad asset on the very next depot + return have ? ExePath : null; + } // Extract the WHOLE zip: the exe needs SteamKit2.dll and friends beside it. ZipFile.ExtractToDirectory(zipPath, ToolDir, overwriteFiles: true); try { File.Delete(zipPath); } catch { /* leftover zip is harmless */ } - return File.Exists(ExePath) ? ExePath : null; + if (!File.Exists(ExePath)) + { + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + cache.DepotDownloaderVersion = release!.TagName; + cache.DepotDownloaderCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return ExePath; } catch (OperationCanceledException) { throw; } catch (Exception ex) { log.LogDebug(ex, "Obtaining DepotDownloader failed"); - return null; + // Same backoff as the explicit failure paths: a throwing check must not be retried per depot. + if (have) cache.DepotDownloaderCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return have ? ExePath : null; } finally { _toolGate.Release(); } } diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index 452eb06..9d3987f 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -121,7 +121,6 @@ public DownloadJob CreateDepotJob( long appId, string gameName, IReadOnlyList selections, string outDir, Action? onFinished = null) { - long totalSize = selections.Sum(s => s.Size); return new DownloadJob( DownloadKind.Depot, $"depot:{appId}", @@ -129,7 +128,7 @@ public DownloadJob CreateDepotJob( gameName, Resources.Strings.Downloads_Kind_Depot, covers.GetLocalPath(appId), - (item, progress, ct) => RunDepotsAsync(item, appId, gameName, selections, outDir, totalSize, progress, ct), + (item, progress, ct) => RunDepotsAsync(item, appId, gameName, selections, outDir, progress, ct), // Nothing to install: the depots were written straight to outDir. (_, _, _) => Task.FromResult(new JobResult(true, string.Format(Resources.Strings.Depot_Status_Done, selections.Count, outDir), outDir)), @@ -139,55 +138,91 @@ public DownloadJob CreateDepotJob( private async Task RunDepotsAsync( DownloadItem item, long appId, string gameName, IReadOnlyList selections, - string outDir, long totalSize, IProgress progress, CancellationToken ct) + string outDir, IProgress progress, CancellationToken ct) { var keys = depotTool.ResolveKeys(appId); if (keys.Count == 0) throw new DownloadAbortedException(Resources.Strings.Depot_Err_NoKeys); + // Sampled ONCE, before anything runs. Checking it inside the loop would be self-fulfilling: + // the first depot creates outDir, so every later depot would see it and think a previous session + // had written there. (Harmless in cost — a depot whose files don't exist yet validates nothing — + // but the intent is "did an earlier run leave partial files here", which is only true up front.) + bool outDirExisted = Directory.Exists(outDir); + + // ── Phase 0: the downloader itself ─────────────────────────────────────────────────────────── + // Hoisted out of the per-depot loop so the ~37 MB first fetch (and any update) happens once, with + // visible progress. RunAsync still calls EnsureToolAsync per depot, but those hit its fast path. + OnUi(() => item.Detail = Resources.Strings.Downloads_Depots_GettingTool); + if (await depotTool.EnsureToolAsync(progress, ct) is null) + throw new DownloadAbortedException(Resources.Strings.Depot_Err_Tool); + + // Hand the bar back. On a fresh install the step above just drove it to 100% against the tool's + // own size; leaving it there would show a full bar through Phase 1 and then snap to 0% when the + // depots start. A null total reads as indeterminate until Phase 2 knows the real one. + progress.Report(new DownloadProgress(0, null)); + + // ── Phase 1: resolve EVERYTHING before a single byte is written ────────────────────────────── + // Sizes for every selection (including finished ones, so a resumed job's baseline is right), and + // manifests only for what's left to do. Doing this inside the download loop meant a manifest that + // couldn't be fetched aborted the job after earlier depots had already pulled tens of GB, and it + // left the free-space check below summing 0 for every unresolved shared depot. + var resolved = new List(selections.Count); + for (int i = 0; i < selections.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + // Formatted into a local BEFORE the closure: `i` is a for-loop variable, so it is shared + // across iterations and would have moved on by the time the dispatcher ran the lambda. + string prep = string.Format(Resources.Strings.Downloads_Depots_Preparing, i + 1, selections.Count); + OnUi(() => item.Detail = prep); + + // A shared redistributable carries no gid or size in the game's own app-info (it's a + // three-field stub pointing at the owning app), so both are resolved here rather than at + // pick time. Cached per app by SteamDepotInfo, and the owner is app 228980 for nearly + // every game, so this costs one lookup per session across all downloads. + var sized = await ResolveSharedAsync(selections[i], ct); + + // Resolve the manifest, fetching it into depotcache if Steam doesn't already have it. This is + // what lets a depot be downloaded at all when the game was added with "Auto Update Apps" on, + // which comments out the pins and skips the manifest files. Skipped for a depot already + // finished — its bytes are on disk and nothing will re-read the manifest. + // `prep` (not the download-phase caption) is the step text here: EnsureManifestAsync appends + // "· fetching manifest" to whatever it's given, so passing the other string would relabel the + // row mid-pre-flight as though depots were already downloading. + if (!item.CompletedDepots.Contains(sized.DepotId)) + sized = sized with { ManifestPath = await EnsureManifestAsync(item, sized, prep, ct) }; + + resolved.Add(sized); + } + + // ── Phase 2: budget, now that the sizes are real ───────────────────────────────────────────── // Refuse up front rather than part-way through. The downloader pre-allocates every file at its // full size BEFORE fetching a byte, so a short disk fails almost immediately — but only after it // has already created multi-GB of zero-filled files. Checking here also gives a message that says // what's actually wrong instead of a raw allocation error. - long needed = selections.Where(s => !item.CompletedDepots.Contains(s.DepotId)).Sum(s => s.Size); + long totalSize = resolved.Sum(s => s.Size); + long needed = resolved.Where(s => !item.CompletedDepots.Contains(s.DepotId)).Sum(s => s.Size); if (needed > 0 && DepotDownloaderService.FreeSpaceFor(outDir) is { } free && free < needed) throw new DownloadAbortedException(string.Format( Resources.Strings.Depot_Err_NoSpace, ByteFormat.Size(needed), ByteFormat.Size(free))); - // Sampled ONCE, before anything runs. Checking it inside the loop would be self-fulfilling: - // the first depot creates outDir, so every later depot would see it and think a previous session - // had written there. (Harmless in cost — a depot whose files don't exist yet validates nothing — - // but the intent is "did an earlier run leave partial files here", which is only true up front.) - bool outDirExisted = Directory.Exists(outDir); - + // ── Phase 3: download ──────────────────────────────────────────────────────────────────────── string keysFile = DepotDownloaderService.WriteKeysFile(keys); try { long done = 0; - for (int i = 0; i < selections.Count; i++) + for (int i = 0; i < resolved.Count; i++) { ct.ThrowIfCancellationRequested(); - var sel = selections[i]; + var ready = resolved[i]; - // Resume skips what's already finished rather than re-hashing tens of GB. - if (item.CompletedDepots.Contains(sel.DepotId)) { done += sel.Size; continue; } + // Resume skips what's already finished rather than re-hashing tens of GB. Its size is the + // resolved one, so a finished shared depot no longer contributes 0 to the baseline. + if (item.CompletedDepots.Contains(ready.DepotId)) { done += ready.Size; continue; } - string step = string.Format(Resources.Strings.Downloads_Depots_Progress, i + 1, selections.Count); + string step = string.Format(Resources.Strings.Downloads_Depots_Progress, i + 1, resolved.Count); OnUi(() => item.Detail = step); - // A shared redistributable carries no gid or size in the game's own app-info (it's a - // three-field stub pointing at the owning app), so both are resolved here rather than at - // pick time. Cached per app by SteamDepotInfo, and the owner is app 228980 for nearly - // every game, so this costs one lookup per session across all downloads. - var sized = await ResolveSharedAsync(sel, ct); - // The up-front total assumed 0 bytes for a shared depot. Correct it now we know better, - // so the overall bar stays honest instead of finishing early. - totalSize += sized.Size - sel.Size; - - // Resolve the manifest, fetching it into depotcache if Steam doesn't already have it. - // This is what lets a depot be downloaded at all when the game was added with - // "Auto Update Apps" on, which comments out the pins and skips the manifest files. - var ready = sized with { ManifestPath = await EnsureManifestAsync(item, sized, step, ct) }; - // Only the FIRST depot after a resume is the partially-written one, so only it needs the // (expensive) re-hash. Consume the flag so later depots download at full speed. // @@ -216,9 +251,9 @@ private async Task RunDepotsAsync( if (!res.Ok) throw new DownloadAbortedException(res.Error == "tool" ? Resources.Strings.Depot_Err_Tool - : string.Format(Resources.Strings.Depot_Err_Failed, sel.DepotId, res.Error ?? "")); + : string.Format(Resources.Strings.Depot_Err_Failed, ready.DepotId, res.Error ?? "")); - item.CompletedDepots.Add(sel.DepotId); + item.CompletedDepots.Add(ready.DepotId); done += ready.Size; progress.Report(new DownloadProgress(done, totalSize)); } diff --git a/src/LuaToolsGui/Services/PluginInstallerService.cs b/src/LuaToolsGui/Services/PluginInstallerService.cs index 4d3d6fc..944fcde 100644 --- a/src/LuaToolsGui/Services/PluginInstallerService.cs +++ b/src/LuaToolsGui/Services/PluginInstallerService.cs @@ -1,8 +1,7 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net.Http; -using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; @@ -287,7 +286,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT bool dllMatches = Slots.All(slot => SlotPath(slot) is { } p && File.Exists(p) && AssetDigest(latest, slot.DllAsset) is { } digest && - Sha256OfFile(p) == digest); + AssetHash.OfFile(p) == digest); bool installed = frontend && loader; // `|| legacy` keeps a leftover/locked legacy dll getting swept on subsequent auto-updates until gone. bool updateAvailable = installed && (manifest?.Tag != latest.TagName || !dllMatches || legacy); @@ -331,13 +330,13 @@ public async Task GetStatusAsync(bool force = false, CancellationT } // Verify each against its release asset digest before touching anything on disk. - string zipSha = Sha256OfFile(zipPath); + string zipSha = AssetHash.OfFile(zipPath); if (AssetDigest(latest, PluginZipAsset) is { } zd && zipSha != zd) return (false, string.Format(Resources.Strings.Plugin_Err_VerifyFailed, PluginZipAsset)); var slotShas = new Dictionary(); foreach (var (slot, p) in slotDlPaths) { - string sha = Sha256OfFile(p); + string sha = AssetHash.OfFile(p); slotShas[slot] = sha; if (AssetDigest(latest, slot.DllAsset) is { } dd && sha != dd) return (false, string.Format(Resources.Strings.Plugin_Err_VerifyFailed, slot.DllAsset)); @@ -366,7 +365,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT // (so hand-placed test builds aren't clobbered), and thus never stop/restart Steam for it either. bool legacyPresent = LegacyDllPaths.Any(File.Exists); bool anySlotNeedsUpdate = Slots.Any(slot => - SlotPath(slot) is not { } cur || !File.Exists(cur) || Sha256OfFile(cur) != slotShas[slot]); + SlotPath(slot) is not { } cur || !File.Exists(cur) || AssetHash.OfFile(cur) != slotShas[slot]); bool dllNeedsUpdate = !DllUpdateDisabled && (anySlotNeedsUpdate || legacyPresent); if (dllNeedsUpdate) { @@ -623,21 +622,9 @@ private static void NormalizeFrontendLayout() // ── Helpers (same shape as UnlockerService's) ── private static string? AssetDigest(GithubRelease r, string name) => - ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Digest); + AssetHash.ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Digest); private static GithubAsset? FindAsset(GithubRelease r, string name) => r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - private static string Sha256OfFile(string path) - { - using var s = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(s)).ToLowerInvariant(); - } - - private static string? ParseDigest(string? digest) - { - if (string.IsNullOrWhiteSpace(digest)) return null; - int colon = digest.IndexOf(':'); - return (colon >= 0 ? digest[(colon + 1)..] : digest).Trim().ToLowerInvariant(); - } } diff --git a/src/LuaToolsGui/Services/SteamlessService.cs b/src/LuaToolsGui/Services/SteamlessService.cs index eb6bbf5..90a81c1 100644 --- a/src/LuaToolsGui/Services/SteamlessService.cs +++ b/src/LuaToolsGui/Services/SteamlessService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Text.Json; @@ -20,7 +20,7 @@ public record SteamlessResult(int Patched, int Unchanged, int Total, string? Err /// filtered recursive scan of the install folder. Each patched exe is backed up to <exe>.bak /// first. Steamless silently no-ops on exes without DRM, so over-selection is harmless. /// -public class SteamlessService(GithubProxy gh, SteamLibraryService library, SteamDepotInfo depots) +public class SteamlessService(GithubProxy gh, SteamLibraryService library, SteamDepotInfo depots, CacheService cache) { private static readonly string ToolDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "steamless"); @@ -38,38 +38,83 @@ public class SteamlessService(GithubProxy gh, SteamLibraryService library, Steam private readonly SemaphoreSlim _toolGate = new(1, 1); - /// Ensure Steamless.CLI.exe is on disk (downloads + extracts once). Returns its path, or null - /// if the tool couldn't be obtained. + /// How long an up-to-date check is trusted before we ask GitHub again. + private static readonly TimeSpan ToolCheckInterval = TimeSpan.FromHours(6); + + private static bool CheckedRecently(long lastMs) => + lastMs > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastMs < (long)ToolCheckInterval.TotalMilliseconds; + + /// + /// Ensure Steamless.CLI.exe is on disk and reasonably current. Null only if no usable tool exists. + /// + /// + /// Was a bare File.Exists, which pinned the first download forever. Now the installed release + /// tag is recorded and re-checked at most every . + /// + /// A failed check never disables a working tool — every failure path falls back to an + /// existing CliPath, so being offline can't break DRM removal for someone who already has it. + /// public async Task EnsureToolAsync(IProgress? progress, CancellationToken ct = default) { - if (File.Exists(CliPath)) return CliPath; + if (File.Exists(CliPath) && CheckedRecently(cache.SteamlessCheckedAtMs)) return CliPath; await _toolGate.WaitAsync(ct); + bool have = false; try { - if (File.Exists(CliPath)) return CliPath; // won the race elsewhere + have = File.Exists(CliPath); + if (have && CheckedRecently(cache.SteamlessCheckedAtMs)) return CliPath; // won the race + + // A failed lookup still counts as "we looked", so an offline run backs off instead of + // retrying the whole GithubProxy mirror chain on the next call. + void RecordAttempt() => + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Latest release → the single distributable .zip asset. string url = $"https://api.github.com/repos/{AppConfig.SteamlessRepo}/releases/latest"; using var res = await gh.SendAsync(url, ct); - if (res is null || !res.IsSuccessStatusCode) return null; + if (res is null || !res.IsSuccessStatusCode) { if (have) RecordAttempt(); return have ? CliPath : null; } var release = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); var asset = release?.Assets.FirstOrDefault(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); - if (asset is null) return null; + if (asset is null) { if (have) RecordAttempt(); return have ? CliPath : null; } + + // Already on the published build: record that we looked and skip the download. + if (have && !string.IsNullOrEmpty(release!.TagName) + && string.Equals(release.TagName, cache.SteamlessVersion, StringComparison.Ordinal)) + { + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return CliPath; + } Directory.CreateDirectory(ToolDir); string zipPath = Path.Combine(ToolDir, "steamless.zip"); await gh.DownloadAsync(asset.DownloadUrl, zipPath, progress, ct); + // Verify before overwriting a working install: this is an executable we then run. + if (!AssetHash.Matches(zipPath, asset.Digest)) + { + try { File.Delete(zipPath); } catch { } + if (have) RecordAttempt(); + return have ? CliPath : null; + } + // Extract the WHOLE zip: the CLI needs its plugin DLLs alongside it. ZipFile.ExtractToDirectory(zipPath, ToolDir, overwriteFiles: true); try { File.Delete(zipPath); } catch { /* leftover zip is harmless */ } - return File.Exists(CliPath) ? CliPath : null; + if (!File.Exists(CliPath)) { if (have) RecordAttempt(); return have ? CliPath : null; } + + cache.SteamlessVersion = release!.TagName; + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return CliPath; } catch (OperationCanceledException) { throw; } - catch { return null; } + catch + { + if (have) cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return have ? CliPath : null; + } finally { _toolGate.Release(); } } diff --git a/src/LuaToolsGui/Services/UnlockerService.cs b/src/LuaToolsGui/Services/UnlockerService.cs index 9bfd730..18234ae 100644 --- a/src/LuaToolsGui/Services/UnlockerService.cs +++ b/src/LuaToolsGui/Services/UnlockerService.cs @@ -1,7 +1,6 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.IO.Compression; -using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; using LuaToolsGui.Models; @@ -142,7 +141,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) { string local = Path.Combine(root, manifest.File); if (!File.Exists(local)) return ModeStatus.NotInstalled; - return Sha256OfFile(local).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase) + return AssetHash.OfFile(local).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase) ? ModeStatus.UpToDate : ModeStatus.UpdateAvailable; } @@ -159,7 +158,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) string ostDll = Path.Combine(root, "OpenSteamTool.dll"); if (nightly is not null && File.Exists(ostDll) - && AssetDigest(nightly, "OpenSteamTool.dll") == Sha256OfFile(ostDll)) + && AssetDigest(nightly, "OpenSteamTool.dll") == AssetHash.OfFile(ostDll)) return (ModeStatus.UpToDate, nightly.TagName); // Not the current nightly. Fall back to the stable mirror to tell "on stable OST" apart from @@ -196,7 +195,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) if (ost.Count == 0) return (ModeStatus.Unknown, null); var latest = ost[0]; - string dwmHash = Sha256OfFile(dwmapi); + string dwmHash = AssetHash.OfFile(dwmapi); if (AssetDigest(latest, "dwmapi.dll") == dwmHash) return (ModeStatus.UpToDate, latest.TagName); // Matches an older ost- release, or is present but unrecognized → an update exists. @@ -269,13 +268,13 @@ public async Task InstallAsync( if (asset is null) return ModeInstallResult.Fail(Resources.Strings.Err_ReleaseMissingDownload); zipName = asset.Name; zipUrl = asset.DownloadUrl; - wantedZipDigest = ParseDigest(asset.Digest); + wantedZipDigest = AssetHash.ParseDigest(asset.Digest); } string zipPath = Path.Combine(staging, zipName); await DownloadToFileAsync(zipUrl, zipPath, progress, ct); - zipDigest = Sha256OfFile(zipPath); + zipDigest = AssetHash.OfFile(zipPath); if (wantedZipDigest is { } want && !zipDigest.Equals(want, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(Resources.Strings.Err_VerifyFailed); @@ -287,7 +286,7 @@ public async Task InstallAsync( // Manifest modes don't publish a zip digest, so verify the payload file the manifest // DOES vouch for, once it's out of the archive. if (manifest is not null && staged.TryGetValue(manifest.File, out string? payload) - && !Sha256OfFile(payload).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) + && !AssetHash.OfFile(payload).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, manifest.File)); } @@ -365,7 +364,7 @@ public async Task InstallAsync( string ostDll = Path.Combine(root, "OpenSteamTool.dll"); if (File.Exists(ostDll)) { - string ostHash = Sha256OfFile(ostDll); + string ostHash = AssetHash.OfFile(ostDll); var bstManifest = await FetchUpdateManifestAsync(Def(UnlockerMode.Bst), forceRefresh: false, ct); if (bstManifest is not null @@ -395,8 +394,8 @@ public async Task InstallAsync( .ToList(); if (tagged is { Count: > 0 }) { - string dwmHash = Sha256OfFile(dwmapi); - string xinHash = Sha256OfFile(xinput); + string dwmHash = AssetHash.OfFile(dwmapi); + string xinHash = AssetHash.OfFile(xinput); if (tagged.Any(r => AssetDigest(r, "dwmapi.dll") == dwmHash) && tagged.Any(r => AssetDigest(r, "xinput1_4.dll") == xinHash)) detected = UnlockerMode.Ost; @@ -410,7 +409,7 @@ public async Task InstallAsync( /// Digest (hex, no prefix) of a release's same-named asset, or null if absent. private static string? AssetDigest(GithubRelease r, string assetName) => - ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase))?.Digest); + AssetHash.ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase))?.Digest); /// The same-named asset, or null if this release doesn't have it. private static GithubAsset? FindAsset(GithubRelease r, string assetName) => @@ -587,7 +586,7 @@ public async Task GetCloudRedirectStateAsync( { latest = release.TagName; string? wanted = AssetDigest(release, CloudRedirectDll); - if (wanted is not null && !Sha256OfFile(dll).Equals(wanted, StringComparison.OrdinalIgnoreCase)) + if (wanted is not null && !AssetHash.OfFile(dll).Equals(wanted, StringComparison.OrdinalIgnoreCase)) updateAvailable = true; } } @@ -645,7 +644,7 @@ private async Task DownloadCloudRedirectDllAsync(string root, Directory.CreateDirectory(staging); string tmp = Path.Combine(staging, CloudRedirectDll); await DownloadToFileAsync(asset.DownloadUrl, tmp, progress, ct); - if (ParseDigest(asset.Digest) is { } want && !Sha256OfFile(tmp).Equals(want, StringComparison.OrdinalIgnoreCase)) + if (AssetHash.ParseDigest(asset.Digest) is { } want && !AssetHash.OfFile(tmp).Equals(want, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, CloudRedirectDll)); try @@ -860,20 +859,6 @@ private static Dictionary ExtractWanted(string zipPath, string[] private Task DownloadToFileAsync(string url, string destPath, IProgress? progress, CancellationToken ct) => gh.DownloadAsync(url, destPath, progress, ct); - private static string Sha256OfFile(string path) - { - using var s = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(s)).ToLowerInvariant(); - } - - /// Strip the "sha256:" prefix GitHub puts on asset digests; null if absent. - private static string? ParseDigest(string? digest) - { - if (string.IsNullOrWhiteSpace(digest)) return null; - int colon = digest.IndexOf(':'); - return (colon >= 0 ? digest[(colon + 1)..] : digest).Trim().ToLowerInvariant(); - } - private static void StampNow(string path) { try From 286aa6281be5e95bbe5fb760fcc0dc6a36fe5904 Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Sun, 30 Aug 2026 08:40:00 +1000 Subject: [PATCH 17/29] Add SteamAutoCrack support and depot fixes This change adds SteamAutoCrack download/launch support with .NET runtime installation, update checks, and queue integration. It also hardens depot downloads by validating cached manifests, tracking created files for cancel/delete flows, and surfacing clearer progress/status phases. The downloads/history UI gains row actions for app-id copy and folder reveal, plus clearer history clearing and remove actions. Related localization strings and app config/cache updates were added to support the new tool and depot behavior. --- src/LuaToolsGui/App.xaml.cs | 1 + src/LuaToolsGui/AppConfig.cs | 14 + src/LuaToolsGui/Resources/Strings.Designer.cs | 26 +- src/LuaToolsGui/Resources/Strings.ar.resx | 31 +- src/LuaToolsGui/Resources/Strings.bg.resx | 31 +- src/LuaToolsGui/Resources/Strings.cs.resx | 31 +- src/LuaToolsGui/Resources/Strings.da.resx | 31 +- src/LuaToolsGui/Resources/Strings.de.resx | 31 +- src/LuaToolsGui/Resources/Strings.el.resx | 31 +- src/LuaToolsGui/Resources/Strings.es-419.resx | 31 +- src/LuaToolsGui/Resources/Strings.es.resx | 31 +- src/LuaToolsGui/Resources/Strings.fi.resx | 31 +- src/LuaToolsGui/Resources/Strings.fr.resx | 31 +- src/LuaToolsGui/Resources/Strings.hu.resx | 31 +- src/LuaToolsGui/Resources/Strings.id.resx | 31 +- src/LuaToolsGui/Resources/Strings.it.resx | 31 +- src/LuaToolsGui/Resources/Strings.ja.resx | 31 +- src/LuaToolsGui/Resources/Strings.ko.resx | 31 +- src/LuaToolsGui/Resources/Strings.nb.resx | 31 +- src/LuaToolsGui/Resources/Strings.nl.resx | 31 +- src/LuaToolsGui/Resources/Strings.pl.resx | 31 +- src/LuaToolsGui/Resources/Strings.pt-BR.resx | 31 +- src/LuaToolsGui/Resources/Strings.pt-PT.resx | 31 +- src/LuaToolsGui/Resources/Strings.resx | 31 +- src/LuaToolsGui/Resources/Strings.ro.resx | 31 +- src/LuaToolsGui/Resources/Strings.ru.resx | 31 +- src/LuaToolsGui/Resources/Strings.sv.resx | 31 +- src/LuaToolsGui/Resources/Strings.th.resx | 31 +- src/LuaToolsGui/Resources/Strings.tr.resx | 31 +- src/LuaToolsGui/Resources/Strings.uk.resx | 31 +- src/LuaToolsGui/Resources/Strings.vi.resx | 31 +- .../Resources/Strings.zh-Hans.resx | 31 +- .../Resources/Strings.zh-Hant.resx | 31 +- src/LuaToolsGui/Services/CacheService.cs | 24 +- .../Services/DepotDownloaderService.cs | 208 ++++++++++- .../Services/Downloads/DownloadHistory.cs | 22 +- .../Services/Downloads/DownloadItem.cs | 88 ++++- .../Services/Downloads/DownloadJob.cs | 30 +- .../Services/Downloads/DownloadQueue.cs | 44 ++- .../Services/Downloads/ManifestJobFactory.cs | 162 +++++++- src/LuaToolsGui/Services/LuaFileParser.cs | 28 +- src/LuaToolsGui/Services/ManifestFile.cs | 309 ++++++++++++++++ .../Services/SteamAutoCrackService.cs | 345 ++++++++++++++++++ src/LuaToolsGui/Services/SteamService.cs | 71 ++++ src/LuaToolsGui/ViewModels/BuildsViewModel.cs | 257 ++++++++++++- .../ViewModels/DownloadsViewModel.cs | 146 +++++++- src/LuaToolsGui/ViewModels/FixesViewModel.cs | 26 ++ src/LuaToolsGui/ViewModels/ManageViewModel.cs | 9 +- src/LuaToolsGui/Views/BuildsView.xaml | 76 +++- src/LuaToolsGui/Views/ConfirmOverlay.xaml | 5 +- src/LuaToolsGui/Views/DownloadView.xaml | 20 +- src/LuaToolsGui/Views/DownloadsView.xaml | 84 ++++- src/LuaToolsGui/Views/FixesView.xaml | 20 +- src/LuaToolsGui/Views/ManageView.xaml | 4 +- tests/LuaToolsGui.Tests/DownloadRateTests.cs | 169 +++++++++ 55 files changed, 2976 insertions(+), 142 deletions(-) create mode 100644 src/LuaToolsGui/Services/ManifestFile.cs create mode 100644 src/LuaToolsGui/Services/SteamAutoCrackService.cs create mode 100644 tests/LuaToolsGui.Tests/DownloadRateTests.cs diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index db3931f..41374c1 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -40,6 +40,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/LuaToolsGui/AppConfig.cs b/src/LuaToolsGui/AppConfig.cs index 494e37c..0a65eb0 100644 --- a/src/LuaToolsGui/AppConfig.cs +++ b/src/LuaToolsGui/AppConfig.cs @@ -49,6 +49,20 @@ public static class AppConfig // GUI manager from here and launches it. (Separate from the CLI fixer used by the mode install flow.) public const string CloudRedirectRepo = "Selectively11/CloudRedirect"; + // SteamAutoCrack: the Downloads page button fetches this release and launches its GUI. + // + // Three facts about the shipped asset drive how SteamAutoCrackService handles it: + // * GUI ONLY. The release contains a single exe and no SteamAutoCrack.CLI.exe, and that GUI parses + // no command-line arguments, so we can open it and nothing more. There is no way to drive a + // crack from here; the user does everything in their own window. + // * FRAMEWORK-DEPENDENT net10.0-windows. It is a single-file bundle but the runtime is NOT inside + // it (no hostpolicy/coreclr/System.Private.CoreLib, no includedFrameworks), so the .NET 10 + // DESKTOP runtime must be present. We install it on demand via Velopack's Runtimes API. + // * Its zip has a real directory TREE (Goldberg/, TEMP/ beside the exe), unlike the flat zip + // DepotDownloaderService expects. Their exe resolves those paths from its own base directory, + // so extract without flattening. + public const string SteamAutoCrackRepo = "SteamAutoCracks/Steam-auto-crack"; + // DepotDownloaderMod: downloads raw depot content from Steam's CDN using depot keys + a local manifest. // Powers the Depots page "Download" action. // diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 6951e4e..199186b 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -330,7 +330,6 @@ public static class Strings public static string Add_Err_BaseGame => Get(nameof(Add_Err_BaseGame)); public static string Add_Err_Generic => Get(nameof(Add_Err_Generic)); public static string Add_Err_Download => Get(nameof(Add_Err_Download)); - public static string Add_Err_Generate => Get(nameof(Add_Err_Generate)); public static string Add_Confirm_Replace => Get(nameof(Add_Confirm_Replace)); public static string Add_Confirm_NoChanges => Get(nameof(Add_Confirm_NoChanges)); public static string Add_Status_Cancelled => Get(nameof(Add_Status_Cancelled)); @@ -510,7 +509,6 @@ public static class Strings public static string Nav_Downloads => Get(nameof(Nav_Downloads)); public static string Downloads_Title => Get(nameof(Downloads_Title)); public static string Downloads_Empty => Get(nameof(Downloads_Empty)); - public static string Downloads_History_Empty => Get(nameof(Downloads_History_Empty)); public static string Downloads_Section_Active => Get(nameof(Downloads_Section_Active)); public static string Downloads_Section_History => Get(nameof(Downloads_Section_History)); public static string Downloads_Status_Queued => Get(nameof(Downloads_Status_Queued)); @@ -561,4 +559,28 @@ public static class Strings public static string Builds_Select_Space => Get(nameof(Builds_Select_Space)); public static string Downloads_Depots_Preparing => Get(nameof(Downloads_Depots_Preparing)); public static string Downloads_Depots_GettingTool => Get(nameof(Downloads_Depots_GettingTool)); + public static string Downloads_Kind_Tool => Get(nameof(Downloads_Kind_Tool)); + public static string Downloads_SteamAutoCrack => Get(nameof(Downloads_SteamAutoCrack)); + public static string Downloads_SAC_GettingRuntime => Get(nameof(Downloads_SAC_GettingRuntime)); + public static string Downloads_SAC_GettingTool => Get(nameof(Downloads_SAC_GettingTool)); + public static string Downloads_SAC_Launched => Get(nameof(Downloads_SAC_Launched)); + public static string Downloads_SAC_Err_Runtime => Get(nameof(Downloads_SAC_Err_Runtime)); + public static string Downloads_SAC_Err_Restart => Get(nameof(Downloads_SAC_Err_Restart)); + public static string Downloads_SAC_Err_Tool => Get(nameof(Downloads_SAC_Err_Tool)); + public static string Downloads_SAC_Err_Launch => Get(nameof(Downloads_SAC_Err_Launch)); + public static string Downloads_SAC_Updated => Get(nameof(Downloads_SAC_Updated)); + public static string Downloads_Depot_PreAllocating => Get(nameof(Downloads_Depot_PreAllocating)); + public static string Downloads_Depot_Validating => Get(nameof(Downloads_Depot_Validating)); + public static string Depot_Cancel_Title => Get(nameof(Depot_Cancel_Title)); + public static string Depot_Cancel_Body => Get(nameof(Depot_Cancel_Body)); + public static string Depot_Cancel_DeleteFailed => Get(nameof(Depot_Cancel_DeleteFailed)); + public static string Common_CopyAppId => Get(nameof(Common_CopyAppId)); + public static string Common_ShowInFolder => Get(nameof(Common_ShowInFolder)); + public static string Err_ClipboardBusy => Get(nameof(Err_ClipboardBusy)); + public static string Err_PathMissing => Get(nameof(Err_PathMissing)); + public static string Builds_Select_NoKey => Get(nameof(Builds_Select_NoKey)); + public static string Depot_Err_BadKey => Get(nameof(Depot_Err_BadKey)); + public static string Depot_Err_NoKeyFor => Get(nameof(Depot_Err_NoKeyFor)); + public static string Builds_Select_SharedHint => Get(nameof(Builds_Select_SharedHint)); + public static string Downloads_ClearHistory_Confirm => Get(nameof(Downloads_ClearHistory_Confirm)); } diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 5375d88..8bb97b8 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -369,7 +369,6 @@ تعذّر تحديد اللعبة الأساسية لمحتوى DLC هذا حدث خطأ ما — تحقق من اتصالك وحاول مجددًا. فشل التنزيل — تحقق من اتصالك وحاول مجددًا. - فشل الإنشاء — تحقق من اتصالك وحاول مجددًا. استبدال "{0}"؟ لا تغييرات في المستودع/DLC — المحتوى نفسه. أُلغي التثبيت — تُركت الملفات الحالية دون تغيير. @@ -553,7 +552,6 @@ التنزيلات التنزيلات لا توجد تنزيلات الآن. ابدأ بتنزيل شيء ما! - لا توجد تنزيلات مكتملة بعد. قائمة الانتظار السجل في الانتظار @@ -602,4 +600,33 @@ تعذّر الحصول على ملف manifest لهذا المستودع. جارٍ التحضير · {0} من {1} جارٍ إحضار أداة التنزيل + أداة + SteamAutoCrack + جارٍ فحص بيئة تشغيل ‎.NET + جارٍ إحضار SteamAutoCrack + تم فتح SteamAutoCrack. + تعذّر تثبيت بيئة تشغيل ‎.NET التي يحتاجها SteamAutoCrack. + تم تثبيت بيئة تشغيل ‎.NET، لكن يجب إعادة تشغيل Windows قبل أن يعمل SteamAutoCrack. + تعذّر تنزيل SteamAutoCrack. + تعذّر تشغيل SteamAutoCrack. + تم تحديث SteamAutoCrack. + تخصيص الملفات + فحص الملفات الموجودة + إلغاء التنزيل + تمت كتابة {0} بالفعل إلى: +{1} + +نعم - إيقاف وحذف تلك الملفات +لا - إيقاف مع الاحتفاظ بها +إلغاء - متابعة التنزيل + تعذّر حذف الملفات التي تم تنزيلها. لا تزال في {0}. + نسخ App ID + إظهار في المجلد + تعذّر النسخ — تطبيق آخر يستخدم الحافظة. + تعذّر فتح {0} — ربما تم نقله أو حذفه. + لا يوجد مفتاح فك تشفير في ملف Lua لهذا الـ depot. + مفتاح فك التشفير الخاص بالـ depot ‏{0} غير صحيح. + لا يوجد مفتاح فك تشفير في ملف Lua للـ depot ‏{0}. + بيئة تشغيل مشتركة — مثبّتة غالبًا بالفعل. حدّدها للتنزيل على أي حال. + هل تريد إزالة كل الإدخالات ({0}) من سجل التنزيلات؟ لن تتأثر الملفات التي تم تنزيلها. diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 6734941..bf12f47 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -345,7 +345,6 @@ Не може да се определи основната игра за това DLC Нещо се обърка — проверете връзката си и опитайте отново. Изтеглянето е неуспешно — проверете връзката си и опитайте отново. - Генерирането е неуспешно — проверете връзката си и опитайте отново. Замяна на „{0}“? Няма промени в депо/DLC — същото съдържание. Инсталирането е отказано — съществуващите файлове са непроменени. @@ -527,7 +526,6 @@ Изтегляния Изтегляния В момента няма изтегляния. Хайде, свали нещо! - Още няма завършени изтегляния. Опашка История На опашка @@ -576,4 +574,33 @@ Manifest файлът за това депо не можа да бъде получен. Подготовка · {0} от {1} Изтегляне на инструмента + Инструмент + SteamAutoCrack + Проверка на .NET средата + Изтегляне на SteamAutoCrack + SteamAutoCrack е отворен. + Не можа да се инсталира .NET средата, необходима на SteamAutoCrack. + .NET средата е инсталирана, но Windows трябва да се рестартира, преди SteamAutoCrack да може да работи. + SteamAutoCrack не можа да се изтегли. + SteamAutoCrack не можа да се стартира. + SteamAutoCrack е обновен. + заделяне на файлове + проверка на съществуващите файлове + Отказ на изтеглянето + Вече са записани {0} в: +{1} + +Да - спри и изтрий тези файлове +Не - спри, но ги запази +Отказ - продължи изтеглянето + Изтеглените файлове не можаха да бъдат изтрити. Още са в {0}. + Копиране на App ID + Показване в папката + Копирането е неуспешно — друго приложение използва клипборда. + {0} не може да се отвори — може да е преместен или изтрит. + В Lua няма ключ за дешифриране за този depot. + Ключът за дешифриране за depot {0} е грешен. + В Lua няма ключ за дешифриране за depot {0}. + Споделена среда за изпълнение — обикновено вече е инсталирана. Отметнете, за да я изтеглите. + Да се премахнат ли всички {0} записа от историята на изтеглянията? Изтеглените файлове не се засягат. diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 8fc91c3..1163975 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -345,7 +345,6 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Nepodařilo se určit základní hru pro toto DLC Něco se pokazilo — zkontrolujte připojení a zkuste to znovu. Stahování se nezdařilo — zkontrolujte připojení a zkuste to znovu. - Generování se nezdařilo — zkontrolujte připojení a zkuste to znovu. Nahradit „{0}“? Žádné změny depotu/DLC — stejný obsah. Instalace zrušena — stávající soubory zůstaly beze změny. @@ -527,7 +526,6 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Stahování Stahování Nic se právě nestahuje. Tak si něco stáhni! - Zatím žádná dokončená stahování. Fronta Historie Ve frontě @@ -576,4 +574,33 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Nepodařilo se získat manifest tohoto depotu. Příprava · {0} z {1} Získávání nástroje + Nástroj + SteamAutoCrack + Kontrola běhového prostředí .NET + Získávání SteamAutoCrack + SteamAutoCrack otevřen. + Nepodařilo se nainstalovat běhové prostředí .NET, které SteamAutoCrack potřebuje. + Běhové prostředí .NET bylo nainstalováno, ale Windows je nutné restartovat, než půjde SteamAutoCrack spustit. + Nepodařilo se stáhnout SteamAutoCrack. + Nepodařilo se spustit SteamAutoCrack. + SteamAutoCrack aktualizován. + alokace souborů + kontrola existujících souborů + Zrušit stahování + Již bylo zapsáno {0} do: +{1} + +Ano - zastavit a smazat tyto soubory +Ne - zastavit, ale ponechat +Zrušit - pokračovat ve stahování + Stažené soubory se nepodařilo smazat. Zůstávají v {0}. + Kopírovat App ID + Zobrazit ve složce + Nelze zkopírovat — schránku používá jiná aplikace. + Nelze otevřít {0} — možná byl přesunut nebo smazán. + V Lua není dešifrovací klíč pro tento depot. + Dešifrovací klíč pro depot {0} je nesprávný. + V Lua není dešifrovací klíč pro depot {0}. + Sdílený runtime — obvykle již nainstalován. Zaškrtnutím jej přesto stáhnete. + Odstranit všech {0} záznamů z historie stahování? Stažené soubory zůstanou zachovány. diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 3f88d0a..57129a4 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -345,7 +345,6 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Kunne ikke fastslå basisspillet for denne DLC Noget gik galt — tjek din forbindelse og prøv igen. Download mislykkedes — tjek din forbindelse og prøv igen. - Generering mislykkedes — tjek din forbindelse og prøv igen. Erstat "{0}"? Ingen depot-/DLC-ændringer — samme indhold. Installation annulleret — eksisterende filer er uændrede. @@ -527,7 +526,6 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Downloads Downloads Ingen downloads lige nu. Kom i gang – hent noget! - Ingen færdige downloads endnu. Kø Historik I kø @@ -576,4 +574,33 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Kunne ikke hente dette depots manifest. Forbereder · {0} af {1} Henter downloaderen + Værktøj + SteamAutoCrack + Kontrollerer .NET-runtime + Henter SteamAutoCrack + SteamAutoCrack er åbnet. + Kunne ikke installere den .NET-runtime, SteamAutoCrack kræver. + .NET-runtime blev installeret, men Windows skal genstartes, før SteamAutoCrack kan køre. + Kunne ikke hente SteamAutoCrack. + Kunne ikke starte SteamAutoCrack. + SteamAutoCrack er opdateret. + tildeler filer + kontrollerer eksisterende filer + Annuller download + Der er allerede skrevet {0} til: +{1} + +Ja - stop og slet filerne +Nej - stop, men behold dem +Annuller - fortsæt download + Kunne ikke slette de hentede filer. De ligger stadig i {0}. + Kopiér App ID + Vis i mappe + Kunne ikke kopiere — en anden app bruger udklipsholderen. + Kunne ikke åbne {0} — den er måske flyttet eller slettet. + Ingen dekrypteringsnøgle i Lua til denne depot. + Dekrypteringsnøglen til depot {0} er forkert. + Ingen dekrypteringsnøgle i Lua til depot {0}. + Delt runtime — normalt allerede installeret. Sæt flueben for at hente alligevel. + Fjern alle {0} poster fra downloadhistorikken? Hentede filer påvirkes ikke. diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index 6e67546..3054c0d 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -345,7 +345,6 @@ Dies löscht die .lua-Dateien aus Steam\config\stplug-in. Das Hauptspiel für diesen DLC konnte nicht ermittelt werden Etwas ist schiefgelaufen — überprüfe deine Verbindung und versuche es erneut. Download fehlgeschlagen — überprüfe deine Verbindung und versuche es erneut. - Generierung fehlgeschlagen — überprüfe deine Verbindung und versuche es erneut. "{0}" ersetzen? Keine Depot-/DLC-Änderungen — gleicher Inhalt. Installation abgebrochen — vorhandene Dateien unverändert. @@ -527,7 +526,6 @@ Dies löscht die .lua-Dateien aus Steam\config\stplug-in. Downloads Downloads Derzeit keine Downloads. Lad dir was runter! - Noch keine abgeschlossenen Downloads. Warteschlange Verlauf In Warteschlange @@ -576,4 +574,33 @@ Dies löscht die .lua-Dateien aus Steam\config\stplug-in. Das Manifest dieses Depots konnte nicht geladen werden. Vorbereiten · {0} von {1} Downloader wird geholt + Werkzeug + SteamAutoCrack + .NET-Runtime wird geprüft + SteamAutoCrack wird geholt + SteamAutoCrack geöffnet. + Die von SteamAutoCrack benötigte .NET-Runtime konnte nicht installiert werden. + Die .NET-Runtime wurde installiert, aber Windows muss neu gestartet werden, bevor SteamAutoCrack läuft. + SteamAutoCrack konnte nicht heruntergeladen werden. + SteamAutoCrack konnte nicht gestartet werden. + SteamAutoCrack aktualisiert. + Dateien werden reserviert + vorhandene Dateien werden geprüft + Download abbrechen + {0} wurden bereits geschrieben nach: +{1} + +Ja - anhalten und diese Dateien löschen +Nein - anhalten, Dateien behalten +Abbrechen - weiter herunterladen + Die heruntergeladenen Dateien konnten nicht gelöscht werden. Sie liegen weiterhin in {0}. + App-ID kopieren + Im Ordner anzeigen + Kopieren fehlgeschlagen — eine andere App blockiert die Zwischenablage. + {0} konnte nicht geöffnet werden — möglicherweise verschoben oder gelöscht. + Kein Entschlüsselungsschlüssel im Lua für dieses Depot. + Der Entschlüsselungsschlüssel für Depot {0} ist falsch. + Kein Entschlüsselungsschlüssel im Lua für Depot {0}. + Gemeinsame Laufzeit — meist bereits installiert. Zum Herunterladen trotzdem ankreuzen. + Alle {0} Einträge aus dem Download-Verlauf entfernen? Heruntergeladene Dateien bleiben erhalten. diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index 4c6812e..4850027 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -345,7 +345,6 @@ Δεν ήταν δυνατός ο προσδιορισμός του βασικού παιχνιδιού για αυτό το DLC Κάτι πήγε στραβά — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Η λήψη απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. - Η δημιουργία απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Αντικατάσταση του «{0}»; Καμία αλλαγή depot/DLC — ίδιο περιεχόμενο. Η εγκατάσταση ακυρώθηκε — τα υπάρχοντα αρχεία παρέμειναν αμετάβλητα. @@ -527,7 +526,6 @@ Λήψεις Λήψεις Καμία λήψη αυτή τη στιγμή. Έλα, κατέβασε κάτι! - Δεν υπάρχουν ολοκληρωμένες λήψεις ακόμη. Ουρά Ιστορικό Σε ουρά @@ -576,4 +574,33 @@ Δεν ήταν δυνατή η λήψη του manifest αυτού του depot. Προετοιμασία · {0} από {1} Λήψη του εργαλείου + Εργαλείο + SteamAutoCrack + Έλεγχος του runtime .NET + Λήψη του SteamAutoCrack + Το SteamAutoCrack άνοιξε. + Δεν ήταν δυνατή η εγκατάσταση του runtime .NET που χρειάζεται το SteamAutoCrack. + Το runtime .NET εγκαταστάθηκε, αλλά τα Windows χρειάζονται επανεκκίνηση πριν τρέξει το SteamAutoCrack. + Δεν ήταν δυνατή η λήψη του SteamAutoCrack. + Δεν ήταν δυνατή η εκκίνηση του SteamAutoCrack. + Το SteamAutoCrack ενημερώθηκε. + δέσμευση αρχείων + έλεγχος υπαρχόντων αρχείων + Ακύρωση λήψης + Έχουν ήδη γραφτεί {0} στο: +{1} + +Ναι - διακοπή και διαγραφή των αρχείων +Όχι - διακοπή αλλά διατήρηση +Άκυρο - συνέχιση της λήψης + Δεν ήταν δυνατή η διαγραφή των ληφθέντων αρχείων. Παραμένουν στο {0}. + Αντιγραφή App ID + Εμφάνιση στον φάκελο + Δεν έγινε αντιγραφή — άλλη εφαρμογή χρησιμοποιεί το πρόχειρο. + Δεν άνοιξε το {0} — ίσως μετακινήθηκε ή διαγράφηκε. + Δεν υπάρχει κλειδί αποκρυπτογράφησης στο Lua για αυτό το depot. + Το κλειδί αποκρυπτογράφησης για το depot {0} είναι λάθος. + Δεν υπάρχει κλειδί αποκρυπτογράφησης στο Lua για το depot {0}. + Κοινόχρηστο runtime — συνήθως ήδη εγκατεστημένο. Επιλέξτε για λήψη ούτως ή άλλως. + Να αφαιρεθούν και οι {0} καταχωρίσεις από το ιστορικό λήψεων; Τα ληφθέντα αρχεία δεν επηρεάζονται. diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index e139e50..9f24190 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -345,7 +345,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo determinar el juego base de este DLC Algo salió mal: comprueba tu conexión e inténtalo de nuevo. Error de descarga: comprueba tu conexión e inténtalo de nuevo. - Error de generación: comprueba tu conexión e inténtalo de nuevo. ¿Reemplazar «{0}»? Sin cambios de depósito/DLC: mismo contenido. Instalación cancelada: los archivos existentes no se han modificado. @@ -527,7 +526,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. Descargas Descargas No hay descargas en curso. ¡Dale, descarga algo! - Todavía no hay descargas terminadas. Cola Historial En cola @@ -576,4 +574,33 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo obtener el manifest de este depósito. Preparando · {0} de {1} Obteniendo el descargador + Herramienta + SteamAutoCrack + Comprobando el runtime de .NET + Obteniendo SteamAutoCrack + SteamAutoCrack abierto. + No se pudo instalar el runtime de .NET que necesita SteamAutoCrack. + El runtime de .NET se instaló, pero Windows debe reiniciarse antes de poder ejecutar SteamAutoCrack. + No se pudo descargar SteamAutoCrack. + No se pudo iniciar SteamAutoCrack. + SteamAutoCrack actualizado. + asignando archivos + comprobando archivos existentes + Cancelar descarga + Ya se escribieron {0} en: +{1} + +Sí: detener y eliminar esos archivos +No: detener pero conservarlos +Cancelar: seguir descargando + No se pudieron eliminar los archivos descargados. Siguen en {0}. + Copiar App ID + Mostrar en la carpeta + No se pudo copiar: otra aplicación está usando el portapapeles. + No se pudo abrir {0}: puede que se haya movido o eliminado. + No hay clave de descifrado en el Lua para este depot. + La clave de descifrado del depot {0} es incorrecta. + No hay clave de descifrado en el Lua para el depot {0}. + Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. + ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index b9d19f5..f49178d 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -345,7 +345,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo determinar el juego base de este DLC Algo salió mal: comprueba tu conexión e inténtalo de nuevo. Error de descarga: comprueba tu conexión e inténtalo de nuevo. - Error de generación: comprueba tu conexión e inténtalo de nuevo. ¿Reemplazar «{0}»? Sin cambios de depósito/DLC: mismo contenido. Instalación cancelada: los archivos existentes no se han modificado. @@ -527,7 +526,6 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. Descargas Descargas No hay descargas en curso. ¡Venga, descarga algo! - Todavía no hay descargas terminadas. Cola Historial En cola @@ -576,4 +574,33 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. No se pudo obtener el manifest de este depósito. Preparando · {0} de {1} Obteniendo el descargador + Herramienta + SteamAutoCrack + Comprobando el runtime de .NET + Obteniendo SteamAutoCrack + SteamAutoCrack abierto. + No se pudo instalar el runtime de .NET que necesita SteamAutoCrack. + El runtime de .NET se instaló, pero Windows debe reiniciarse antes de poder ejecutar SteamAutoCrack. + No se pudo descargar SteamAutoCrack. + No se pudo iniciar SteamAutoCrack. + SteamAutoCrack actualizado. + asignando archivos + comprobando archivos existentes + Cancelar descarga + Ya se han escrito {0} en: +{1} + +Sí: detener y eliminar esos archivos +No: detener pero conservarlos +Cancelar: seguir descargando + No se pudieron eliminar los archivos descargados. Siguen en {0}. + Copiar App ID + Mostrar en la carpeta + No se pudo copiar: otra aplicación está usando el portapapeles. + No se pudo abrir {0}: puede que se haya movido o eliminado. + No hay clave de descifrado en el Lua para este depot. + La clave de descifrado del depot {0} es incorrecta. + No hay clave de descifrado en el Lua para el depot {0}. + Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. + ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index a622d2a..98f667a 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -345,7 +345,6 @@ Tämä poistaa .lua-tiedostot kansiosta Steam\config\stplug-in. Tämän DLC:n peruspeliä ei voitu määrittää Jokin meni pieleen — tarkista yhteytesi ja yritä uudelleen. Lataus epäonnistui — tarkista yhteytesi ja yritä uudelleen. - Luonti epäonnistui — tarkista yhteytesi ja yritä uudelleen. Korvataanko "{0}"? Ei depo-/DLC-muutoksia — sama sisältö. Asennus peruutettu — olemassa olevat tiedostot ennallaan. @@ -527,7 +526,6 @@ Tämä poistaa .lua-tiedostot kansiosta Steam\config\stplug-in. Lataukset Lataukset Ei latauksia juuri nyt. Lataa nyt jotain! - Ei valmiita latauksia vielä. Jono Historia Jonossa @@ -576,4 +574,33 @@ Tämä poistaa .lua-tiedostot kansiosta Steam\config\stplug-in. Tämän depotin manifestia ei saatu haettua. Valmistellaan · {0}/{1} Haetaan lataustyökalua + Työkalu + SteamAutoCrack + Tarkistetaan .NET-ajonaikaa + Haetaan SteamAutoCrackia + SteamAutoCrack avattiin. + SteamAutoCrackin tarvitsemaa .NET-ajonaikaa ei voitu asentaa. + .NET-ajonaika asennettiin, mutta Windows on käynnistettävä uudelleen ennen SteamAutoCrackin suorittamista. + SteamAutoCrackia ei voitu ladata. + SteamAutoCrackia ei voitu käynnistää. + SteamAutoCrack päivitettiin. + varataan tiedostoja + tarkistetaan olemassa olevia tiedostoja + Peruuta lataus + Kohteeseen on jo kirjoitettu {0}: +{1} + +Kyllä - pysäytä ja poista tiedostot +Ei - pysäytä mutta säilytä ne +Peruuta - jatka lataamista + Ladattuja tiedostoja ei voitu poistaa. Ne ovat yhä kohteessa {0}. + Kopioi App ID + Näytä kansiossa + Kopiointi epäonnistui — toinen sovellus käyttää leikepöytää. + Kohdetta {0} ei voitu avata — se on ehkä siirretty tai poistettu. + Lua-tiedostossa ei ole salauksenpurkuavainta tälle depotille. + Depotin {0} salauksenpurkuavain on väärä. + Lua-tiedostossa ei ole salauksenpurkuavainta depotille {0}. + Jaettu suoritusympäristö — yleensä jo asennettu. Valitse ladataksesi silti. + Poistetaanko kaikki {0} merkintää lataushistoriasta? Ladattuihin tiedostoihin tämä ei vaikuta. diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 5f386a5..9bd3003 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -345,7 +345,6 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Impossible de déterminer le jeu de base de ce DLC Une erreur s'est produite — vérifiez votre connexion et réessayez. Échec du téléchargement — vérifiez votre connexion et réessayez. - Échec de la génération — vérifiez votre connexion et réessayez. Remplacer « {0} » ? Aucun changement de dépôt/DLC — même contenu. Installation annulée — les fichiers existants sont inchangés. @@ -527,7 +526,6 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Téléchargements Téléchargements Aucun téléchargement en cours. Lancez-vous, téléchargez un truc ! - Aucun téléchargement terminé pour l'instant. File d'attente Historique En attente @@ -576,4 +574,33 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Impossible de récupérer le manifest de ce dépôt. Préparation · {0} sur {1} Récupération du téléchargeur + Outil + SteamAutoCrack + Vérification du runtime .NET + Récupération de SteamAutoCrack + SteamAutoCrack ouvert. + Impossible d'installer le runtime .NET requis par SteamAutoCrack. + Le runtime .NET est installé, mais Windows doit redémarrer avant de pouvoir lancer SteamAutoCrack. + Impossible de télécharger SteamAutoCrack. + Impossible de lancer SteamAutoCrack. + SteamAutoCrack mis à jour. + allocation des fichiers + vérification des fichiers existants + Annuler le téléchargement + {0} ont déjà été écrits dans : +{1} + +Oui - arrêter et supprimer ces fichiers +Non - arrêter mais les conserver +Annuler - continuer le téléchargement + Impossible de supprimer les fichiers téléchargés. Ils sont toujours dans {0}. + Copier l'App ID + Afficher dans le dossier + Copie impossible — une autre application occupe le presse-papiers. + Impossible d'ouvrir {0} — il a pu être déplacé ou supprimé. + Aucune clé de déchiffrement dans le Lua pour ce depot. + La clé de déchiffrement du depot {0} est incorrecte. + Aucune clé de déchiffrement dans le Lua pour le depot {0}. + Runtime partagé — généralement déjà installé. Cochez pour le télécharger quand même. + Supprimer les {0} entrées de l'historique des téléchargements ? Les fichiers téléchargés ne sont pas affectés. diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index f2f72db..184a58e 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -345,7 +345,6 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Nem sikerült meghatározni a DLC alapjátékát Hiba történt — ellenőrizd a kapcsolatot, és próbáld újra. Letöltés sikertelen — ellenőrizd a kapcsolatot, és próbáld újra. - Generálás sikertelen — ellenőrizd a kapcsolatot, és próbáld újra. Lecseréled a következőt: „{0}”? Nincs depó-/DLC-változás — ugyanaz a tartalom. Telepítés megszakítva — a meglévő fájlok változatlanok. @@ -527,7 +526,6 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Letöltések Letöltések Most nincs letöltés. Gyerünk, tölts le valamit! - Még nincs befejezett letöltés. Várólista Előzmények Várólistán @@ -576,4 +574,33 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Nem sikerült megszerezni a depot manifestjét. Előkészítés · {0} / {1} Letöltő beszerzése + Eszköz + SteamAutoCrack + .NET futtatókörnyezet ellenőrzése + SteamAutoCrack beszerzése + A SteamAutoCrack megnyílt. + Nem sikerült telepíteni a SteamAutoCrackhez szükséges .NET futtatókörnyezetet. + A .NET futtatókörnyezet települt, de a Windows újraindítása szükséges a SteamAutoCrack futtatásához. + Nem sikerült letölteni a SteamAutoCracket. + Nem sikerült elindítani a SteamAutoCracket. + A SteamAutoCrack frissült. + fájlok lefoglalása + meglévő fájlok ellenőrzése + Letöltés megszakítása + Már {0} lett kiírva ide: +{1} + +Igen - leállítás és a fájlok törlése +Nem - leállítás, de a fájlok megtartása +Mégse - letöltés folytatása + A letöltött fájlokat nem sikerült törölni. Továbbra is itt vannak: {0}. + App ID másolása + Megjelenítés a mappában + Nem sikerült a másolás — egy másik alkalmazás használja a vágólapot. + A(z) {0} nem nyitható meg — lehet, hogy áthelyezték vagy törölték. + A Lua nem tartalmaz visszafejtő kulcsot ehhez a depot-hoz. + A(z) {0} depot visszafejtő kulcsa hibás. + A Lua nem tartalmaz visszafejtő kulcsot a(z) {0} depot-hoz. + Megosztott futtatókörnyezet — általában már telepítve van. Jelölje be, ha mégis letöltené. + Eltávolítja mind a(z) {0} bejegyzést a letöltési előzményekből? A letöltött fájlokat ez nem érinti. diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index d9981ad..2bff851 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -345,7 +345,6 @@ Ini menghapus berkas .lua dari Steam\config\stplug-in. Tidak dapat menentukan gim dasar untuk DLC ini Terjadi kesalahan — periksa koneksi Anda dan coba lagi. Unduhan gagal — periksa koneksi Anda dan coba lagi. - Pembuatan gagal — periksa koneksi Anda dan coba lagi. Ganti "{0}"? Tidak ada perubahan depot/DLC — konten sama. Pemasangan dibatalkan — file yang ada tidak berubah. @@ -527,7 +526,6 @@ Ini menghapus berkas .lua dari Steam\config\stplug-in. Unduhan Unduhan Tidak ada unduhan saat ini. Ayo, unduh sesuatu! - Belum ada unduhan yang selesai. Antrean Riwayat Dalam antrean @@ -576,4 +574,33 @@ Ini menghapus berkas .lua dari Steam\config\stplug-in. Tidak bisa mendapatkan manifest depot ini. Menyiapkan · {0} dari {1} Mengambil pengunduh + Alat + SteamAutoCrack + Memeriksa runtime .NET + Mengambil SteamAutoCrack + SteamAutoCrack dibuka. + Tidak dapat memasang runtime .NET yang dibutuhkan SteamAutoCrack. + Runtime .NET terpasang, tetapi Windows perlu dimulai ulang sebelum SteamAutoCrack bisa berjalan. + Tidak dapat mengunduh SteamAutoCrack. + Tidak dapat menjalankan SteamAutoCrack. + SteamAutoCrack diperbarui. + mengalokasikan berkas + memeriksa berkas yang ada + Batalkan unduhan + Sudah tertulis {0} ke: +{1} + +Ya - hentikan dan hapus berkas tersebut +Tidak - hentikan tapi simpan +Batal - lanjutkan mengunduh + Tidak dapat menghapus berkas yang diunduh. Berkas masih ada di {0}. + Salin App ID + Tampilkan di folder + Tidak bisa menyalin — aplikasi lain sedang memakai papan klip. + Tidak dapat membuka {0} — mungkin sudah dipindahkan atau dihapus. + Tidak ada kunci dekripsi di Lua untuk depot ini. + Kunci dekripsi untuk depot {0} salah. + Tidak ada kunci dekripsi di Lua untuk depot {0}. + Runtime bersama — biasanya sudah terpasang. Centang untuk tetap mengunduh. + Hapus semua {0} entri dari riwayat unduhan? Berkas yang sudah diunduh tidak terpengaruh. diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index 147a78a..3d89b23 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -345,7 +345,6 @@ Questo elimina i file .lua da Steam\config\stplug-in. Impossibile determinare il gioco base di questo DLC Qualcosa è andato storto — controlla la tua connessione e riprova. Download non riuscito — controlla la tua connessione e riprova. - Generazione non riuscita — controlla la tua connessione e riprova. Sostituire «{0}»? Nessuna modifica a depot/DLC — stesso contenuto. Installazione annullata — i file esistenti rimangono invariati. @@ -527,7 +526,6 @@ Questo elimina i file .lua da Steam\config\stplug-in. Download Download Nessun download in corso. Dai, scarica qualcosa! - Nessun download completato per ora. Coda Cronologia In coda @@ -576,4 +574,33 @@ Questo elimina i file .lua da Steam\config\stplug-in. Impossibile ottenere il manifest di questo depot. Preparazione · {0} di {1} Recupero del downloader + Strumento + SteamAutoCrack + Verifica del runtime .NET + Recupero di SteamAutoCrack + SteamAutoCrack aperto. + Impossibile installare il runtime .NET richiesto da SteamAutoCrack. + Il runtime .NET è stato installato, ma Windows deve riavviarsi prima di poter eseguire SteamAutoCrack. + Impossibile scaricare SteamAutoCrack. + Impossibile avviare SteamAutoCrack. + SteamAutoCrack aggiornato. + allocazione file + verifica dei file esistenti + Annulla download + Sono già stati scritti {0} in: +{1} + +Sì - interrompi ed elimina quei file +No - interrompi ma conservali +Annulla - continua a scaricare + Impossibile eliminare i file scaricati. Si trovano ancora in {0}. + Copia App ID + Mostra nella cartella + Impossibile copiare — un'altra app sta usando gli appunti. + Impossibile aprire {0} — potrebbe essere stato spostato o eliminato. + Nessuna chiave di decrittazione nel Lua per questo depot. + La chiave di decrittazione del depot {0} non è corretta. + Nessuna chiave di decrittazione nel Lua per il depot {0}. + Runtime condiviso — di solito già installato. Spunta per scaricarlo comunque. + Rimuovere tutte le {0} voci dalla cronologia dei download? I file scaricati non vengono toccati. diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index afa06eb..f59f34d 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -345,7 +345,6 @@ Steam\config\stplug-in から .lua ファイルを削除します。このDLCのベースゲームを特定できませんでした 問題が発生しました — 接続を確認してもう一度お試しください。 ダウンロードに失敗しました — 接続を確認してもう一度お試しください。 - 生成に失敗しました — 接続を確認してもう一度お試しください。 「{0}」を置き換えますか? デポ/DLCの変更なし — 同じ内容です。 インストールをキャンセルしました — 既存のファイルは変更されていません。 @@ -527,7 +526,6 @@ Steam\config\stplug-in から .lua ファイルを削除します。ダウンロード ダウンロード 現在ダウンロードはありません。何かダウンロードしてみましょう! - 完了したダウンロードはまだありません。 キュー 履歴 待機中 @@ -576,4 +574,33 @@ Steam\config\stplug-in から .lua ファイルを削除します。このデポの manifest を取得できませんでした。 準備中 · {0} / {1} ダウンローダーを取得中 + ツール + SteamAutoCrack + .NET ランタイムを確認中 + SteamAutoCrack を取得中 + SteamAutoCrack を開きました。 + SteamAutoCrack に必要な .NET ランタイムをインストールできませんでした。 + .NET ランタイムはインストールされましたが、SteamAutoCrack を実行するには Windows の再起動が必要です。 + SteamAutoCrack をダウンロードできませんでした。 + SteamAutoCrack を起動できませんでした。 + SteamAutoCrack を更新しました。 + ファイルを確保中 + 既存ファイルを検証中 + ダウンロードを取消 + すでに {0} が次の場所に書き込まれています: +{1} + +はい - 停止してファイルを削除 +いいえ - 停止するがファイルは残す +キャンセル - ダウンロードを続行 + ダウンロード済みファイルを削除できませんでした。{0} に残っています。 + App ID をコピー + フォルダーに表示 + コピーできません — 他のアプリがクリップボードを使用中です。 + {0} を開けません — 移動または削除された可能性があります。 + Lua に この depot の復号キーがありません。 + depot {0} の復号キーが正しくありません。 + Lua に depot {0} の復号キーがありません。 + 共有ランタイム — 通常はインストール済みです。必要ならチェックしてください。 + ダウンロード履歴から {0} 件すべてを削除しますか?ダウンロード済みのファイルには影響しません。 diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index b6e6987..a2294a5 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -345,7 +345,6 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 이 DLC의 본편 게임을 확인할 수 없습니다 문제가 발생했습니다 — 연결을 확인하고 다시 시도하세요. 다운로드 실패 — 연결을 확인하고 다시 시도하세요. - 생성 실패 — 연결을 확인하고 다시 시도하세요. "{0}"을(를) 교체할까요? 디포/DLC 변경 없음 — 동일한 콘텐츠입니다. 설치가 취소되었습니다 — 기존 파일은 변경되지 않았습니다. @@ -527,7 +526,6 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 다운로드 다운로드 현재 다운로드가 없습니다. 뭐라도 다운로드해 보세요! - 완료된 다운로드가 아직 없습니다. 대기열 기록 대기 중 @@ -576,4 +574,33 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 이 디포의 manifest를 가져오지 못했습니다. 준비 중 · {0} / {1} 다운로더 가져오는 중 + 도구 + SteamAutoCrack + .NET 런타임 확인 중 + SteamAutoCrack 가져오는 중 + SteamAutoCrack을 열었습니다. + SteamAutoCrack에 필요한 .NET 런타임을 설치하지 못했습니다. + .NET 런타임이 설치되었지만 SteamAutoCrack을 실행하려면 Windows를 다시 시작해야 합니다. + SteamAutoCrack을 다운로드하지 못했습니다. + SteamAutoCrack을 시작하지 못했습니다. + SteamAutoCrack이 업데이트되었습니다. + 파일 할당 중 + 기존 파일 확인 중 + 다운로드 취소 + 이미 {0}이(가) 다음 위치에 기록되었습니다: +{1} + +예 - 중지하고 파일 삭제 +아니요 - 중지하되 파일 유지 +취소 - 계속 다운로드 + 다운로드한 파일을 삭제하지 못했습니다. {0}에 그대로 있습니다. + App ID 복사 + 폴더에서 보기 + 복사할 수 없습니다 — 다른 앱이 클립보드를 사용 중입니다. + {0}을(를) 열 수 없습니다 — 이동되었거나 삭제된 것 같습니다. + Lua에 이 depot의 복호화 키가 없습니다. + depot {0}의 복호화 키가 잘못되었습니다. + Lua에 depot {0}의 복호화 키가 없습니다. + 공유 런타임 — 대개 이미 설치되어 있습니다. 그래도 받으려면 선택하세요. + 다운로드 기록에서 {0}개 항목을 모두 제거할까요? 내려받은 파일은 영향을 받지 않습니다. diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index c2a3dda..5c61f94 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -345,7 +345,6 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Kunne ikke fastslå grunnspillet for denne DLC-en Noe gikk galt — sjekk tilkoblingen din og prøv igjen. Nedlasting mislyktes — sjekk tilkoblingen din og prøv igjen. - Generering mislyktes — sjekk tilkoblingen din og prøv igjen. Erstatte "{0}"? Ingen depot-/DLC-endringer — samme innhold. Installasjon avbrutt — eksisterende filer er uendret. @@ -527,7 +526,6 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Nedlastinger Nedlastinger Ingen nedlastinger akkurat nå. Kom i gang – last ned noe! - Ingen fullførte nedlastinger ennå. Kø Historikk I kø @@ -576,4 +574,33 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Fikk ikke tak i manifestet for dette depotet. Forbereder · {0} av {1} Henter nedlasteren + Verktøy + SteamAutoCrack + Kontrollerer .NET-kjøretid + Henter SteamAutoCrack + SteamAutoCrack er åpnet. + Kunne ikke installere .NET-kjøretiden SteamAutoCrack trenger. + .NET-kjøretiden ble installert, men Windows må startes på nytt før SteamAutoCrack kan kjøre. + Kunne ikke laste ned SteamAutoCrack. + Kunne ikke starte SteamAutoCrack. + SteamAutoCrack er oppdatert. + tildeler filer + kontrollerer eksisterende filer + Avbryt nedlasting + {0} er allerede skrevet til: +{1} + +Ja - stopp og slett filene +Nei - stopp, men behold dem +Avbryt - fortsett nedlastingen + Kunne ikke slette de nedlastede filene. De ligger fortsatt i {0}. + Kopier App ID + Vis i mappe + Kunne ikke kopiere — en annen app bruker utklippstavlen. + Kunne ikke åpne {0} — den kan være flyttet eller slettet. + Ingen dekrypteringsnøkkel i Lua for denne depoten. + Dekrypteringsnøkkelen for depot {0} er feil. + Ingen dekrypteringsnøkkel i Lua for depot {0}. + Delt kjøretid — vanligvis allerede installert. Huk av for å laste ned likevel. + Fjerne alle {0} oppføringer fra nedlastingsloggen? Nedlastede filer påvirkes ikke. diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index 6095308..1821279 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -345,7 +345,6 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Kon de basisgame voor deze DLC niet bepalen Er is iets misgegaan — controleer je verbinding en probeer opnieuw. Download mislukt — controleer je verbinding en probeer opnieuw. - Genereren mislukt — controleer je verbinding en probeer opnieuw. "{0}" vervangen? Geen depot-/DLC-wijzigingen — dezelfde inhoud. Installatie geannuleerd — bestaande bestanden ongewijzigd. @@ -527,7 +526,6 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Downloads Downloads Geen downloads bezig. Kom op, download iets! - Nog geen voltooide downloads. Wachtrij Geschiedenis In wachtrij @@ -576,4 +574,33 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Kon het manifest van dit depot niet ophalen. Voorbereiden · {0} van {1} Downloader ophalen + Hulpprogramma + SteamAutoCrack + .NET-runtime controleren + SteamAutoCrack ophalen + SteamAutoCrack geopend. + Kan de .NET-runtime die SteamAutoCrack nodig heeft niet installeren. + De .NET-runtime is geïnstalleerd, maar Windows moet opnieuw opstarten voordat SteamAutoCrack kan draaien. + Kan SteamAutoCrack niet downloaden. + Kan SteamAutoCrack niet starten. + SteamAutoCrack bijgewerkt. + bestanden reserveren + bestaande bestanden controleren + Download annuleren + Er is al {0} geschreven naar: +{1} + +Ja - stoppen en die bestanden verwijderen +Nee - stoppen maar behouden +Annuleren - doorgaan met downloaden + Kan de gedownloade bestanden niet verwijderen. Ze staan nog in {0}. + App ID kopiëren + In map weergeven + Kopiëren mislukt — een andere app gebruikt het klembord. + Kan {0} niet openen — mogelijk verplaatst of verwijderd. + Geen ontsleutelingssleutel in de Lua voor deze depot. + De ontsleutelingssleutel voor depot {0} is onjuist. + Geen ontsleutelingssleutel in de Lua voor depot {0}. + Gedeelde runtime — meestal al geïnstalleerd. Vink aan om toch te downloaden. + Alle {0} items uit de downloadgeschiedenis verwijderen? Gedownloade bestanden blijven staan. diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index 71fa38a..cfd84fa 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -345,7 +345,6 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Nie udało się ustalić gry podstawowej dla tego DLC Coś poszło nie tak — sprawdź połączenie i spróbuj ponownie. Pobieranie nie powiodło się — sprawdź połączenie i spróbuj ponownie. - Generowanie nie powiodło się — sprawdź połączenie i spróbuj ponownie. Zastąpić „{0}”? Brak zmian depotu/DLC — ta sama zawartość. Anulowano instalację — istniejące pliki pozostały bez zmian. @@ -527,7 +526,6 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Pobieranie Pobieranie Nic się teraz nie pobiera. No dawaj, pobierz coś! - Brak ukończonych pobrań. Kolejka Historia W kolejce @@ -576,4 +574,33 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Nie udało się pobrać manifestu tego depotu. Przygotowywanie · {0} z {1} Pobieranie narzędzia + Narzędzie + SteamAutoCrack + Sprawdzanie środowiska .NET + Pobieranie SteamAutoCrack + Otwarto SteamAutoCrack. + Nie udało się zainstalować środowiska .NET wymaganego przez SteamAutoCrack. + Środowisko .NET zostało zainstalowane, ale Windows wymaga ponownego uruchomienia przed uruchomieniem SteamAutoCrack. + Nie udało się pobrać SteamAutoCrack. + Nie udało się uruchomić SteamAutoCrack. + Zaktualizowano SteamAutoCrack. + przydzielanie plików + sprawdzanie istniejących plików + Anuluj pobieranie + Zapisano już {0} w: +{1} + +Tak - zatrzymaj i usuń te pliki +Nie - zatrzymaj, ale zachowaj +Anuluj - kontynuuj pobieranie + Nie udało się usunąć pobranych plików. Nadal są w {0}. + Kopiuj App ID + Pokaż w folderze + Nie można skopiować — inna aplikacja blokuje schowek. + Nie można otworzyć {0} — mógł zostać przeniesiony lub usunięty. + Brak klucza deszyfrującego w Lua dla tego depotu. + Klucz deszyfrujący dla depotu {0} jest nieprawidłowy. + Brak klucza deszyfrującego w Lua dla depotu {0}. + Współdzielone środowisko — zwykle już zainstalowane. Zaznacz, aby i tak pobrać. + Usunąć wszystkie {0} wpisy z historii pobierania? Pobrane pliki pozostaną nienaruszone. diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index 2fed9f5..c5801cd 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -345,7 +345,6 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Não foi possível determinar o jogo base deste DLC Algo deu errado — verifique sua conexão e tente novamente. Falha no download — verifique sua conexão e tente novamente. - Falha na geração — verifique sua conexão e tente novamente. Substituir "{0}"? Sem alterações de depósito/DLC — mesmo conteúdo. Instalação cancelada — os arquivos existentes não foram alterados. @@ -527,7 +526,6 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Downloads Downloads Nenhum download em andamento. Bora, baixa alguma coisa! - Ainda não há downloads concluídos. Fila Histórico Na fila @@ -576,4 +574,33 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Não deu para obter o manifest deste depot. Preparando · {0} de {1} Obtendo o downloader + Ferramenta + SteamAutoCrack + Verificando o runtime do .NET + Obtendo o SteamAutoCrack + SteamAutoCrack aberto. + Não foi possível instalar o runtime do .NET exigido pelo SteamAutoCrack. + O runtime do .NET foi instalado, mas o Windows precisa reiniciar antes de o SteamAutoCrack rodar. + Não foi possível baixar o SteamAutoCrack. + Não foi possível iniciar o SteamAutoCrack. + SteamAutoCrack atualizado. + alocando arquivos + verificando arquivos existentes + Cancelar download + {0} já foram gravados em: +{1} + +Sim - parar e excluir esses arquivos +Não - parar mas mantê-los +Cancelar - continuar baixando + Não foi possível excluir os arquivos baixados. Eles ainda estão em {0}. + Copiar App ID + Mostrar na pasta + Não foi possível copiar — outro app está usando a área de transferência. + Não foi possível abrir {0} — pode ter sido movido ou excluído. + Sem chave de descriptografia no Lua para este depot. + A chave de descriptografia do depot {0} está errada. + Sem chave de descriptografia no Lua para o depot {0}. + Runtime compartilhado — normalmente já instalado. Marque para baixar mesmo assim. + Remover todas as {0} entradas do histórico de downloads? Os arquivos baixados não são afetados. diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index 4af6349..0835d98 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -345,7 +345,6 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Não foi possível determinar o jogo base deste DLC Algo correu mal — verifique a sua ligação e tente novamente. Falha na transferência — verifique a sua ligação e tente novamente. - Falha na geração — verifique a sua ligação e tente novamente. Substituir "{0}"? Sem alterações de depósito/DLC — mesmo conteúdo. Instalação cancelada — os ficheiros existentes não foram alterados. @@ -527,7 +526,6 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Transferências Transferências Nenhuma transferência em curso. Vá lá, descarregue alguma coisa! - Ainda não há transferências concluídas. Fila Histórico Em fila @@ -576,4 +574,33 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Não foi possível obter o manifest deste depot. A preparar · {0} de {1} A obter o transferidor + Ferramenta + SteamAutoCrack + A verificar o runtime do .NET + A obter o SteamAutoCrack + SteamAutoCrack aberto. + Não foi possível instalar o runtime do .NET exigido pelo SteamAutoCrack. + O runtime do .NET foi instalado, mas o Windows precisa de reiniciar antes de o SteamAutoCrack correr. + Não foi possível transferir o SteamAutoCrack. + Não foi possível iniciar o SteamAutoCrack. + SteamAutoCrack atualizado. + a alocar ficheiros + a verificar ficheiros existentes + Cancelar transferência + Já foram escritos {0} em: +{1} + +Sim - parar e eliminar esses ficheiros +Não - parar mas mantê-los +Cancelar - continuar a transferir + Não foi possível eliminar os ficheiros transferidos. Continuam em {0}. + Copiar App ID + Mostrar na pasta + Não foi possível copiar — outra aplicação está a usar a área de transferência. + Não foi possível abrir {0} — pode ter sido movido ou eliminado. + Sem chave de desencriptação no Lua para este depot. + A chave de desencriptação do depot {0} está errada. + Sem chave de desencriptação no Lua para o depot {0}. + Runtime partilhado — normalmente já instalado. Marque para transferir na mesma. + Remover todas as {0} entradas do histórico de transferências? Os ficheiros transferidos não são afetados. diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index b7d83e5..340b033 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -382,7 +382,6 @@ This deletes the .lua files from Steam\config\stplug-in. Could not resolve the base game for this DLC Something went wrong. Check your connection and try again. Download failed. Check your connection and try again. - Generation failed. Check your connection and try again. Replace "{0}"? No depot/DLC changes. Same content. Install cancelled. Existing files left unchanged. @@ -562,7 +561,6 @@ This deletes the .lua files from Steam\config\stplug-in. Downloads Downloads Nothing downloading right now. Start downloading some shit! - No finished downloads yet. Queue History Queued @@ -611,4 +609,33 @@ This deletes the .lua files from Steam\config\stplug-in. Couldn't get this depot's manifest. Preparing · {0} of {1} Getting the downloader + Tool + SteamAutoCrack + Checking the .NET runtime + Getting SteamAutoCrack + SteamAutoCrack opened. + Couldn't install the .NET runtime SteamAutoCrack needs. + The .NET runtime installed, but Windows needs a restart before SteamAutoCrack can run. + Couldn't download SteamAutoCrack. + Couldn't start SteamAutoCrack. + SteamAutoCrack updated. + allocating files + checking existing files + Cancel download + {0} has already been written to: +{1} + +Yes - stop and delete those files +No - stop but keep them +Cancel - keep downloading + Couldn't delete the downloaded files. They're still in {0}. + Copy App ID + Show in folder + Couldn't copy — another app is holding the clipboard. + Couldn't open {0} — it may have been moved or deleted. + No decryption key in Lua for this depot. + The decryption key for depot {0} is wrong. + No decryption key in Lua for depot {0}. + Shared runtime — usually already installed. Tick to download anyway. + Remove all {0} entries from the download history? Downloaded files are not affected. diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 5d0755c..5c8d6c8 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -345,7 +345,6 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Nu s-a putut determina jocul de bază pentru acest DLC Ceva nu a mers bine — verifică-ți conexiunea și încearcă din nou. Descărcare eșuată — verifică-ți conexiunea și încearcă din nou. - Generare eșuată — verifică-ți conexiunea și încearcă din nou. Înlocuiești „{0}”? Nicio modificare de depozit/DLC — același conținut. Instalare anulată — fișierele existente au rămas neschimbate. @@ -527,7 +526,6 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Descărcări Descărcări Nicio descărcare în curs. Hai, descarcă ceva! - Încă nu există descărcări finalizate. Coadă Istoric În coadă @@ -576,4 +574,33 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Nu s-a putut obține manifestul acestui depou. Se pregătește · {0} din {1} Se obține descărcătorul + Instrument + SteamAutoCrack + Se verifică runtime-ul .NET + Se obține SteamAutoCrack + SteamAutoCrack a fost deschis. + Nu s-a putut instala runtime-ul .NET necesar pentru SteamAutoCrack. + Runtime-ul .NET a fost instalat, dar Windows trebuie repornit înainte ca SteamAutoCrack să poată rula. + Nu s-a putut descărca SteamAutoCrack. + Nu s-a putut porni SteamAutoCrack. + SteamAutoCrack a fost actualizat. + se alocă fișierele + se verifică fișierele existente + Anulează descărcarea + S-au scris deja {0} în: +{1} + +Da - oprește și șterge acele fișiere +Nu - oprește dar păstrează-le +Anulare - continuă descărcarea + Fișierele descărcate nu au putut fi șterse. Sunt încă în {0}. + Copiază App ID + Afișează în folder + Nu s-a putut copia — altă aplicație folosește clipboardul. + Nu s-a putut deschide {0} — poate a fost mutat sau șters. + Nu există cheie de decriptare în Lua pentru acest depot. + Cheia de decriptare pentru depot {0} este greșită. + Nu există cheie de decriptare în Lua pentru depot {0}. + Runtime partajat — de obicei deja instalat. Bifează pentru a-l descărca oricum. + Elimini toate cele {0} intrări din istoricul descărcărilor? Fișierele descărcate nu sunt afectate. diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index 3858ebc..e43eb9c 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -345,7 +345,6 @@ Не удалось определить базовую игру для этого DLC Что-то пошло не так — проверьте подключение и попробуйте снова. Ошибка загрузки — проверьте подключение и попробуйте снова. - Ошибка генерации — проверьте подключение и попробуйте снова. Заменить «{0}»? Нет изменений депо/DLC — то же содержимое. Установка отменена — существующие файлы не изменены. @@ -527,7 +526,6 @@ Загрузки Загрузки Сейчас ничего не загружается. Скачайте уже что-нибудь! - Завершённых загрузок пока нет. Очередь История В очереди @@ -576,4 +574,33 @@ Не удалось получить manifest этого депо. Подготовка · {0} из {1} Загрузка загрузчика + Инструмент + SteamAutoCrack + Проверка среды .NET + Загрузка SteamAutoCrack + SteamAutoCrack открыт. + Не удалось установить среду .NET, необходимую SteamAutoCrack. + Среда .NET установлена, но перед запуском SteamAutoCrack нужно перезагрузить Windows. + Не удалось загрузить SteamAutoCrack. + Не удалось запустить SteamAutoCrack. + SteamAutoCrack обновлён. + выделение файлов + проверка имеющихся файлов + Отменить загрузку + Уже записано {0} в: +{1} + +Да - остановить и удалить эти файлы +Нет - остановить, но оставить +Отмена - продолжить загрузку + Не удалось удалить загруженные файлы. Они остались в {0}. + Копировать App ID + Показать в папке + Не удалось скопировать — буфер обмена занят другим приложением. + Не удалось открыть {0} — возможно, он перемещён или удалён. + В Lua нет ключа расшифровки для этого depot. + Ключ расшифровки для depot {0} неверен. + В Lua нет ключа расшифровки для depot {0}. + Общая среда выполнения — обычно уже установлена. Отметьте, чтобы всё равно скачать. + Удалить все записи ({0}) из истории загрузок? Загруженные файлы не затрагиваются. diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index 91ce3d8..4ea7e47 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -345,7 +345,6 @@ Detta raderar .lua-filerna från Steam\config\stplug-in. Det gick inte att fastställa grundspelet för denna DLC Något gick fel — kontrollera din anslutning och försök igen. Nedladdningen misslyckades — kontrollera din anslutning och försök igen. - Genereringen misslyckades — kontrollera din anslutning och försök igen. Ersätta "{0}"? Inga depå-/DLC-ändringar — samma innehåll. Installationen avbröts — befintliga filer är oförändrade. @@ -527,7 +526,6 @@ Detta raderar .lua-filerna från Steam\config\stplug-in. Nedladdningar Nedladdningar Inga nedladdningar just nu. Kom igen – ladda ner något! - Inga färdiga nedladdningar än. Kö Historik I kö @@ -576,4 +574,33 @@ Detta raderar .lua-filerna från Steam\config\stplug-in. Kunde inte hämta depåns manifest. Förbereder · {0} av {1} Hämtar nedladdaren + Verktyg + SteamAutoCrack + Kontrollerar .NET-körtiden + Hämtar SteamAutoCrack + SteamAutoCrack öppnades. + Kunde inte installera .NET-körtiden som SteamAutoCrack behöver. + .NET-körtiden installerades, men Windows måste startas om innan SteamAutoCrack kan köras. + Kunde inte hämta SteamAutoCrack. + Kunde inte starta SteamAutoCrack. + SteamAutoCrack uppdaterades. + allokerar filer + kontrollerar befintliga filer + Avbryt nedladdning + {0} har redan skrivits till: +{1} + +Ja - stoppa och radera filerna +Nej - stoppa men behåll dem +Avbryt - fortsätt hämta + Kunde inte radera de hämtade filerna. De finns kvar i {0}. + Kopiera App ID + Visa i mapp + Kunde inte kopiera — en annan app använder urklipp. + Kunde inte öppna {0} — den kan ha flyttats eller raderats. + Ingen dekrypteringsnyckel i Lua för den här depoten. + Dekrypteringsnyckeln för depot {0} är fel. + Ingen dekrypteringsnyckel i Lua för depot {0}. + Delad körtid — vanligtvis redan installerad. Kryssa i för att hämta ändå. + Ta bort alla {0} poster från hämtningshistoriken? Hämtade filer påverkas inte. diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index a2965aa..f45496a 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -345,7 +345,6 @@ ไม่สามารถระบุเกมหลักของ DLC นี้ได้ มีบางอย่างผิดพลาด — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง การดาวน์โหลดล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง - การสร้างล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง แทนที่ "{0}" หรือไม่? ไม่มีการเปลี่ยนแปลง depot/DLC — เนื้อหาเหมือนเดิม ยกเลิกการติดตั้งแล้ว — ไฟล์ที่มีอยู่ไม่เปลี่ยนแปลง @@ -527,7 +526,6 @@ การดาวน์โหลด การดาวน์โหลด ยังไม่มีการดาวน์โหลด มาเริ่มดาวน์โหลดอะไรสักอย่างกัน! - ยังไม่มีการดาวน์โหลดที่เสร็จสิ้น คิว ประวัติ อยู่ในคิว @@ -576,4 +574,33 @@ ไม่สามารถดึง manifest ของ depot นี้ได้ กำลังเตรียม · {0} จาก {1} กำลังรับตัวดาวน์โหลด + เครื่องมือ + SteamAutoCrack + กำลังตรวจสอบ .NET runtime + กำลังรับ SteamAutoCrack + เปิด SteamAutoCrack แล้ว + ติดตั้ง .NET runtime ที่ SteamAutoCrack ต้องใช้ไม่สำเร็จ + ติดตั้ง .NET runtime แล้ว แต่ต้องรีสตาร์ท Windows ก่อนจึงจะเรียกใช้ SteamAutoCrack ได้ + ดาวน์โหลด SteamAutoCrack ไม่สำเร็จ + เริ่ม SteamAutoCrack ไม่สำเร็จ + อัปเดต SteamAutoCrack แล้ว + กำลังจองพื้นที่ไฟล์ + กำลังตรวจสอบไฟล์ที่มีอยู่ + ยกเลิกการดาวน์โหลด + เขียนไปแล้ว {0} ที่: +{1} + +ใช่ - หยุดและลบไฟล์เหล่านั้น +ไม่ - หยุดแต่เก็บไฟล์ไว้ +ยกเลิก - ดาวน์โหลดต่อ + ลบไฟล์ที่ดาวน์โหลดไม่สำเร็จ ไฟล์ยังอยู่ที่ {0} + คัดลอก App ID + แสดงในโฟลเดอร์ + คัดลอกไม่ได้ — แอปอื่นกำลังใช้คลิปบอร์ดอยู่ + เปิด {0} ไม่ได้ — อาจถูกย้ายหรือลบไปแล้ว + ไม่มีคีย์ถอดรหัสใน Lua สำหรับ depot นี้ + คีย์ถอดรหัสสำหรับ depot {0} ไม่ถูกต้อง + ไม่มีคีย์ถอดรหัสใน Lua สำหรับ depot {0} + รันไทม์ที่ใช้ร่วมกัน — ปกติติดตั้งไว้แล้ว ติ๊กเพื่อดาวน์โหลดอยู่ดี + ต้องการลบรายการทั้งหมด {0} รายการออกจากประวัติการดาวน์โหลดหรือไม่ ไฟล์ที่ดาวน์โหลดแล้วจะไม่ได้รับผลกระทบ diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 829c967..bfeb407 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -345,7 +345,6 @@ Bu, .lua dosyalarını Steam\config\stplug-in konumundan siler. Bu DLC'nin ana oyunu belirlenemedi Bir şeyler ters gitti — bağlantınızı kontrol edip tekrar deneyin. İndirme başarısız — bağlantınızı kontrol edip tekrar deneyin. - Oluşturma başarısız — bağlantınızı kontrol edip tekrar deneyin. "{0}" değiştirilsin mi? Depo/DLC değişikliği yok — aynı içerik. Kurulum iptal edildi — mevcut dosyalar değiştirilmedi. @@ -527,7 +526,6 @@ Bu, .lua dosyalarını Steam\config\stplug-in konumundan siler. İndirmeler İndirmeler Şu anda indirme yok. Hadi, bir şeyler indir! - Henüz tamamlanmış indirme yok. Kuyruk Geçmiş Kuyrukta @@ -576,4 +574,33 @@ Bu, .lua dosyalarını Steam\config\stplug-in konumundan siler. Bu depotun manifest dosyası alınamadı. Hazırlanıyor · {0} / {1} İndirici alınıyor + Araç + SteamAutoCrack + .NET çalışma zamanı denetleniyor + SteamAutoCrack alınıyor + SteamAutoCrack açıldı. + SteamAutoCrack için gereken .NET çalışma zamanı kurulamadı. + .NET çalışma zamanı kuruldu, ancak SteamAutoCrack çalışmadan önce Windows'un yeniden başlatılması gerekiyor. + SteamAutoCrack indirilemedi. + SteamAutoCrack başlatılamadı. + SteamAutoCrack güncellendi. + dosyalar ayrılıyor + mevcut dosyalar denetleniyor + İndirmeyi iptal et + Şu konuma zaten {0} yazıldı: +{1} + +Evet - durdur ve bu dosyaları sil +Hayır - durdur ama dosyaları koru +İptal - indirmeye devam et + İndirilen dosyalar silinemedi. Hâlâ {0} konumundalar. + App ID'yi kopyala + Klasörde göster + Kopyalanamadı — panoyu başka bir uygulama kullanıyor. + {0} açılamadı — taşınmış veya silinmiş olabilir. + Lua'da bu depot için şifre çözme anahtarı yok. + {0} depot'u için şifre çözme anahtarı hatalı. + Lua'da {0} depot'u için şifre çözme anahtarı yok. + Paylaşılan çalışma zamanı — genelde zaten kurulu. Yine de indirmek için işaretleyin. + İndirme geçmişindeki {0} kaydın tümü kaldırılsın mı? İndirilen dosyalar etkilenmez. diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 36bac9a..8e9a528 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -345,7 +345,6 @@ Не вдалося визначити базову гру для цього DLC Щось пішло не так — перевірте з'єднання і спробуйте ще раз. Помилка завантаження — перевірте з'єднання і спробуйте ще раз. - Помилка генерування — перевірте з'єднання і спробуйте ще раз. Замінити «{0}»? Немає змін депо/DLC — той самий вміст. Встановлення скасовано — наявні файли не змінено. @@ -527,7 +526,6 @@ Завантаження Завантаження Зараз нічого не завантажується. Завантажте вже щось! - Завершених завантажень ще немає. Черга Історія У черзі @@ -576,4 +574,33 @@ Не вдалося отримати manifest цього депо. Підготовка · {0} з {1} Завантаження завантажувача + Інструмент + SteamAutoCrack + Перевірка середовища .NET + Завантаження SteamAutoCrack + SteamAutoCrack відкрито. + Не вдалося встановити середовище .NET, потрібне для SteamAutoCrack. + Середовище .NET встановлено, але перед запуском SteamAutoCrack потрібно перезавантажити Windows. + Не вдалося завантажити SteamAutoCrack. + Не вдалося запустити SteamAutoCrack. + SteamAutoCrack оновлено. + виділення файлів + перевірка наявних файлів + Скасувати завантаження + Уже записано {0} у: +{1} + +Так - зупинити й вилучити ці файли +Ні - зупинити, але залишити +Скасувати - продовжити завантаження + Не вдалося вилучити завантажені файли. Вони залишилися в {0}. + Копіювати App ID + Показати в теці + Не вдалося скопіювати — буфер обміну зайнятий іншим застосунком. + Не вдалося відкрити {0} — можливо, його переміщено або вилучено. + У Lua немає ключа розшифрування для цього depot. + Ключ розшифрування для depot {0} неправильний. + У Lua немає ключа розшифрування для depot {0}. + Спільне середовище виконання — зазвичай уже встановлене. Позначте, щоб усе одно завантажити. + Вилучити всі записи ({0}) з історії завантажень? Завантажені файли не зачіпаються. diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index 86b8951..e8ce5f5 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -345,7 +345,6 @@ Thao tác này xóa các tệp .lua khỏi Steam\config\stplug-in.Không thể xác định trò chơi gốc cho DLC này Đã xảy ra lỗi — kiểm tra kết nối và thử lại. Tải xuống thất bại — kiểm tra kết nối và thử lại. - Tạo thất bại — kiểm tra kết nối và thử lại. Thay thế "{0}"? Không có thay đổi depot/DLC — cùng nội dung. Đã hủy cài đặt — các tệp hiện có không thay đổi. @@ -527,7 +526,6 @@ Thao tác này xóa các tệp .lua khỏi Steam\config\stplug-in.Tải xuống Tải xuống Hiện không có gì đang tải. Tải cái gì đó đi nào! - Chưa có lượt tải nào hoàn tất. Hàng đợi Lịch sử Đang chờ @@ -576,4 +574,33 @@ Thao tác này xóa các tệp .lua khỏi Steam\config\stplug-in.Không lấy được manifest của depot này. Đang chuẩn bị · {0} trên {1} Đang lấy trình tải + Công cụ + SteamAutoCrack + Đang kiểm tra .NET runtime + Đang lấy SteamAutoCrack + Đã mở SteamAutoCrack. + Không thể cài .NET runtime mà SteamAutoCrack cần. + Đã cài .NET runtime, nhưng cần khởi động lại Windows trước khi chạy SteamAutoCrack. + Không thể tải SteamAutoCrack. + Không thể khởi động SteamAutoCrack. + Đã cập nhật SteamAutoCrack. + đang cấp phát tệp + đang kiểm tra tệp hiện có + Hủy tải xuống + Đã ghi {0} vào: +{1} + +Có - dừng và xóa các tệp đó +Không - dừng nhưng giữ lại +Hủy - tiếp tục tải + Không thể xóa các tệp đã tải. Chúng vẫn ở {0}. + Sao chép App ID + Hiện trong thư mục + Không thể sao chép — ứng dụng khác đang giữ khay nhớ tạm. + Không thể mở {0} — có thể đã bị di chuyển hoặc xóa. + Không có khóa giải mã trong Lua cho depot này. + Khóa giải mã cho depot {0} không đúng. + Không có khóa giải mã trong Lua cho depot {0}. + Runtime dùng chung — thường đã được cài. Đánh dấu để vẫn tải xuống. + Xóa tất cả {0} mục khỏi lịch sử tải xuống? Các tệp đã tải không bị ảnh hưởng. diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index 3da962c..39890c0 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -363,7 +363,6 @@ 无法确定此 DLC 的本体游戏 出了点问题——请检查你的网络连接后重试。 下载失败——请检查你的网络连接后重试。 - 生成失败——请检查你的网络连接后重试。 替换"{0}"? 仓库/DLC 无变化——内容相同。 安装已取消——现有文件保持不变。 @@ -547,7 +546,6 @@ 下载 下载 当前没有下载。快去下载点什么吧! - 还没有已完成的下载。 队列 历史记录 排队中 @@ -596,4 +594,33 @@ 无法获取该仓库的 manifest。 准备中 · {0} / {1} 正在获取下载器 + 工具 + SteamAutoCrack + 正在检查 .NET 运行时 + 正在获取 SteamAutoCrack + 已打开 SteamAutoCrack。 + 无法安装 SteamAutoCrack 所需的 .NET 运行时。 + .NET 运行时已安装,但需要重启 Windows 后 SteamAutoCrack 才能运行。 + 无法下载 SteamAutoCrack。 + 无法启动 SteamAutoCrack。 + SteamAutoCrack 已更新。 + 正在分配文件 + 正在校验已有文件 + 取消下载 + 已写入 {0} 到: +{1} + +是 - 停止并删除这些文件 +否 - 停止但保留文件 +取消 - 继续下载 + 无法删除已下载的文件。它们仍在 {0}。 + 复制 App ID + 在文件夹中显示 + 无法复制 — 另一个应用正占用剪贴板。 + 无法打开 {0} — 可能已被移动或删除。 + Lua 中没有此 depot 的解密密钥。 + depot {0} 的解密密钥不正确。 + Lua 中没有 depot {0} 的解密密钥。 + 共享运行库 — 通常已安装。如仍需下载请勾选。 + 要从下载历史中移除全部 {0} 条记录吗?已下载的文件不会受影响。 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 5adf7aa..0d70526 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -345,7 +345,6 @@ 無法確定此 DLC 的本體遊戲 發生錯誤——請檢查你的連線後重試。 下載失敗——請檢查你的連線後重試。 - 產生失敗——請檢查你的連線後重試。 取代「{0}」? 倉庫/DLC 無變更——內容相同。 安裝已取消——現有檔案保持不變。 @@ -527,7 +526,6 @@ 下載 下載 目前沒有下載。快去下載點什麼吧! - 尚無已完成的下載。 佇列 歷史紀錄 排隊中 @@ -576,4 +574,33 @@ 無法取得這個倉庫的 manifest。 準備中 · {0} / {1} 正在取得下載器 + 工具 + SteamAutoCrack + 正在檢查 .NET 執行階段 + 正在取得 SteamAutoCrack + 已開啟 SteamAutoCrack。 + 無法安裝 SteamAutoCrack 所需的 .NET 執行階段。 + .NET 執行階段已安裝,但需要重新啟動 Windows 後 SteamAutoCrack 才能執行。 + 無法下載 SteamAutoCrack。 + 無法啟動 SteamAutoCrack。 + SteamAutoCrack 已更新。 + 正在配置檔案 + 正在驗證既有檔案 + 取消下載 + 已寫入 {0} 到: +{1} + +是 - 停止並刪除這些檔案 +否 - 停止但保留檔案 +取消 - 繼續下載 + 無法刪除已下載的檔案。它們仍在 {0}。 + 複製 App ID + 在資料夾中顯示 + 無法複製 — 另一個應用程式正佔用剪貼簿。 + 無法開啟 {0} — 可能已被移動或刪除。 + Lua 中沒有此 depot 的解密金鑰。 + depot {0} 的解密金鑰不正確。 + Lua 中沒有 depot {0} 的解密金鑰。 + 共用執行階段 — 通常已安裝。如仍需下載請勾選。 + 要從下載紀錄中移除全部 {0} 筆記錄嗎?已下載的檔案不會受影響。 diff --git a/src/LuaToolsGui/Services/CacheService.cs b/src/LuaToolsGui/Services/CacheService.cs index 0607038..0b81128 100644 --- a/src/LuaToolsGui/Services/CacheService.cs +++ b/src/LuaToolsGui/Services/CacheService.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Text.Json; namespace LuaToolsGui.Services; @@ -40,7 +40,7 @@ public class CacheData // holds delegates and can't be serialized, so nothing here is resumable. public List DownloadHistory { get; set; } = []; - // ── Downloaded tool fingerprints (DepotDownloader, Steamless) ───── + // ── Downloaded tool fingerprints (DepotDownloader, Steamless, SteamAutoCrack) ── // The release tag last installed, plus when we last asked GitHub. Without these the tools were // fetched once by a bare File.Exists check and then pinned forever, which matters now that the // DepotDownloader re-pack republishes on every upstream release. CheckedAtMs is Unix-ms; 0 = never. @@ -48,6 +48,8 @@ public class CacheData public long DepotDownloaderCheckedAtMs { get; set; } public string? SteamlessVersion { get; set; } public long SteamlessCheckedAtMs { get; set; } + public string? SteamAutoCrackVersion { get; set; } + public long SteamAutoCrackCheckedAtMs { get; set; } } /// @@ -185,6 +187,20 @@ public long SteamlessCheckedAtMs set { _cache.SteamlessCheckedAtMs = value; Save(); } } + /// Release tag of the installed SteamAutoCrack, or null if never recorded. + public string? SteamAutoCrackVersion + { + get => _cache.SteamAutoCrackVersion; + set { _cache.SteamAutoCrackVersion = string.IsNullOrWhiteSpace(value) ? null : value; Save(); } + } + + /// Unix-ms of the last successful SteamAutoCrack release check; 0 = never. + public long SteamAutoCrackCheckedAtMs + { + get => _cache.SteamAutoCrackCheckedAtMs; + set { _cache.SteamAutoCrackCheckedAtMs = value; Save(); } + } + /// Clear the loaded-apps notification list (ReadLoadedApps → DismissLoadedApps). public void ClearLoadedAppIds() { @@ -240,7 +256,9 @@ private void SaveLocked() && _cache.DepotDownloaderVersion is null && _cache.DepotDownloaderCheckedAtMs == 0 && _cache.SteamlessVersion is null - && _cache.SteamlessCheckedAtMs == 0; + && _cache.SteamlessCheckedAtMs == 0 + && _cache.SteamAutoCrackVersion is null + && _cache.SteamAutoCrackCheckedAtMs == 0; if (empty) { try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { /* best effort */ } diff --git a/src/LuaToolsGui/Services/DepotDownloaderService.cs b/src/LuaToolsGui/Services/DepotDownloaderService.cs index ae5c3e4..4995b3a 100644 --- a/src/LuaToolsGui/Services/DepotDownloaderService.cs +++ b/src/LuaToolsGui/Services/DepotDownloaderService.cs @@ -56,6 +56,26 @@ public record DepotRunResult(bool Ok, string? Error); /// SteamKit-derived LoginID and disconnect each other (-loginid is ignored on the anonymous path), /// and parallel multi-GB transfers only split the same bandwidth. /// +/// +/// What the downloader is doing right now, parsed from its stdout. +/// +/// +/// A big depot spends a long stretch before a single byte arrives: the tool pre-allocates every new file +/// at full size first, and a resumed one re-hashes what is already on disk. Without this the row just +/// reads "Downloading, 0 B of 4.49 GB" and looks hung. +/// +public enum DepotPhase +{ + /// Fetching the depot manifest. + Manifest, + /// Creating zero-filled files at their final size. No bytes are being fetched yet. + PreAllocating, + /// Re-hashing files that already exist (a resume with -validate). + Validating, + /// Actually pulling chunks. + Downloading, +} + public partial class DepotDownloaderService( GithubProxy gh, SteamService steam, @@ -102,6 +122,26 @@ public partial class DepotDownloaderService( private readonly SemaphoreSlim _toolGate = new(1, 1); private readonly SemaphoreSlim _runGate = new(1, 1); + /// + /// Map one stdout line to a phase, or null when it says nothing about phase. + /// + /// + /// These are printed PER FILE, so a large depot emits thousands. The caller reports only on change. + /// Order matters: "Downloading depot N manifest" has to be tested before the bare + /// "Downloading depot N", or every manifest fetch would read as the download starting. + /// + private const string PreAllocatingPrefix = "Pre-allocating "; + + private static DepotPhase? PhaseOf(string line) => line switch + { + _ when line.StartsWith(PreAllocatingPrefix, StringComparison.Ordinal) => DepotPhase.PreAllocating, + _ when line.StartsWith("Validating ", StringComparison.Ordinal) => DepotPhase.Validating, + _ when line.StartsWith("Downloading depot ", StringComparison.Ordinal) + && line.EndsWith(" manifest", StringComparison.Ordinal) => DepotPhase.Manifest, + _ when line.StartsWith("Downloading depot ", StringComparison.Ordinal) => DepotPhase.Downloading, + _ => null, + }; + /// Matches the tool's per-file progress line, e.g. " 42.17% game/data.pak". [GeneratedRegex(@"^\s*([0-9]+(?:\.[0-9]+)?)%\s+(.+)$")] private static partial Regex ProgressRegex(); @@ -267,9 +307,45 @@ public IReadOnlyDictionary ResolveKeys(long appId) /// public string? ResolveManifestPath(long depotId, string manifestId) { - if (steam.DepotCacheDir is not { } dir) return null; - string path = Path.Combine(dir, $"{depotId}_{manifestId}.manifest"); - return File.Exists(path) ? path : null; + if (CachedManifestPath(depotId, manifestId) is not { } path) return null; + + // Existence is not enough. A half-written or truncated file (a killed download, a full disk) + // stays on disk under the right name and would be handed to the downloader, which fails on it + // with a raw parse error. Requiring the file to parse AND to declare the depot and gid its name + // claims turns that into a clean cache miss, which the fetch path then repairs. + return ManifestFile.Matches(path, depotId, manifestId) ? path : null; + } + + /// The name a depot's manifest has in depotcache, whether or not it is there. + private string? CachedManifestPath(long depotId, string manifestId) => + steam.DepotCacheDir is { } dir + ? Path.Combine(dir, $"{depotId}_{manifestId}.manifest") + : null; + + /// + /// Delete a cached manifest that failed validation, so a re-fetch can actually replace it. + /// + /// + /// Mandatory before re-fetching, not a tidy-up: LuaInstaller.InstallManifestFile SKIPS a + /// destination that already exists (deliberately, since the name is content-addressed and Steam may + /// hold the file open). So a corrupt entry would survive the fetch, be handed back, and fail again + /// on every attempt — the download could never recover on its own. + /// + public bool DiscardCachedManifest(long depotId, string manifestId) + { + if (CachedManifestPath(depotId, manifestId) is not { } path) return false; + try + { + if (!File.Exists(path)) return false; + File.Delete(path); + log.LogDebug("Discarded unreadable cached manifest {Path}", path); + return true; + } + catch (Exception ex) + { + log.LogDebug(ex, "Could not discard cached manifest {Path}", path); + return false; // Steam holding it open; the fetch below will fail loudly rather than silently + } } /// Free bytes on the volume that will hold , or null if unknown. @@ -318,7 +394,8 @@ public static string WriteKeysFile(IReadOnlyDictionary keys) /// public async Task RunAsync( long appId, DepotSelection sel, string keysFile, string outDir, bool validate, - IProgress? depotFraction, CancellationToken ct) + IProgress? depotFraction, CancellationToken ct, IProgress? phase = null, + IProgress? createdFile = null) { string? exe = await EnsureToolAsync(null, ct); if (exe is null) return new DepotRunResult(false, "tool"); @@ -365,6 +442,7 @@ public async Task RunAsync( string? lastError = null; string? lastLine = null; + DepotPhase? lastPhase = null; proc.OutputDataReceived += (_, e) => { if (e.Data is null) return; @@ -374,10 +452,35 @@ public async Task RunAsync( if (m.Success && double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double pct)) { + // A progress line IS the download phase; chunks are moving by definition. + if (lastPhase != DepotPhase.Downloading) + { + lastPhase = DepotPhase.Downloading; + phase?.Report(DepotPhase.Downloading); + } depotFraction?.Report(Math.Clamp(pct / 100d, 0d, 1d)); return; } + string trimmed = e.Data.Trim(); + + // "Pre-allocating X" is printed ONLY when X did not already exist, so it is exactly the + // set of files this run created — which is what a cancel is allowed to delete. Anything + // the user already had is reported as "Validating" instead and is never recorded. + if (createdFile is not null + && trimmed.StartsWith(PreAllocatingPrefix, StringComparison.Ordinal)) + { + string path = trimmed[PreAllocatingPrefix.Length..].Trim(); + if (path.Length > 0) createdFile.Report(path); + } + + // Only on CHANGE: these are per-file, so a big depot would otherwise emit thousands. + if (PhaseOf(trimmed) is { } p && p != lastPhase) + { + lastPhase = p; + phase?.Report(p); + } + // The tool writes fatal errors to STDOUT via Console.WriteLine and leaves stderr empty // ("There is not enough space on the disk", "No valid depot key for N", ...). Keeping the // last non-progress line is what turns a useless "exit 1" into the actual reason. @@ -430,6 +533,103 @@ public async Task RunAsync( finally { _runGate.Release(); } } + /// + /// The folder DepotDownloader creates inside its output directory. Its presence is proof the + /// downloader actually ran there, which is what makes deleting the folder safe to offer. + /// + private const string DownloaderMarkerDir = ".DepotDownloader"; + + /// True when the downloader has actually written to this folder. + public static bool HasDownloadedContent(string? dir) => + !string.IsNullOrWhiteSpace(dir) + && Directory.Exists(Path.Combine(dir, DownloaderMarkerDir)); + + /// + /// Delete exactly the files this download created, then tidy up after them. Returns false only if + /// something was left behind. + /// + /// + /// Deliberately not a recursive delete of the output folder. That folder is chosen by + /// the user through the Change picker, so it can legitimately be an existing game directory being + /// repaired — wiping it would destroy an install we never created. Only paths the downloader + /// reported Pre-allocating are removed, and it prints that line only for files that did not + /// already exist. + /// + /// Every path is checked to be inside before deletion, so a malformed + /// or hostile line in the child's stdout cannot reach outside the download folder. + /// + /// Call only once the child process has exited. The kill is asynchronous and the downloader + /// holds handles on what it pre-allocated, so deleting too early just fails. + /// + public static bool TryDeleteCreatedFiles(string? dir, IReadOnlyCollection createdFiles) + { + if (string.IsNullOrWhiteSpace(dir)) return false; + + string root; + try { root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)); } + catch { return false; } + + bool allGone = true; + var touched = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string f in createdFiles) + { + try + { + // Resolved against the output folder, not our own working directory (which is ToolDir): + // absolute paths are unaffected, and a relative one lands where the downloader meant it. + string full = Path.GetFullPath(f, root); + // Containment check: never delete outside the folder we were given. + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + continue; + if (File.Exists(full)) File.Delete(full); + if (Path.GetDirectoryName(full) is { Length: > 0 } parent) touched.Add(parent); + } + catch { allGone = false; } // locked or vanished; the folder just keeps it + } + + // The downloader's own bookkeeping is ours by definition, so it goes too. + try + { + string marker = Path.Combine(root, DownloaderMarkerDir); + if (Directory.Exists(marker)) Directory.Delete(marker, recursive: true); + } + catch { allGone = false; } + + PruneEmptyDirectories(root, touched); + return allGone; + } + + /// + /// Remove directories left empty by the deletion, deepest first, stopping at (and keeping) the root. + /// + /// + /// Walks up from the folders we actually emptied rather than scanning the whole tree. The output + /// directory is user-chosen and can be an existing game folder, so sweeping every empty directory + /// under it would delete ones that were there before this download and are none of our business. + /// + private static void PruneEmptyDirectories(string root, HashSet touched) + { + foreach (string start in touched.OrderByDescending(d => d.Length)) + { + string? dir = start; + // Up the chain: emptying a leaf can leave its parent empty too, but never remove the root. + while (dir is not null + && dir.Length > root.Length + && dir.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + try + { + if (!Directory.Exists(dir)) { dir = Path.GetDirectoryName(dir); continue; } + if (Directory.EnumerateFileSystemEntries(dir).Any()) break; // still holds something + Directory.Delete(dir); + } + catch { break; } // in use or denied: stop climbing this branch + dir = Path.GetDirectoryName(dir); + } + } + } + private static void TryKill(Process proc) { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { /* already gone */ } diff --git a/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs b/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs index 48326d5..a05a9a6 100644 --- a/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs +++ b/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace LuaToolsGui.Services.Downloads; @@ -16,7 +16,17 @@ public sealed record DownloadHistoryRecord( long Bytes, string Status, string? Message, - long CompletedAtMs); + long CompletedAtMs, + + /// + /// What "Show in folder" opens for this entry: the installed file, or the depot output folder. + /// + /// + /// Trailing and nullable so a downloads.json written before this field existed still deserializes — + /// the property is simply absent and lands as null, which reads as "nothing to show" and hides the + /// menu item rather than offering a dead path. + /// + string? RevealPath = null); /// A finished download as shown in the Downloads tab's history list. public partial class DownloadHistoryEntry : ObservableObject @@ -47,6 +57,11 @@ public DownloadHistoryEntry(DownloadHistoryRecord record) _ => Resources.Strings.Downloads_Status_Cancelled, }; + /// Tool downloads carry appid 0; see . + public bool CanCopyAppId => Record.AppId > 0; + + public bool CanShowInFolder => !string.IsNullOrWhiteSpace(Record.RevealPath); + public string WhenLabel => DateTimeOffset.FromUnixTimeMilliseconds(Record.CompletedAtMs).LocalDateTime.ToString("g"); @@ -59,5 +74,6 @@ public DownloadHistoryEntry(DownloadHistoryRecord record) item.BytesRead, status.ToString(), item.Message, - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + item.RevealPath); } diff --git a/src/LuaToolsGui/Services/Downloads/DownloadItem.cs b/src/LuaToolsGui/Services/Downloads/DownloadItem.cs index c6b4981..f4bb1d8 100644 --- a/src/LuaToolsGui/Services/Downloads/DownloadItem.cs +++ b/src/LuaToolsGui/Services/Downloads/DownloadItem.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace LuaToolsGui.Services.Downloads; @@ -34,7 +34,22 @@ public partial class DownloadItem : ObservableObject // Sliding-window rate samples: (timestamp ticks, bytes read). Small ring, oldest trimmed by age. private readonly Queue<(long Ticks, long Bytes)> _samples = new(); private static readonly TimeSpan RateWindow = TimeSpan.FromSeconds(3); - private const int MaxSamples = 20; + + /// + /// Shortest interval the rate will be divided by. Depot progress arrives once per COMPLETED FILE, so + /// several concurrent files landing together produce samples milliseconds apart; dividing a burst by + /// ~10ms is what made the speed read in GB/s. + /// + private static readonly TimeSpan MinSpan = TimeSpan.FromSeconds(0.5); + + /// + /// Cap on retained samples. Sized to outlast at the queue's 100ms progress + /// throttle (~30 samples); at the old 20 the count cap silently shortened the window to about 2s. + /// + private const int MaxSamples = 64; + + /// Injectable clock, so the rate window can be tested without waiting minutes in real time. + internal Func UtcNow { get; init; } = () => DateTime.UtcNow; public DownloadItem(DownloadJob job) { @@ -129,6 +144,51 @@ internal void ResetCts() /// internal HashSet CompletedDepots { get; } = []; + /// + /// Files the downloader reported creating (its "Pre-allocating" lines), so a cancel can delete + /// exactly those and nothing else. + /// + /// + /// Accumulates across pause/resume because the item outlives both — a resumed run only + /// pre-allocates what is still missing, so the earlier run's paths would otherwise be forgotten + /// and left on disk. Not persisted: after an app restart the list is empty and cancel simply has + /// less to clean, which is the safe direction to fail in. + /// + internal HashSet CreatedFiles { get; } = new(StringComparer.OrdinalIgnoreCase); + + private string? _installedPath; + + /// + /// What "Show in folder" opens: the file this job installed, or the folder it downloaded into. + /// Null when the job produces neither (a tool download). + /// + /// + /// The fallback is deliberate — a depot job knows its folder + /// from the moment it is created, so the action works while the download is still running, not only + /// once it finishes. + /// + public string? RevealPath => _installedPath ?? Job.OutputPath; + + /// Tool jobs carry appid 0, and "Copy App ID: 0" is not worth offering. + public bool CanCopyAppId => AppId > 0; + + public bool CanShowInFolder => !string.IsNullOrWhiteSpace(RevealPath); + + /// + /// Record where the install landed, from the job's . + /// + /// + /// Must be called BEFORE the history entry is built: DownloadHistoryEntry.From reads this off + /// the item, and DownloadQueue.Finish inserts the history row before it settles the result. + /// + internal void RecordInstalledPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; // keep the OutputPath fallback rather than blanking it + _installedPath = path; + OnPropertyChanged(nameof(RevealPath)); + OnPropertyChanged(nameof(CanShowInFolder)); + } + public bool IsIndeterminate => TotalBytes is not > 0; public double Percent => TotalBytes is > 0 ? BytesRead * 100d / TotalBytes.Value : 0; @@ -206,17 +266,31 @@ internal void ApplySample(long bytesRead, long? total) BytesRead = bytesRead; TotalBytes = total; - long now = DateTime.UtcNow.Ticks; + long now = UtcNow().Ticks; _samples.Enqueue((now, bytesRead)); - while (_samples.Count > MaxSamples || - (_samples.Count > 1 && now - _samples.Peek().Ticks > RateWindow.Ticks)) + + while (_samples.Count > MaxSamples) _samples.Dequeue(); + + // Drop stale samples, but keep at least TWO and keep the measured span meaningful. + // + // Both floors exist because depot progress is reported once per completed file, not per chunk: + // * without the two-sample floor, any gap longer than the window trimmed everything but the + // newest reading, hit the "< 2" return below, and left the speed frozen at a stale value + // forever — ATS has a single 8.3 GB file that reports nothing for ~166s; + // * without the span floor, several concurrent files completing together left two samples + // milliseconds apart, and dividing by that read as multiple GB/s. + while (_samples.Count > 2 + && now - _samples.Peek().Ticks > RateWindow.Ticks + && now - _samples.ElementAt(1).Ticks >= MinSpan.Ticks) _samples.Dequeue(); - if (_samples.Count < 2) return; + if (_samples.Count < 2) return; // the very first reading: nothing to measure against yet var (oldTicks, oldBytes) = _samples.Peek(); double seconds = (now - oldTicks) / (double)TimeSpan.TicksPerSecond; - if (seconds <= 0) return; + + // Too soon to say anything honest. Keep the previous figure rather than publish a spike. + if (seconds < MinSpan.TotalSeconds) return; double rate = (bytesRead - oldBytes) / seconds; BytesPerSecond = rate; diff --git a/src/LuaToolsGui/Services/Downloads/DownloadJob.cs b/src/LuaToolsGui/Services/Downloads/DownloadJob.cs index 45d2103..261afeb 100644 --- a/src/LuaToolsGui/Services/Downloads/DownloadJob.cs +++ b/src/LuaToolsGui/Services/Downloads/DownloadJob.cs @@ -1,7 +1,7 @@ -namespace LuaToolsGui.Services.Downloads; +namespace LuaToolsGui.Services.Downloads; /// What a queued download is for. Drives the row icon and the history label. -public enum DownloadKind { Manifest, Dlc, DenuvoManifest, DenuvoFix, Depot } +public enum DownloadKind { Manifest, Dlc, DenuvoManifest, DenuvoFix, Depot, Tool } /// /// A job refused to start for a reason the user can act on — e.g. the game a Denuvo fix targets isn't @@ -12,7 +12,19 @@ public enum DownloadKind { Manifest, Dlc, DenuvoManifest, DenuvoFix, Depot } /// nothing was spent (no bandwidth, and no slot of the server-side daily limit). Reusing ApiException /// would surface the message correctly but would name the failure after a call that never happened. /// -public sealed class DownloadAbortedException(string message) : Exception(message); +public sealed class DownloadAbortedException(string message, bool isCancellation = false) : Exception(message) +{ + /// + /// Settle the item as Cancelled rather than Failed, keeping this exception's message. + /// + /// + /// For outcomes that stop the job without anything having gone wrong — the user declining an + /// elevation prompt, or a runtime that installed fine but wants a reboot. Those are not errors and + /// should not be dressed as red failures. Throwing would + /// give the right status but discard the specific message. + /// + public bool IsCancellation { get; } = isCancellation; +} /// Outcome of a job's install phase. /// User-facing result text, already localized by the factory. @@ -74,4 +86,14 @@ public sealed record DownloadJob( Action? OnFinished = null, /// Target of the Downloads tab's "Review" / "Reveal" button (e.g. navigate to Add). - Action? OnReveal = null); + Action? OnReveal = null, + + /// + /// Where this job writes its output, for jobs that produce a folder rather than a staged file + /// (depot downloads). Null for everything else. + /// + /// + /// Exists so a cancel can offer to delete what was written. The path is otherwise captured only + /// inside the job's own closure, leaving nothing outside able to name it. + /// + string? OutputPath = null); diff --git a/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs b/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs index ce13fbd..6bf8210 100644 --- a/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs +++ b/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.ComponentModel; using System.IO; using System.Windows; @@ -149,10 +149,21 @@ public DownloadItem Enqueue(DownloadJob job) public void Cancel(DownloadItem item) => Dispatcher.Invoke(() => { if (!item.IsActive) return; - // A queued item never entered RunItemAsync, so nothing will observe the token. Finish it here. - bool wasQueued = item.Status == DownloadStatus.Queued; + + // Nothing is running for a Queued item (it never entered RunItemAsync) OR for a Paused one + // (Pause cancelled the token and RunItemAsync already returned at its PauseRequested check), so + // in both cases no one is left to observe the token and settle the item — this has to do it. + // + // Paused used to be missed here, which left the row stuck: Cancel appeared to do nothing, and + // because Paused counts as active it offered no Remove either, so Resume was the only way out. + bool nothingRunning = item.Status is DownloadStatus.Queued or DownloadStatus.Paused; + try { item.Cts.Cancel(); } catch { } - if (wasQueued) Finish(item, DownloadStatus.Cancelled, Resources.Strings.Err_CancelledByUser, null); + if (nothingRunning) + { + item.PauseRequested = false; // settled, not parked: don't let a later path read it as a pause + Finish(item, DownloadStatus.Cancelled, Resources.Strings.Err_CancelledByUser, null); + } Kick(); }); @@ -197,6 +208,12 @@ public DownloadItem Retry(DownloadItem item) if (item.Job.Kind is DownloadKind.Depot && !ReferenceEquals(fresh, item)) { foreach (long id in item.CompletedDepots) fresh.CompletedDepots.Add(id); + + // Same reasoning for the files themselves: they are on disk under the SAME output folder, so + // without this a cancel of the retry would offer to delete only what the retry re-created and + // orphan the rest — and with nothing recorded yet it suppresses the prompt entirely. + foreach (string f in item.CreatedFiles) fresh.CreatedFiles.Add(f); + fresh.NeedsValidate = true; } return fresh; @@ -229,6 +246,12 @@ public void ClearHistory() => Dispatcher.Invoke(() => PersistHistory(); }); + /// Drop one finished download from the history. Nothing on disk is touched. + public void RemoveHistory(DownloadHistoryEntry entry) => Dispatcher.Invoke(() => + { + if (History.Remove(entry)) PersistHistory(); + }); + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(DownloadItem.Status)) StateChanged?.Invoke(); @@ -361,7 +384,14 @@ await Dispatcher.InvokeAsync(() => string message = ex is ApiException or DownloadAbortedException ? ex.Message : Resources.Strings.Add_Err_Download; - await Dispatcher.InvokeAsync(() => Finish(item, DownloadStatus.Failed, message, null)); + + // Some aborts are not failures — a declined elevation prompt, or a runtime that installed but + // wants a reboot. Those settle as Cancelled so they don't read as something having broken. + var status = ex is DownloadAbortedException { IsCancellation: true } + ? DownloadStatus.Cancelled + : DownloadStatus.Failed; + + await Dispatcher.InvokeAsync(() => Finish(item, status, message, null)); } finally { @@ -377,6 +407,10 @@ private void Finish(DownloadItem item, DownloadStatus status, string? message, J item.Message = message; item.Status = status; + // Before the history row is built: From() copies the reveal path off the item, and settling the + // result (which carries it) happens further down. + item.RecordInstalledPath(result?.InstalledPath); + History.Insert(0, new DownloadHistoryEntry(DownloadHistoryEntry.From(item, status))); while (History.Count > 100) History.RemoveAt(History.Count - 1); PersistHistory(); diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index 9d3987f..df1ca2d 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -23,7 +23,8 @@ public class ManifestJobFactory( CoverCache covers, ToastService toast, DepotDownloaderService depotTool, - SteamDepotInfo depotInfo) + SteamDepotInfo depotInfo, + SteamAutoCrackService sac) { // ── Job builders ───────────────────────────────────────────────── @@ -133,6 +134,72 @@ public DownloadJob CreateDepotJob( (_, _, _) => Task.FromResult(new JobResult(true, string.Format(Resources.Strings.Depot_Status_Done, selections.Count, outDir), outDir)), ConfirmAsync: null, + OnFinished: onFinished, + OutputPath: outDir); + } + + /// + /// Fetch SteamAutoCrack (installing the .NET runtime it needs first) and open it. + /// + /// + /// Modelled as a queue job so the ~100 MB first run shows real progress and can be cancelled, rather + /// than freezing a button. It only OPENS their GUI: the shipped release has no CLI and the GUI takes + /// no arguments, so nothing about the actual crack can be driven from here. + /// + /// + /// False for the background-update path. Finishing an update must NOT open a second SteamAutoCrack + /// window while the user already has one open. + /// + public DownloadJob CreateSteamAutoCrackJob( + bool launchWhenDone = true, Action? onFinished = null) + { + return new DownloadJob( + DownloadKind.Tool, + "tool:steamautocrack", + 0, + "SteamAutoCrack", // a product name; deliberately not localized + Resources.Strings.Downloads_Kind_Tool, + null, + async (item, progress, ct) => + { + // Runtime BEFORE the 41 MB tool: no point paying for the download if the user declines + // the elevation prompt. + OnUi(() => item.Detail = Resources.Strings.Downloads_SAC_GettingRuntime); + var runtimeProgress = new ProgressRelay(f => + { + if (f is { } v) progress.Report(new DownloadProgress((long)(v * 1000), 1000)); + }); + var prepared = await sac.EnsureRuntimeAsync(runtimeProgress, ct); + if (prepared != SacPrepareResult.Ready) + { + // Declining the prompt, and "installed but needs a reboot", are both outcomes where + // nothing went wrong — they settle as Cancelled so the row isn't dressed as an error. + bool notAFailure = prepared is SacPrepareResult.RuntimeDeclined + or SacPrepareResult.RuntimeNeedsRestart; + throw new DownloadAbortedException(prepared switch + { + SacPrepareResult.RuntimeDeclined => Resources.Strings.Err_CancelledByUser, + SacPrepareResult.RuntimeNeedsRestart => Resources.Strings.Downloads_SAC_Err_Restart, + _ => Resources.Strings.Downloads_SAC_Err_Runtime, + }, isCancellation: notAFailure); + } + + OnUi(() => item.Detail = Resources.Strings.Downloads_SAC_GettingTool); + progress.Report(new DownloadProgress(0, null)); // hand the bar back before the real download + // force when this job was queued by the background update probe: that probe already + // recorded the check timestamp, so the throttle would otherwise skip this download. + string? exe = await sac.EnsureToolAsync(progress, force: !launchWhenDone, ct) + ?? throw new DownloadAbortedException(Resources.Strings.Downloads_SAC_Err_Tool); + + OnUi(() => item.Detail = null); + // Directory sentinel, same as CreateDepotJob: the queue's staged-file cleanup no-ops on it. + return new DownloadedFile(Path.GetDirectoryName(exe)!, "SteamAutoCrack"); + }, + (_, _, _) => Task.FromResult( + !launchWhenDone ? new JobResult(true, Resources.Strings.Downloads_SAC_Updated) + : sac.Launch() ? new JobResult(true, Resources.Strings.Downloads_SAC_Launched) + : new JobResult(false, Resources.Strings.Downloads_SAC_Err_Launch)), + ConfirmAsync: null, OnFinished: onFinished); } @@ -190,8 +257,29 @@ private async Task RunDepotsAsync( // "· fetching manifest" to whatever it's given, so passing the other string would relabel the // row mid-pre-flight as though depots were already downloading. if (!item.CompletedDepots.Contains(sized.DepotId)) + { sized = sized with { ManifestPath = await EnsureManifestAsync(item, sized, prep, ct) }; + // Without a key the tool cannot decrypt a single chunk, and a depot that fails aborts the + // whole job below — so refuse here, before anything is written, naming the depot instead + // of surfacing the downloader's own "No valid depot key" much later. + if (!keys.TryGetValue(sized.DepotId, out string? hex) || !TryParseKey(hex, out byte[] key)) + throw new DownloadAbortedException( + string.Format(Resources.Strings.Depot_Err_NoKeyFor, sized.DepotId)); + + // A key that exists but is WRONG can only be caught when the manifest still has its + // filenames encrypted, which is the small minority — see ManifestFile.KeyLooksValid. + if (!ManifestFile.KeyLooksValid(sized.ManifestPath, key)) + throw new DownloadAbortedException( + string.Format(Resources.Strings.Depot_Err_BadKey, sized.DepotId)); + } + + // The manifest's own cb_disk_original beats app info's size: it is exact, and app info may + // not have carried a size at all (a token-gated app returns no depot list, so those depots + // arrive here as 0 and would otherwise be budgeted as free). + if (ManifestFile.TryRead(sized.ManifestPath) is { SizeOnDisk: > 0 } info) + sized = sized with { Size = info.SizeOnDisk }; + resolved.Add(sized); } @@ -220,6 +308,16 @@ private async Task RunDepotsAsync( // resolved one, so a finished shared depot no longer contributes 0 to the baseline. if (item.CompletedDepots.Contains(ready.DepotId)) { done += ready.Size; continue; } + // Re-checked per depot, not just once up front: the volume is shared with everything else + // on the machine, so a budget that cleared at the start can be gone by depot 12. Running + // out mid-download is not reported as a disk error — the tool simply stops printing, and + // the silence watchdog kills it ten minutes later as a "timeout", which explains nothing. + if (ready.Size > 0 && DepotDownloaderService.FreeSpaceFor(outDir) is { } left + && left < ready.Size) + throw new DownloadAbortedException(string.Format( + Resources.Strings.Depot_Err_NoSpace, + ByteFormat.Size(ready.Size), ByteFormat.Size(left))); + string step = string.Format(Resources.Strings.Downloads_Depots_Progress, i + 1, resolved.Count); OnUi(() => item.Detail = step); @@ -232,22 +330,38 @@ private async Task RunDepotsAsync( // complete, which is this tool's worst failure mode. bool validate = item.NeedsValidate || outDirExisted; item.NeedsValidate = false; - if (validate) OnUi(() => item.Status = DownloadStatus.Verifying); long baseBytes = done; - bool sawBytes = false; var relay = new ProgressRelay(f => + progress.Report(new DownloadProgress(baseBytes + (long)(f * ready.Size), totalSize))); + + // The phase is PARSED from the downloader's own output rather than guessed. A big depot + // pre-allocates every new file at full size before fetching a byte, so the row used to sit + // at "Downloading - 0 B of 4.49 GB" looking hung for minutes. Reported only on change. + var phases = new ProgressRelay(ph => OnUi(() => { - // First real progress means hashing is over and bytes are moving again. - if (validate && !sawBytes) + item.Detail = ph switch { - sawBytes = true; - OnUi(() => item.Status = DownloadStatus.Downloading); - } - progress.Report(new DownloadProgress(baseBytes + (long)(f * ready.Size), totalSize)); - }); - - var res = await depotTool.RunAsync(appId, ready, keysFile, outDir, validate, relay, ct); + DepotPhase.PreAllocating => $"{step} · {Resources.Strings.Downloads_Depot_PreAllocating}", + DepotPhase.Validating => $"{step} · {Resources.Strings.Downloads_Depot_Validating}", + DepotPhase.Manifest => $"{step} · {Resources.Strings.Downloads_Depot_FetchingManifest}", + _ => step, + }; + + // Verifying is a real status (it gates Pause and the label), so keep driving it - + // but from what the tool actually reports, not from "validate was requested and no + // bytes have arrived yet", which also covered pre-allocation and plain slow starts. + item.Status = ph == DepotPhase.Validating + ? DownloadStatus.Verifying + : DownloadStatus.Downloading; + })); + + // Recorded so a cancel can delete exactly what this download created. Collected off the + // UI thread on purpose: a big depot reports thousands of files and none of it is visible. + var created = new ProgressRelay(path => item.CreatedFiles.Add(path)); + + var res = await depotTool.RunAsync( + appId, ready, keysFile, outDir, validate, relay, ct, phases, created); if (!res.Ok) throw new DownloadAbortedException(res.Error == "tool" ? Resources.Strings.Depot_Err_Tool @@ -268,6 +382,18 @@ private async Task RunDepotsAsync( } } + /// + /// A depot key as bytes. Keys come from a lua file and from Steam's config.vdf, so a malformed one is + /// a real possibility and reads the same as having no key at all: the download cannot proceed. + /// + private static bool TryParseKey(string? hex, out byte[] key) + { + key = []; + if (hex is not { Length: 64 }) return false; // AES-256, hex-encoded + try { key = Convert.FromHexString(hex); return true; } + catch (FormatException) { return false; } + } + /// /// Fill in a shared depot's manifest id and size from the app that actually owns its content. /// Returns the selection unchanged for an ordinary depot (one that already declares its own gid). @@ -291,8 +417,13 @@ private async Task EnsureManifestAsync( DownloadItem item, DepotSelection sel, string step, CancellationToken ct) { // Already on disk (a previous run, a pinned install, or Steam's own copy): no request at all. + // ResolveManifestPath only accepts a file that actually parses as this depot's manifest. if (depotTool.ResolveManifestPath(sel.DepotId, sel.ManifestId!) is { } have) return have; + // Nothing usable — but something may still be sitting there under the right name. It has to go + // before the fetch, or InstallManifestFile will skip the copy and hand the bad file straight back. + depotTool.DiscardCachedManifest(sel.DepotId, sel.ManifestId!); + if (!depotTool.CanFetchManifests) throw new DownloadAbortedException(Resources.Strings.Depot_Err_SignIn); @@ -384,7 +515,10 @@ private JobResult InstallManifest(DownloadedFile file, long appId, string gameNa string message = result.ManifestCount > 0 ? string.Format(Resources.Strings.Add_Status_AddedManifests, gameName, result.ManifestCount) : string.Format(Resources.Strings.Add_Status_AddedFetch, gameName); - return new JobResult(true, message, file.FilePath); + + // Where it LANDED, not where it was staged: file.FilePath is deleted by the finally below, + // so returning it handed callers a path guaranteed not to exist. + return new JobResult(true, message, installer.ReadInstalledLua(appId)); } finally { @@ -419,7 +553,7 @@ private JobResult InstallDenuvoManifest(DownloadedFile file, long appId, string string message = string.Format(Resources.Strings.Fixes_Toast_FixInstalled_Body, gameName); toast.Show(Resources.Strings.Fixes_Toast_FixInstalled, message); - return new JobResult(true, message, file.FilePath); + return new JobResult(true, message, installer.ReadInstalledLua(appId)); // see InstallManifest } finally { diff --git a/src/LuaToolsGui/Services/LuaFileParser.cs b/src/LuaToolsGui/Services/LuaFileParser.cs index 9d3282e..763ecdb 100644 --- a/src/LuaToolsGui/Services/LuaFileParser.cs +++ b/src/LuaToolsGui/Services/LuaFileParser.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Text.RegularExpressions; namespace LuaToolsGui.Services; @@ -21,6 +21,12 @@ public record LuaEntry(long Id, string? Key, string? ManifestId, string? Comment { /// True when this entry carries a decryption key, i.e. it's a content depot. public bool HasKey => Key is not null; + + /// + /// Size on disk from setManifestid(id, "gid", size)'s third argument, or null when the line + /// omits it. The only size available for a depot Steam's app info does not list. + /// + public long? SizeOnDisk { get; init; } } /// Parsed contents of a stplug-in lua file. @@ -67,8 +73,10 @@ public static partial class LuaFileParser RegexOptions.IgnoreCase)] private static partial Regex AddAppIdRegex(); - // setManifestid(depotid, "manifestid", ...) - [GeneratedRegex(@"setManifestid\s*\(\s*(\d+)\s*,\s*""(\d+)""", RegexOptions.IgnoreCase)] + // setManifestid(depotid, "manifestid", size) — the third argument is optional and is the depot's + // size on disk, which is the only size available for a depot Steam's app info never mentions. + [GeneratedRegex(@"setManifestid\s*\(\s*(\d+)\s*,\s*""(\d+)""\s*(?:,\s*(\d+))?", + RegexOptions.IgnoreCase)] private static partial Regex SetManifestRegex(); // Strips a trailing "(123456) ..." / "デポ" tail that Hubcap appends to depot comments. @@ -102,6 +110,7 @@ public static partial class LuaFileParser var keyById = new Dictionary(); var commentById = new Dictionary(); var manifests = new Dictionary(); // active pins + var sizes = new Dictionary(); // setManifestid's 3rd arg var commentedManifests = new Dictionary(); // pins disabled by Auto Update var disabledOrder = new List(); // addappid lines commented out var disabledKeyById = new Dictionary(); @@ -118,8 +127,15 @@ public static partial class LuaFileParser // so a lua whose pins were commented out by "Auto Update Apps" still looked pinned. var pin = SetManifestRegex().Match(line); if (pin.Success && long.TryParse(pin.Groups[1].Value, out long depot)) + { (commented ? commentedManifests : manifests)[depot] = pin.Groups[2].Value; + // Taken from a commented pin too: the line is disabled as a version LOCK, but the + // size it records is still this depot's size and is otherwise unobtainable offline. + if (pin.Groups[3].Success && long.TryParse(pin.Groups[3].Value, out long sz) && sz > 0) + sizes.TryAdd(depot, sz); + } + // A commented-out addappid is a DISABLED declaration, not an active one. Matched against // the line with its "--" stripped so it can still be recognised and reported separately. var m = AddAppIdRegex().Match(commented ? line.TrimStart('-', ' ') : line); @@ -155,7 +171,8 @@ public static partial class LuaFileParser .Select(id => new LuaEntry(id, keyById[id], manifests.TryGetValue(id, out var mid) ? mid : null, commentedManifests.TryGetValue(id, out var cmid) ? cmid : null, - commentById.TryGetValue(id, out var c) ? c : null)) + commentById.TryGetValue(id, out var c) ? c : null) + { SizeOnDisk = sizes.TryGetValue(id, out long sz) ? sz : null }) .ToList(); // Ids that are ONLY commented out. An id with both an active and a commented line is active @@ -165,7 +182,8 @@ public static partial class LuaFileParser .Select(id => new LuaEntry(id, disabledKeyById[id], manifests.TryGetValue(id, out var dmid) ? dmid : null, commentedManifests.TryGetValue(id, out var dcmid) ? dcmid : null, - commentById.TryGetValue(id, out var dc) ? dc : null)) + commentById.TryGetValue(id, out var dc) ? dc : null) + { SizeOnDisk = sizes.TryGetValue(id, out long dsz) ? dsz : null }) .ToList(); // The base app is the first addappid id (matches how the files are generated). diff --git a/src/LuaToolsGui/Services/ManifestFile.cs b/src/LuaToolsGui/Services/ManifestFile.cs new file mode 100644 index 0000000..1bbc3a0 --- /dev/null +++ b/src/LuaToolsGui/Services/ManifestFile.cs @@ -0,0 +1,309 @@ +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace LuaToolsGui.Services; + +/// +/// What a Steam .manifest tells us without contacting Steam: the depot it belongs to, its true +/// uncompressed size, and whether its filenames are encrypted. +/// +/// +/// cb_disk_original — the size the depot occupies once installed. This is the authoritative +/// number: app info's size can be absent entirely (a token-gated app returns no depot list at all), and +/// the manifest is already on disk by the time a download is budgeted. +/// +/// +/// Whether the payload's filenames are still encrypted with the depot key. Usually FALSE — Steam stores +/// them decrypted in config\depotcache — which is exactly why key checking cannot rely on this. +/// +/// +/// The manifest's own id. Together with this is the file's self-declared +/// identity, which lets a cached <depot>_<gid>.manifest be checked against its name +/// rather than trusted because it exists. +/// +public readonly record struct ManifestInfo( + long DepotId, bool FilenamesEncrypted, long SizeOnDisk, ulong GidManifest); + +/// +/// Minimal reader for Steam's depot manifest format. Local, allocation-light, no network and no +/// dependency beyond the BCL. +/// +/// +/// The file is a flat run of [magic:uint32][length:uint32][bytes] sections, optionally +/// wrapped in a zip. Only the metadata section is parsed here, and only three of its fields — this is +/// deliberately not a general protobuf decoder, just enough to answer "how big is this depot" and +/// "can the key be checked against it". +/// +/// Everything fails soft: a malformed or truncated file yields null rather than throwing. Of the +/// 2,298 manifests in one real depotcache, one does not parse, and a single bad file must never take +/// down a download that would otherwise work. +/// +public static class ManifestFile +{ + private const uint PayloadMagic = 0x71F617D0; + private const uint MetadataMagic = 0x1F4812BE; + private const uint EofMagic = 0x32C415AB; + + // ContentManifestMetadata field numbers (see DepotDownloader's manifest.proto). + private const int FieldDepotId = 1; + private const int FieldGidManifest = 2; + private const int FieldFilenamesEncrypted = 4; + private const int FieldSizeOnDisk = 5; + + /// Read a manifest's metadata, or null if the file is missing or unparseable. + /// + /// Seeks over the payload rather than loading the file. The payload holds every file entry and runs + /// to megabytes (3 MB is common), while the metadata this returns is a few dozen bytes — and this is + /// now called once per depot when a picker opens, so reading whole files would be felt. + /// + public static ManifestInfo? TryRead(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; + + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // A zipped manifest has to be inflated whole; it cannot be seeked through. + Span peek = stackalloc byte[2]; + if (fs.Read(peek) == 2 && peek[0] == 'P' && peek[1] == 'K') + return ParseMetadata(FindSection(Unwrap(File.ReadAllBytes(path)), MetadataMagic)); + fs.Position = 0; + + byte[] header = new byte[8]; + while (fs.Position + 8 <= fs.Length) + { + if (fs.Read(header, 0, 8) != 8) return null; + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header); + uint len = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(4)); + + if (magic == EofMagic) break; + if (len > int.MaxValue || fs.Position + len > fs.Length) return null; // truncated + + if (magic != MetadataMagic) { fs.Position += len; continue; } + + byte[] meta = new byte[len]; + return fs.ReadAtLeast(meta, (int)len, throwOnEndOfStream: false) == (int)len + ? ParseMetadata(meta) + : null; + } + return null; // no metadata section + } + catch { return null; } // unreadable, truncated, or not a manifest at all + } + + private static ManifestInfo? ParseMetadata(byte[]? meta) + { + if (meta is null) return null; + + long depotId = 0, size = 0; + ulong gid = 0; + bool encrypted = false; + + int o = 0; + while (o < meta.Length) + { + if (!ReadTag(meta, ref o, out int field, out int wire)) return null; + if (wire == 0) + { + if (!ReadVarint(meta, ref o, out ulong v)) return null; + switch (field) + { + case FieldDepotId: depotId = (long)v; break; + case FieldGidManifest: gid = v; break; + case FieldFilenamesEncrypted: encrypted = v != 0; break; + case FieldSizeOnDisk: size = (long)v; break; + } + } + else if (!SkipField(meta, ref o, wire)) return null; + } + + return new ManifestInfo(depotId, encrypted, size, gid); + } + + /// + /// True when this file really is the manifest its name claims — it parses, and its own depot id and + /// gid match. Guards against a truncated or half-written cache entry being trusted because it exists. + /// + public static bool Matches(string? path, long depotId, string manifestId) => + TryRead(path) is { } info + && info.DepotId == depotId + && ulong.TryParse(manifestId, out ulong gid) + && info.GidManifest == gid; + + /// + /// Prove a depot key is the right one, when the manifest allows it. + /// + /// + /// True if a filename decrypted cleanly. Also true when the manifest's filenames are not + /// encrypted — there is nothing to test, so this reports "no objection", not "verified". + /// + /// + /// Only ~1.5% of cached manifests still carry encrypted filenames, so this is an opportunistic + /// extra check on top of "is a key present at all", never a replacement for it. Treating a + /// not-encrypted manifest as a pass is the only honest option: reporting failure there would reject + /// every depot, and claiming verification would be a lie. + /// + public static bool KeyLooksValid(string? path, byte[] key) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path) || key.Length != 32) return true; + + // Cheap metadata pass first. There is nothing to test unless the filenames are still encrypted, + // which is the small minority — this keeps the multi-MB payload read off the other ~98%. + if (TryRead(path) is not { FilenamesEncrypted: true }) return true; + + try + { + byte[] data = Unwrap(File.ReadAllBytes(path)); + if (FindSection(data, PayloadMagic) is not { } payload) return true; + if (FirstFilename(payload) is not { } name) return true; + + // Base64 only while encrypted; a decrypted name is raw UTF-8 and won't round-trip. + byte[] cipher; + try { cipher = Convert.FromBase64String(name); } + catch (FormatException) { return true; } + if (cipher.Length <= 16 || cipher.Length % 16 != 0) return true; + + return TryDecryptName(cipher, key); + } + catch { return true; } // never block a download on this check failing to run + } + + /// + /// Steam's filename cipher: the leading 16 bytes are an IV encrypted with AES-ECB under the depot + /// key, and the remainder is AES-CBC under that IV. A wrong key fails the PKCS7 unpad. + /// + private static bool TryDecryptName(byte[] cipher, byte[] key) + { + using var ecb = Aes.Create(); + ecb.Key = key; + ecb.Mode = CipherMode.ECB; + ecb.Padding = PaddingMode.None; + byte[] iv = ecb.CreateDecryptor().TransformFinalBlock(cipher, 0, 16); + + using var cbc = Aes.Create(); + cbc.Key = key; + cbc.IV = iv; + cbc.Mode = CipherMode.CBC; + cbc.Padding = PaddingMode.PKCS7; + + try + { + byte[] plain = cbc.CreateDecryptor().TransformFinalBlock(cipher, 16, cipher.Length - 16); + // A correct key yields a printable path; a wrong one that happens to unpad yields control bytes. + foreach (byte b in plain) + if (b < 0x20 && b != 0) return false; + return true; + } + catch (CryptographicException) { return false; } // bad padding = wrong key + } + + /// A manifest may be zipped; if so the single entry inside is the real thing. + private static byte[] Unwrap(byte[] data) + { + if (data.Length < 2 || data[0] != 'P' || data[1] != 'K') return data; + + using var zip = new ZipArchive(new MemoryStream(data), ZipArchiveMode.Read); + var entry = zip.Entries.FirstOrDefault(); + if (entry is null) return data; + + using var s = entry.Open(); + using var buf = new MemoryStream(); + s.CopyTo(buf); + return buf.ToArray(); + } + + /// Walk the section table and return the first section with this magic. + private static byte[]? FindSection(byte[] data, uint magic) + { + int o = 0; + while (o + 8 <= data.Length) + { + uint m = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(o)); + int len = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(o + 4)); + o += 8; + + if (m == EofMagic) break; + if (len < 0 || o + len > data.Length) break; // truncated + + if (m == magic) return data[o..(o + len)]; + o += len; + } + return null; + } + + /// The first FileMapping's filename, as stored (base64 while encrypted). + private static string? FirstFilename(byte[] payload) + { + int o = 0; + while (o < payload.Length) + { + if (!ReadTag(payload, ref o, out int field, out int wire)) return null; + + if (field == 1 && wire == 2) // repeated FileMapping + { + if (!ReadVarint(payload, ref o, out ulong len)) return null; + int end = o + (int)len; + if (end > payload.Length) return null; + + int inner = o; + while (inner < end) + { + if (!ReadTag(payload, ref inner, out int f2, out int w2)) return null; + if (f2 == 1 && w2 == 2) // filename + { + if (!ReadVarint(payload, ref inner, out ulong n)) return null; + if (inner + (int)n > payload.Length) return null; + return Encoding.UTF8.GetString(payload, inner, (int)n); + } + if (!SkipField(payload, ref inner, w2)) return null; + } + o = end; + } + else if (!SkipField(payload, ref o, wire)) return null; + } + return null; + } + + private static bool ReadTag(byte[] d, ref int o, out int field, out int wire) + { + field = wire = 0; + if (!ReadVarint(d, ref o, out ulong tag)) return false; + field = (int)(tag >> 3); + wire = (int)(tag & 0x07); + return true; + } + + private static bool ReadVarint(byte[] d, ref int o, out ulong value) + { + value = 0; + int shift = 0; + while (o < d.Length) + { + byte b = d[o++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) return true; + shift += 7; + if (shift > 63) return false; // malformed + } + return false; // ran off the end + } + + private static bool SkipField(byte[] d, ref int o, int wire) + { + switch (wire) + { + case 0: return ReadVarint(d, ref o, out _); + case 1: o += 8; return o <= d.Length; + case 5: o += 4; return o <= d.Length; + case 2: + if (!ReadVarint(d, ref o, out ulong len)) return false; + o += (int)len; + return o <= d.Length; + default: return false; // groups: not used by this format + } + } +} diff --git a/src/LuaToolsGui/Services/SteamAutoCrackService.cs b/src/LuaToolsGui/Services/SteamAutoCrackService.cs new file mode 100644 index 0000000..8387cfe --- /dev/null +++ b/src/LuaToolsGui/Services/SteamAutoCrackService.cs @@ -0,0 +1,345 @@ +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text.Json; +using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; +using Microsoft.Extensions.Logging; +using Velopack.Windows; + +namespace LuaToolsGui.Services; + +/// The outcome of making SteamAutoCrack runnable, so callers can say what actually happened. +public enum SacPrepareResult +{ + Ready, + /// The user declined the .NET runtime installer's elevation prompt. Not an error. + RuntimeDeclined, + /// The runtime installed but Windows wants a reboot before it can be used. + RuntimeNeedsRestart, + RuntimeFailed, +} + +/// +/// Downloads and launches SteamAutoCrack (SteamAutoCracks/Steam-auto-crack). +/// +/// +/// We can only open it. The published release contains a single GUI exe with no +/// SteamAutoCrack.CLI.exe, and that GUI parses no command-line arguments, so there is no way to hand it +/// an appid or a path. The user does everything inside their own window. +/// +/// Their exe is a FRAMEWORK-DEPENDENT net10.0-windows single-file build: the bundle carries no +/// hostpolicy/coreclr/System.Private.CoreLib and declares Microsoft.WindowsDesktop.App, so it cannot +/// start without the .NET 10 Desktop runtime. installs that on +/// demand through Velopack's Runtimes API, which we already depend on for updates. +/// +/// Update handling mirrors and : +/// the installed release tag is recorded, re-checked at most every , the +/// asset digest is verified before extracting, and every failure path falls back to an existing exe +/// while still recording the attempt so a failing check is not retried on the next click. +/// +public class SteamAutoCrackService( + GithubProxy gh, + CacheService cache, + ILogger log) +{ + private static readonly string ToolDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "steamautocrack"); + + private static string ExePath => Path.Combine(ToolDir, "SteamAutoCrack.exe"); + + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + /// How long an up-to-date check is trusted before we ask GitHub again. + private static readonly TimeSpan ToolCheckInterval = TimeSpan.FromHours(6); + + private readonly SemaphoreSlim _gate = new(1, 1); + + private static bool CheckedRecently(long lastMs) => + lastMs > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastMs < (long)ToolCheckInterval.TotalMilliseconds; + + // ── Runtime ────────────────────────────────────────────────────── + + /// + /// The .NET 10 Desktop runtime their exe needs, named for this machine's architecture. + /// + /// + /// Velopack's static fields stop at DOTNET8, but GetRuntimeByName parses the id generically, so + /// "net10-x64-desktop" resolves fine on 1.2.0 without an upgrade. Their asset carries no RID in its + /// name, so the OS architecture is the honest guess at which runtime it wants. + /// + private static string RuntimeId => RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "net10-x86-desktop", + Architecture.Arm64 => "net10-arm64-desktop", + _ => "net10-x64-desktop", + }; + + // Velopack marks Runtimes [Obsolete] ("no longer used by Velopack, and does not represent the + // current supported runtimes" - docs.velopack.io/packaging/bootstrapping). It is deprecated because + // Velopack now bootstraps runtimes through its own installer rather than at app runtime, which is + // not what we need: this is an on-demand install for a THIRD-PARTY exe, long after our own setup ran. + // + // The API still works on 1.2.0 - verified live against .NET 10, which has no static field: + // GetRuntimeByName("net10-x64-desktop") -> ".NET 10 WindowsDesktop (x64)" + // CheckIsInstalled() -> true on a machine with WindowsDesktop.App 10.0.9 + // GetDownloadUrl() -> builds.dotnet.microsoft.com/.../windowsdesktop-runtime-10.0.11-win-x64.exe + // + // If a future Velopack drops these types, the replacement is doing it by hand: query + // dotnetcli.blob.core.windows.net for the latest 10.0 release, download the desktop runtime exe and + // run it with /install /quiet-style switches. Detection can fall back to parsing `dotnet + // --list-runtimes` for a Microsoft.WindowsDesktop.App 10.x entry. +#pragma warning disable CS0618 // deliberate: see above + + /// + /// Is the runtime their exe needs already present? Local and cheap - no network, no install. + /// + /// + /// Presence of the major version is all their exe needs; patch-level servicing is Windows Update's + /// job, so this deliberately does not chase 10.0.x updates. + /// + public async Task RuntimeInstalledAsync() + { + try + { + var runtime = Runtimes.GetRuntimeByName(RuntimeId); + return runtime is not null && await runtime.CheckIsInstalled(); + } + catch (Exception ex) + { + log.LogDebug(ex, "Checking for the .NET runtime failed"); + return false; + } + } + + /// + /// Make sure the .NET 10 Desktop runtime is present, installing it if not. Returns Ready when their + /// exe can actually start. + /// + public async Task EnsureRuntimeAsync( + IProgress? progress, CancellationToken ct = default) + { + try + { + var runtime = Runtimes.GetRuntimeByName(RuntimeId); + if (runtime is null) + { + log.LogDebug("Unknown .NET runtime id {Id}", RuntimeId); + return SacPrepareResult.RuntimeFailed; + } + + if (await runtime.CheckIsInstalled()) return SacPrepareResult.Ready; + + if (!await runtime.CheckIsSupported()) + { + log.LogDebug("{Runtime} is not supported on this machine", runtime.DisplayName); + return SacPrepareResult.RuntimeFailed; + } + + Directory.CreateDirectory(ToolDir); + string installer = Path.Combine(ToolDir, $"{runtime.Id}.exe"); + await runtime.DownloadToFile(installer, p => progress?.Report(p / 100d), null, null); + ct.ThrowIfCancellationRequested(); + + // Not quiet: this elevates, and the user should see what they are approving. + var result = await runtime.InvokeInstaller(installer, false, null); + try { File.Delete(installer); } catch { /* leftover installer is harmless */ } + + return result switch + { + Runtimes.RuntimeInstallResult.InstallSuccess => SacPrepareResult.Ready, + Runtimes.RuntimeInstallResult.UserCancelled => SacPrepareResult.RuntimeDeclined, + Runtimes.RuntimeInstallResult.RestartRequired => SacPrepareResult.RuntimeNeedsRestart, + _ => SacPrepareResult.RuntimeFailed, + }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + log.LogDebug(ex, "Installing the .NET runtime for SteamAutoCrack failed"); + return SacPrepareResult.RuntimeFailed; + } + } +#pragma warning restore CS0618 + + // ── Tool ───────────────────────────────────────────────────────── + + /// + /// Ensure SteamAutoCrack is on disk and reasonably current. Null only if no usable copy exists. + /// + /// + /// Skip the throttle. Load-bearing for the background-update path: + /// records the check timestamp, so without this the job it queues would see "checked recently" and + /// skip the very download it was queued to perform. + /// + public async Task EnsureToolAsync( + IProgress? progress, bool force = false, CancellationToken ct = default) + { + if (!force && File.Exists(ExePath) && CheckedRecently(cache.SteamAutoCrackCheckedAtMs)) return ExePath; + + await _gate.WaitAsync(ct); + bool have = false; + try + { + have = File.Exists(ExePath); + if (!force && have && CheckedRecently(cache.SteamAutoCrackCheckedAtMs)) return ExePath; // won the race + + // A failed lookup still counts as "we looked", so an offline click backs off instead of + // re-walking the whole GithubProxy mirror chain every time the button is pressed. + void RecordAttempt() => + cache.SteamAutoCrackCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + string url = $"https://api.github.com/repos/{AppConfig.SteamAutoCrackRepo}/releases/latest"; + using var res = await gh.SendAsync(url, ct); + if (res is null || !res.IsSuccessStatusCode) + { + log.LogDebug("SteamAutoCrack release lookup failed: {Status}", res?.StatusCode); + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + var release = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); + var asset = release?.Assets.FirstOrDefault(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + if (asset is null) + { + log.LogDebug("SteamAutoCrack release has no .zip asset"); + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + if (!force && have && !string.IsNullOrEmpty(release!.TagName) + && string.Equals(release.TagName, cache.SteamAutoCrackVersion, StringComparison.Ordinal)) + { + RecordAttempt(); + return ExePath; + } + + Directory.CreateDirectory(ToolDir); + string zipPath = Path.Combine(ToolDir, "steamautocrack.zip"); + var sink = progress is null ? null : new ProgressRelay(f => + progress.Report(new DownloadProgress( + (long)((f ?? 0) * asset.Size), asset.Size > 0 ? asset.Size : null))); + await gh.DownloadAsync(asset.DownloadUrl, zipPath, sink, ct); + + // Verify before extracting over a working copy: we launch this binary afterwards. + if (!AssetHash.Matches(zipPath, asset.Digest)) + { + log.LogDebug("SteamAutoCrack asset digest mismatch; keeping the existing copy"); + try { File.Delete(zipPath); } catch { } + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + // Extract preserving the TREE. Unlike DepotDownloader's flat zip, Goldberg/ and TEMP/ sit + // beside the exe and their code resolves those from its own base directory, so flattening + // would break the bundled emulator and the seeded app list. + // + // This throws if the user has SteamAutoCrack OPEN (the exe is locked) - which a background + // update can easily hit. That is caught below, falls back to the existing copy and records + // the attempt, so it simply retries after the next interval. Not a case worth blocking on. + ZipFile.ExtractToDirectory(zipPath, ToolDir, overwriteFiles: true); + try { File.Delete(zipPath); } catch { /* leftover zip is harmless */ } + + if (!File.Exists(ExePath)) + { + if (have) RecordAttempt(); + return have ? ExePath : null; + } + + cache.SteamAutoCrackVersion = release!.TagName; + RecordAttempt(); + return ExePath; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + log.LogDebug(ex, "Obtaining SteamAutoCrack failed"); + if (have) cache.SteamAutoCrackCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return have ? ExePath : null; + } + finally { _gate.Release(); } + } + + /// + /// Open it immediately when nothing needs downloading. False means the caller should run the full + /// job (which installs the runtime and/or the tool). + /// + /// + /// This exists so a launch that transfers zero bytes never touches the queue: a queued job would + /// flash a progress row and, worse, leave a permanent entry in the user's download history next to + /// their real game downloads. + /// + /// The runtime check is not redundant with File.Exists. A framework-dependent exe with no + /// runtime still STARTS, and then shows Windows' own "you must install .NET" dialog - so skipping it + /// would hand the user that dialog instead of our install flow. + /// + public async Task TryLaunchIfReadyAsync(CancellationToken ct = default) + { + if (!File.Exists(ExePath)) return false; + if (!await RuntimeInstalledAsync()) return false; + return Launch(); + } + + /// + /// Throttled "is there a newer build?" probe. No download, and never blocks a launch. + /// + /// + /// Returns false when already current, when the 6h window has not elapsed, and on any failure - + /// recording the attempt each time so an offline machine does not re-walk the mirror chain on every + /// click. A true result is expected to be followed by EnsureToolAsync(force: true). + /// + public async Task IsUpdateAvailableAsync(CancellationToken ct = default) + { + if (!File.Exists(ExePath)) return false; + if (CheckedRecently(cache.SteamAutoCrackCheckedAtMs)) return false; + + await _gate.WaitAsync(ct); + try + { + if (CheckedRecently(cache.SteamAutoCrackCheckedAtMs)) return false; // won the race + + void RecordAttempt() => + cache.SteamAutoCrackCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + string url = $"https://api.github.com/repos/{AppConfig.SteamAutoCrackRepo}/releases/latest"; + using var res = await gh.SendAsync(url, ct); + if (res is null || !res.IsSuccessStatusCode) { RecordAttempt(); return false; } + + var release = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); + RecordAttempt(); + + return !string.IsNullOrEmpty(release?.TagName) + && !string.Equals(release!.TagName, cache.SteamAutoCrackVersion, StringComparison.Ordinal); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + log.LogDebug(ex, "Checking for a SteamAutoCrack update failed"); + cache.SteamAutoCrackCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return false; + } + finally { _gate.Release(); } + } + + /// Open SteamAutoCrack's window. Fire-and-forget; we don't wait for it to exit. + public bool Launch() + { + if (!File.Exists(ExePath)) return false; + try + { + // WorkingDirectory matters: their exe looks for Goldberg/ and TEMP/ next to itself. + Process.Start(new ProcessStartInfo(ExePath) + { + UseShellExecute = true, + WorkingDirectory = ToolDir, + }); + return true; + } + catch (Exception ex) + { + log.LogDebug(ex, "Launching SteamAutoCrack failed"); + return false; + } + } +} diff --git a/src/LuaToolsGui/Services/SteamService.cs b/src/LuaToolsGui/Services/SteamService.cs index 58fbec7..fcd0ba5 100644 --- a/src/LuaToolsGui/Services/SteamService.cs +++ b/src/LuaToolsGui/Services/SteamService.cs @@ -33,6 +33,38 @@ public string? EffectivePath public bool IsOverridden => !string.IsNullOrWhiteSpace(settings.SteamPathOverride); + /// + /// The Steam client's UI language ("english", "schinese", "brazilian", ...), or null if unreadable. + /// + /// + /// This is the same vocabulary a depot's config.language uses — verified against steamcmd's + /// app info, where the registry's "english" matches the depot value verbatim, and against the + /// baselanguages list ("english,german,french,..."). So it can be compared to depot languages + /// directly, with no mapping table. + /// + /// Read fresh each time rather than cached: a user can change Steam's language without restarting + /// this app, and the read is a single registry lookup. + /// + /// Lowercased on the way out — the depot values are lowercase, and relying on every future call site + /// to remember OrdinalIgnoreCase is the kind of thing that breaks once and is never noticed. + /// + public static string? SteamLanguage + { + get + { + try + { + using var key = RegistryKey + .OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) + .OpenSubKey(@"SOFTWARE\Valve\Steam"); + return key?.GetValue("Language") is string s && s.Length > 0 + ? s.Trim().ToLowerInvariant() + : null; + } + catch { return null; } // no Steam, or the value is missing/unreadable + } + } + /// True when the effective path exists and contains steam.exe. public bool IsValid => EffectivePath is not null && File.Exists(SteamExePathFor(EffectivePath)); @@ -54,6 +86,45 @@ public static void OpenUrl(string url) => public static void RevealInExplorer(string filePath) => Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{filePath}\"") { UseShellExecute = true }); + /// + /// Show a path in Explorer, picking the right gesture for what it is: a file gets selected inside + /// its folder, a folder is opened. Returns false when the path is missing or Explorer refuses. + /// + /// + /// always passes /select, which for a directory highlights it + /// in its PARENT rather than opening it — wrong for the depot output folder and a game's install + /// directory, which are the two most common targets. Callers hand over whichever they have and let + /// this sort it out, so no call site has to probe the filesystem itself. + /// + public static bool ShowInExplorer(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + try + { + if (File.Exists(path)) { RevealInExplorer(path); return true; } + if (Directory.Exists(path)) + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + return true; + } + return false; // deleted since the row was created + } + catch { return false; } // no shell association, or Explorer is wedged + } + + /// Put text on the clipboard. Returns false instead of throwing when it can't. + /// + /// Clipboard.SetText throws CLIPBRD_E_CANT_OPEN when another process is holding the + /// clipboard open — common with remote-desktop and clipboard-manager tools, and entirely outside our + /// control. Copying an app id is never worth an unhandled exception, so failure is reported, not thrown. + /// + public static bool CopyToClipboard(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + try { System.Windows.Clipboard.SetText(text); return true; } + catch { return false; } + } + /// Kill any running steam.exe (and its tree) and wait for it to exit. Safe to call when /// Steam isn't running. Use before changing Steam's files so they aren't locked. /// True while a Steam client process is running. Appinfo.vdf can't be edited under it. diff --git a/src/LuaToolsGui/ViewModels/BuildsViewModel.cs b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs index 028f3c2..b55f7ac 100644 --- a/src/LuaToolsGui/ViewModels/BuildsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs @@ -1,5 +1,6 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.IO; +using System.Text.RegularExpressions; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -46,9 +47,22 @@ public record DepotRow( /// separate from the prettified copy inside so it can be matched on. public string? Os { get; init; } + /// Raw Steam language code ("english", "schinese", ...), null for language-neutral content. + /// Separate from for the same reason as : it is matched on, not + /// just displayed. + public string? Language { get; init; } + /// Owning app for a shared redistributable depot, else null. See ContentDepot.FromAppId. public long? FromAppId { get; init; } + /// + /// Size in bytes from this depot's setManifestid(id, "gid", size) line, or 0 when the lua + /// omits it. Kept separate from (which is app info's) because the two describe + /// DIFFERENT builds: this one matches the manifest the lua pins, app info's matches whatever the + /// public branch ships today. + /// + public long LuaSize { get; init; } + /// /// Free-text match for the depot search box: name, depot id, DLC app id, manifest ids, and the /// literal type words "DLC"/"SHARED". already carries id · size · os · language, @@ -770,6 +784,12 @@ public partial class DepotPickRow : ObservableObject public string? Os { get; init; } + /// + /// The depot's Steam language code ("english", "schinese", ...), or null for language-neutral + /// content. Null is the important case: those depots are the actual game and are always taken. + /// + public string? Language { get; init; } + /// /// A depot built for another platform (a macOS or Linux build). Still listed and still tickable, /// but not ticked by default: on DELTARUNE the macOS depot was 864 MB of the 1.7 GB a select-all @@ -785,6 +805,13 @@ public partial class DepotPickRow : ObservableObject /// public bool IsShared => FromAppId is not null; + /// + /// Content that belongs to a DLC rather than the base game. Carried so the language fallback can + /// ignore it: DLC declares no language, so it is always "wanted", and letting it count as a + /// selection would suppress the fallback on a game whose real content is all language depots. + /// + public bool IsDlc { get; init; } + public long? FromAppId { get; init; } [ObservableProperty] private bool _isSelected; @@ -882,8 +909,20 @@ private void StartDepotDownload() foreach (var old in DepotPicks) old.PropertyChanged -= OnPickChanged; + // Read once per picker open rather than cached: Steam's language can change without this app + // restarting, and it is a single registry lookup. + string? steamLanguage = SteamService.SteamLanguage; + + // Read once, not per row: ResolveKeys parses the lua AND Steam's config.vdf on every call. + var keys = _depotTool.ResolveKeys(game.AppId); + + // DLC is included when it actually ships bytes. Excluding every r.IsDlc row was too broad: most + // DLC "depots" are 0-byte entitlement markers with nothing to fetch (American Truck Simulator has + // 54 of them), but a real chunk of them carry content — the same game has 19 holding 2.5 GB of + // map and truck DLC, all of them keyed by the lua, which the picker simply never offered. + // Size comes straight off the depot info, so this costs no extra lookups. var picks = _allInLua - .Where(r => !r.IsDlc) + .Where(r => !r.IsDlc || r.Size > 0) .Select(r => { // An ACTIVE pin means the user deliberately locked this build, so it wins outright and @@ -898,14 +937,35 @@ private void StartDepotDownload() string? mid = r.ManifestId ?? r.PublicManifestId ?? r.CommentedManifestId; string? path = mid is null ? null : _depotTool.ResolveManifestPath(r.Id, mid); + // Size, best source first: + // 1. the manifest itself — authoritative, and describes the exact build being fetched; + // 2. the lua's setManifestid size, but ONLY when an active pin is what we're downloading, + // because that figure belongs to the pinned build rather than the current one; + // 3. app info, which matches the public branch; + // 4. the lua's figure anyway, for a depot app info never listed (size would be 0). + long size = + ManifestFile.TryRead(path) is { SizeOnDisk: > 0 } mf ? mf.SizeOnDisk + : r.ManifestId is not null && r.LuaSize > 0 ? r.LuaSize + : r.Size > 0 ? r.Size + : r.LuaSize; + // All three checks are local — opening the picker costs zero API calls however many // depots the game has. Only a depot with no declared version is unreachable outright; // a missing manifest is now just a fetch, provided we're signed in to make it. // A shared depot has no gid here by design — its manifest lives under the owning app and // is resolved at download time, so a missing id is only fatal when there's nowhere to // look it up. Both checks stay local; the picker still makes zero requests. + // The key check matters most for DLC: a DLC depot always has a public manifest id, so the + // manifest test can never catch one whose key the lua simply doesn't carry — it would be + // offered, ticked, and then abort the whole download at the first depot. + // + // Shared depots are NOT exempt, unlike the manifest test above them. A shared depot's + // *manifest* is resolved from the owning app at download time, but nothing ever resolves + // a *key* there — ResolveKeys reads this game's lua and config.vdf and that is all the + // downloader will ever get. Exempting them here would only move the failure later. string? blocked = mid is null && r.FromAppId is null ? Resources.Strings.Builds_Select_NoManifest + : !keys.ContainsKey(r.Id) ? Resources.Strings.Builds_Select_NoKey : path is null && !_depotTool.CanFetchManifests ? Resources.Strings.Builds_Select_SignIn : null; @@ -914,25 +974,31 @@ mid is null && r.FromAppId is null ? Resources.Strings.Builds_Select_NoManifest DepotId = r.Id, Title = r.Title, Meta = r.Meta, - Size = r.Size, + Size = size, ManifestId = mid, ManifestPath = path, Os = r.Os, + Language = r.Language, + IsDlc = r.IsDlc, FromAppId = r.FromAppId, BlockReason = blocked, }; - pick.IsSelected = pick.CanDownload && !pick.IsOtherPlatform && !pick.IsShared; + pick.IsSelected = pick.CanDownload && !pick.IsOtherPlatform && !pick.IsShared + && WantedLanguage(pick.Language, steamLanguage); return pick; }) .ToList(); + // Whole-set decision, so it can only run once every row exists. Subscribing afterwards keeps it + // from firing OnPickChanged (and the space recalculation) once per row it flips. + ApplyNoLanguageMatchFallback(picks); + foreach (var p in picks) p.PropertyChanged += OnPickChanged; DepotPicks = picks; // Seed the destination (and with it the free-space read) before the bar first renders. string defaultRoot = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "LuaTools Depots", game.AppId.ToString()); + DownloadsFolder(), "LuaTools Depots", game.AppId.ToString()); try { Directory.CreateDirectory(defaultRoot); } catch { /* the Change picker still opens */ } DepotOutDir = defaultRoot; @@ -950,12 +1016,90 @@ private void CancelDepotDownload() IsSelectMode = false; } + /// + /// Should this depot be ticked by default, on language grounds alone? + /// + /// + /// Null language means language-NEUTRAL content — the actual game — and is always wanted. That case + /// carries the whole design: Witcher 3 has 39 neutral depots against 30 language ones, so a rule that + /// only matched languages would strip most of the game. + /// + /// English is always taken as well. It is the near-universal fallback, it is usually what a missing + /// or partial localisation degrades to, and it costs one depot. + /// + private static bool WantedLanguage(string? depotLanguage, string? steamLanguage) => + depotLanguage is null + || depotLanguage.Equals("english", StringComparison.OrdinalIgnoreCase) + || (steamLanguage is not null + && depotLanguage.Equals(steamLanguage, StringComparison.OrdinalIgnoreCase)); + + /// + /// If the language rule ticked nothing, fall back to ticking every language depot. + /// + /// + /// Only when the selection is COMPLETELY empty. The obvious-looking test — "no language depot got + /// picked" — is wrong, because plenty of games ship no English depot at all and carry English in + /// their neutral content, offering language depots purely as extra localisations. Cyberpunk 2077 + /// (10 language depots, none English) and The Witcher 3 (8, none English) both work that way, and + /// that test would have selected every localisation for them, which is the opposite of the point. + /// + /// An empty selection means the game is all language depots and none matched — a Japanese- or + /// Chinese-only release on a machine whose Steam language it doesn't offer. Taking everything there + /// errs toward a working install; the user can untick. + /// + private static void ApplyNoLanguageMatchFallback(IReadOnlyList picks) + { + // DLC is excluded from the test on purpose: it declares no language, so it is always selected, + // and counting it here would mean a game that ships one DLC depot never gets the fallback. + if (picks.Any(p => p.IsSelected && !p.IsDlc)) return; // neutral content (or a match) covers it + + var languageDepots = picks.Where(p => p.Language is not null).ToList(); + if (languageDepots.Count == 0) return; // nothing language-specific to fall back to + + foreach (var p in languageDepots) + p.IsSelected = p.CanDownload && !p.IsOtherPlatform && !p.IsShared; + } + [RelayCommand] private void SelectAllDepots() { foreach (var p in DepotPicks) p.IsSelected = p.CanDownload; } + /// + /// The user's Downloads folder, honouring a relocated one. + /// + /// + /// There is no %DOWNLOADS% environment variable and Environment.SpecialFolder has no Downloads + /// member, so the only correct source is the Known Folder registry value. That matters here more than + /// usual: anyone who moved Downloads onto a bigger drive is precisely the person pulling tens of GB of + /// depot content, and a naive %USERPROFILE%\Downloads would send it to their system drive instead. + /// + /// Registry rather than SHGetKnownFolderPath deliberately — this codebase has no P/Invoke anywhere, + /// and SteamService already resolves Steam's path the same way. + /// + private static string DownloadsFolder() + { + // FOLDERID_Downloads. "User Shell Folders" holds the unexpanded form (e.g. "%USERPROFILE%\..."), + // which is why it is read in preference to the expanded "Shell Folders" copy Windows keeps stale. + const string DownloadsGuid = "{374DE290-123F-4565-9164-39C4925E467B}"; + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders"); + if (key?.GetValue(DownloadsGuid) is string raw && raw.Length > 0) + { + string expanded = Environment.ExpandEnvironmentVariables(raw); + if (Directory.Exists(expanded)) return expanded; + } + } + catch { /* unreadable registry: fall through to the profile-relative guess */ } + + string profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string guess = Path.Combine(profile, "Downloads"); + return Directory.Exists(guess) ? guess : profile; + } + /// Queue ONE item covering the whole selection, then jump to Downloads. [RelayCommand] private void ConfirmDepotDownload() @@ -1066,6 +1210,18 @@ partial void OnUnknownChanged(IReadOnlyList value) [RelayCommand] private static void OpenSteamDb(DepotRow row) => SteamService.OpenUrl(row.SteamDbUrl); + /// + /// Copy the GAME's app id. Takes no row: a carries a depot id, a DLC app id + /// and an owning-app id, but never the app id of the game whose page this is — that is page state. + /// + [RelayCommand] + private void CopyAppId() + { + if (ActiveGame is not { } game) return; + if (!SteamService.CopyToClipboard(game.AppId.ToString())) + _toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } + /// Pin/unpin one depot. Comments its setManifestid line in or out. [RelayCommand] private void ToggleLock(DepotRow row) @@ -1274,6 +1430,88 @@ private async Task LoadDepotsAsync(bool quiet = false) /// included or a depot the user just switched off would drop out of "In lua" and take its switch /// with it. The row has to stay put so it can be switched back on. /// + /// + /// Add depots the lua declares that Steam's app info never mentioned, so they still appear (and stay + /// downloadable) instead of silently vanishing. + /// + /// + /// Some apps require a Steam access token this app doesn't hold. steamcmd answers those with + /// "_missing_token": true and a null depots block — no depot list at all, not an empty + /// one. Risk of Rain 2, NBA 2K27, Touhou Luna Nights, Soundpad and Beam Eye Tracker all behave this + /// way. Without this the whole page came up blank with nothing explaining why, because "In lua" is + /// the intersection of the declarations and a depot list that was never delivered. + /// + /// Nothing is lost by falling back: the lua already carries every input a download needs — the + /// depot id, its decryption key, and a manifest id (usually a commented pin). Only size is + /// missing, so these rows show no MB and contribute nothing to the free-space estimate. + /// + /// Applied per id rather than only when the whole fetch came back empty, so it equally covers a + /// single depot the app info happens to omit. + /// + private static void AddDeclaredButUnlisted( + List items, Dictionary declared, long baseAppId, + HashSet depotDlcIds) + { + var known = items.Select(d => d.Id).ToHashSet(); + + foreach (var (id, entry) in declared) + { + // Already covered, either as a real depot or as the DLC app id behind one. + if (known.Contains(id) || depotDlcIds.Contains(id)) continue; + + // The base app's own addappid line is the app key, not a depot — downloading it is meaningless. + if (id == baseAppId) continue; + + // A key means content; without one it's a store entitlement, which is what DlcAppId marks. + bool isDepot = entry.HasKey; + + // Shared redistributables are only identifiable from the lua's comment here, and it matters: + // FromAppId is what keeps them off the default selection (they're usually already installed). + long? fromAppId = isDepot ? SharedOwnerFromComment(entry.Comment) : null; + + // The lua's own setManifestid size, when it has one: without it these rows report 0 bytes, + // which understates the download total and leaves the free-space warning blind. + items.Add(new ContentDepot(id, entry.SizeOnDisk ?? 0, isDepot ? null : id, + fromAppId is not null, + Os: null, Language: isDepot ? LanguageFromComment(entry.Comment) : null) + { FromAppId = fromAppId }); + } + } + + /// + /// Steam's language codes, as they appear in a depot's config.language. Used to recognise a + /// synthesized depot's language from its lua comment, which is the only clue available when Steam + /// withheld the app info — without it a "Schinese" depot reads as language-neutral and is selected + /// for everyone. + /// + private static readonly HashSet SteamLanguageCodes = new(StringComparer.OrdinalIgnoreCase) + { + "arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english", "finnish", + "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese", "koreana", + "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian", "spanish", "latam", + "swedish", "thai", "turkish", "ukrainian", "vietnamese", + }; + + /// The Steam language code a lua comment names, or null if it names something else. + private static string? LanguageFromComment(string? comment) + { + if (string.IsNullOrWhiteSpace(comment)) return null; + foreach (string word in comment.Split([' ', ' ', '(', ')', ',', '-', ':'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (SteamLanguageCodes.TryGetValue(word, out string? code)) return code.ToLowerInvariant(); + return null; + } + + /// Owning app id from a lua comment like "VC 2019 Redist (Shared from App 228980)". + private static long? SharedOwnerFromComment(string? comment) => + comment is not null && SharedFromAppRegex().Match(comment) is { Success: true } m + && long.TryParse(m.Groups[1].Value, out long owner) + ? owner + : null; + + [GeneratedRegex(@"Shared\s+from\s+App\s+(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex SharedFromAppRegex(); + private static Dictionary Declarations(LuaContents? lua) { var all = new Dictionary(); @@ -1317,6 +1555,8 @@ private void BuildRows(AppDepotInfo info, LuaContents? lua) if (!depotDlcIds.Contains(dlcId)) items.Add(new ContentDepot(dlcId, 0, dlcId, IsShared: false, Os: null, Language: null)); + AddDeclaredButUnlisted(items, declared, baseAppId, depotDlcIds); + bool DlcNameKnown(long dlcId) => _appList.GetName(dlcId) is not null || _appInfo.GetCached(dlcId)?.Name is not null || luaNames.ContainsKey(dlcId); @@ -1351,7 +1591,10 @@ DepotRow Row(ContentDepot d) IsEnabled: active.Contains(declId), CanToggle: inLua, // anything the lua declares can be switched, in any variant IsBaseApp: declId == baseAppId) - { ToggleId = declId, Size = d.Size, Os = d.Os, FromAppId = d.FromAppId }; + { + ToggleId = declId, Size = d.Size, Os = d.Os, Language = d.Language, + FromAppId = d.FromAppId, LuaSize = entry?.SizeOnDisk ?? 0, + }; } // In lua = the lua declares this id (a keyed depot OR a keyless DLC entitlement) or its DLC app diff --git a/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs index 7592300..6adb933 100644 --- a/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs @@ -1,5 +1,7 @@ +using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LuaToolsGui.Services; using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.ViewModels; @@ -13,9 +15,17 @@ public partial class DownloadsViewModel : ObservableObject { private readonly DownloadQueue _queue; - public DownloadsViewModel(DownloadQueue queue) + private readonly ManifestJobFactory _jobs; + private readonly SteamAutoCrackService _sac; + private readonly ToastService _toast; + + public DownloadsViewModel(DownloadQueue queue, ManifestJobFactory jobs, SteamAutoCrackService sac, + ToastService toast) { _queue = queue; + _jobs = jobs; + _sac = sac; + _toast = toast; _queue.Items.CollectionChanged += (_, _) => RaiseCounts(); _queue.History.CollectionChanged += (_, _) => RaiseCounts(); @@ -41,8 +51,86 @@ private void RaiseCounts() // ── Commands ───────────────────────────────────────────────────── + /// + /// Fetch SteamAutoCrack (and the .NET runtime it needs) and open it. + /// + /// + /// Goes through the queue rather than running inline: the first run pulls roughly 100 MB, which needs + /// a progress row and a Cancel button rather than a frozen-looking window. The job's DedupeKey means + /// repeated clicks join the running item instead of stacking up. + /// [RelayCommand] - private void Cancel(DownloadItem item) => _queue.Cancel(item); + private async Task LaunchSteamAutoCrack() + { + // Already installed and runnable → open it now. Going through the queue here would flash a + // progress row for a launch that transfers nothing AND leave a permanent history entry beside + // the user's real game downloads, which is the whole reason this fast path exists. + if (await _sac.TryLaunchIfReadyAsync()) + { + _ = CheckSteamAutoCrackUpdateAsync(); + return; + } + + // First run, or the runtime is missing: real work, so it earns a queue row. + _queue.Enqueue(_jobs.CreateSteamAutoCrackJob()); + } + + /// Throttled background update probe. Only queues anything if a newer build actually exists. + private async Task CheckSteamAutoCrackUpdateAsync() + { + // Fire-and-forget off a UI command: an unobserved fault here must never reach the user. + try + { + if (_queue.FindActive("tool:steamautocrack") is not null) return; // one already in flight + if (await _sac.IsUpdateAvailableAsync()) + _queue.Enqueue(_jobs.CreateSteamAutoCrackJob(launchWhenDone: false)); + } + catch { /* background nicety; never surfaces */ } + } + + /// + /// Cancel an item. A depot download that has already written to disk asks what to do with the files + /// first — cancelling leaves them behind otherwise, and they are not small. + /// + /// + /// Only depot downloads prompt: they are the only kind that writes a folder rather than a staged file + /// the queue already cleans up. And only when the downloader actually used that folder, which + /// establishes from its own marker — the + /// output directory is user-chosen and may contain unrelated files. + /// + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task Cancel(DownloadItem item) + { + string? outDir = item.Job.OutputPath; + if (item.Job.Kind is not DownloadKind.Depot + || !DepotDownloaderService.HasDownloadedContent(outDir)) + { + _queue.Cancel(item); + return; + } + + // Yes = stop and delete (the default), No = stop and keep, Cancel = keep downloading. + // Escape lands on Cancel, so the accidental keypress is the harmless one even though the + // destructive option is what Enter selects. + var choice = MessageBox.Show( + string.Format(Resources.Strings.Depot_Cancel_Body, ByteFormat.Size(item.BytesRead), outDir), + Resources.Strings.Depot_Cancel_Title, + MessageBoxButton.YesNoCancel, + MessageBoxImage.Warning, + MessageBoxResult.Yes); + + if (choice == MessageBoxResult.Cancel) return; // leave the download running + + _queue.Cancel(item); + if (choice != MessageBoxResult.Yes) return; + + // The kill is asynchronous and the downloader holds handles on everything it pre-allocated, so + // deleting before the item settles would just fail on a locked file. + await item.Completion; + if (!DepotDownloaderService.TryDeleteCreatedFiles(outDir, item.CreatedFiles)) + _toast.Show(Resources.Strings.Depot_Cancel_Title, + string.Format(Resources.Strings.Depot_Cancel_DeleteFailed, outDir), error: true); + } [RelayCommand] private void Retry(DownloadItem item) => _queue.Retry(item); @@ -57,6 +145,39 @@ private void RaiseCounts() [RelayCommand] private void Remove(DownloadItem item) => _queue.Remove(item); + // ── Row actions (right-click) ──────────────────────────────────── + // Two commands per action because RelayCommand is typed and the queue and the history list bind + // different row types. Both funnel into the same pair of helpers so the behaviour can't diverge. + + [RelayCommand] + private void CopyAppId(DownloadItem item) => CopyId(item.AppId); + + [RelayCommand] + private void CopyHistoryAppId(DownloadHistoryEntry entry) => CopyId(entry.AppId); + + [RelayCommand] + private void ShowInFolder(DownloadItem item) => Show(item.RevealPath); + + [RelayCommand] + private void ShowHistoryInFolder(DownloadHistoryEntry entry) => Show(entry.Record.RevealPath); + + private void CopyId(long appId) + { + if (!SteamService.CopyToClipboard(appId.ToString())) + _toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } + + /// + /// Open the install location. The path is recorded when the job finishes, so by the time a row can be + /// right-clicked it is either real or absent — but the user can still have deleted it since. + /// + private void Show(string? path) + { + if (!SteamService.ShowInExplorer(path)) + _toast.Show(Resources.Strings.Common_ShowInFolder, + string.Format(Resources.Strings.Err_PathMissing, path ?? ""), error: true); + } + [RelayCommand] private void MoveUp(DownloadItem item) => _queue.Move(item, -1); @@ -64,7 +185,26 @@ private void RaiseCounts() private void MoveDown(DownloadItem item) => _queue.Move(item, +1); [RelayCommand] - private void ClearHistory() => _queue.ClearHistory(); + private void ClearHistory() + { + // Deliberately NOT async: MessageBox.Show already blocks and returns a result, and an async + // command would become an AsyncRelayCommand, which disables itself while running. + var choice = MessageBox.Show( + string.Format(Resources.Strings.Downloads_ClearHistory_Confirm, _queue.History.Count), + Resources.Strings.Downloads_Action_ClearHistory, + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); // Enter must not wipe the list + + if (choice == MessageBoxResult.Yes) _queue.ClearHistory(); + } + + /// + /// Remove one history row. No confirmation: it deletes a record, not a download, and prompting per + /// row would be tedious. The bulk Clear history above does confirm, because it cannot be undone. + /// + [RelayCommand] + private void RemoveHistoryEntry(DownloadHistoryEntry entry) => _queue.RemoveHistory(entry); /// Jump to the page that can resolve this item (e.g. the pending overwrite confirmation). [RelayCommand] diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index 0e16076..47c6ee3 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -252,6 +252,32 @@ public async Task OpenForAppIdAsync(long appId) await OpenGame(game); } + [RelayCommand] + private void CopyAppId(FixGameCardVm game) + { + if (!SteamService.CopyToClipboard(game.AppId)) + toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } + + /// + /// Open the game's Steam install folder — where ApplyDenuvoFix extracts a fix to. + /// + /// + /// Resolved on click, not bound to a property: GetInstallDir walks libraryfolders.vdf and the + /// appmanifest files, which is far too much work to repeat for every card in a grid on every render. + /// The cost of that is the action being offered for games that aren't installed, so it reports the + /// same "game not found" toast the fix flow already uses rather than failing silently. + /// + [RelayCommand] + private void ShowInFolder(FixGameCardVm game) + { + string? dir = long.TryParse(game.AppId, out long appId) ? library.GetInstallDir(appId) : null; + if (SteamService.ShowInExplorer(dir)) return; + + toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, + string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, game.Name), error: true); + } + [RelayCommand] private async Task OpenGame(FixGameCardVm game) { diff --git a/src/LuaToolsGui/ViewModels/ManageViewModel.cs b/src/LuaToolsGui/ViewModels/ManageViewModel.cs index b15946b..1e034cd 100644 --- a/src/LuaToolsGui/ViewModels/ManageViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ManageViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.IO; using System.Windows; using System.Windows.Media; @@ -366,8 +366,11 @@ private static void RevealFile(LuaTileViewModel tile) => SteamService.RevealInExplorer(tile.FilePath); [RelayCommand] - private static void CopyAppId(LuaTileViewModel tile) => - Clipboard.SetText(tile.AppId.ToString()); + private void CopyAppId(LuaTileViewModel tile) + { + if (!SteamService.CopyToClipboard(tile.AppId.ToString())) + _toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } [RelayCommand] private void Update(LuaTileViewModel tile) => NavigateToAdd?.Invoke(tile.AppId); diff --git a/src/LuaToolsGui/Views/BuildsView.xaml b/src/LuaToolsGui/Views/BuildsView.xaml index ed44e36..6899018 100644 --- a/src/LuaToolsGui/Views/BuildsView.xaml +++ b/src/LuaToolsGui/Views/BuildsView.xaml @@ -1,4 +1,4 @@ - - + + + + + + + + + + + + @@ -798,7 +812,8 @@ Grid.Row="1" Margin="0,10,0,0" VerticalScrollBarVisibility="Auto"> - + + - + + + + + + + + + + + + + + + + + + - + + @@ -252,7 +256,11 @@ HorizontalScrollBarVisibility="Auto" PreviewMouseWheel="FeaturedStrip_PreviewMouseWheel" VerticalScrollBarVisibility="Disabled"> - + + @@ -663,7 +671,8 @@ - + + @@ -789,7 +798,8 @@ Grid.Row="2" Margin="0,14,0,0" VerticalScrollBarVisibility="Auto"> - + + + + + + + + + + + + @@ -192,11 +212,33 @@ - + + + + + + + + + + + + @@ -227,6 +269,17 @@ FontSize="11" Foreground="{DynamicResource TextFillColorTertiaryBrush}" Text="{Binding WhenLabel}" /> + + @@ -238,17 +291,32 @@ - - + + + + + + + + + + - + + + + + + + + + + + - + + +/// The speed/ETA readout on a download row. +/// +/// +/// These guard a beta report of "download speed is unstable". Depot progress is reported once per +/// COMPLETED FILE — DepotDownloaderMod only prints its percentage line when a file's last chunk lands, +/// and its finer per-chunk ANSI progress is disabled whenever stdout is redirected, which is how we +/// launch it. Real depots are lumpy enough for that to matter: American Truck Simulator's content depot +/// is 73 files, one of which is 8.3 GB — about 166 seconds of silence at 50 MB/s. +/// +/// So the window has to survive minute-long gaps AND bursts of files finishing together, which is what +/// each test below pins down. +/// +public class DownloadRateTests +{ + private const long MB = 1024 * 1024; + + /// An item whose clock the test drives, so a 166-second gap costs no real time. + private static (DownloadItem Item, Action Advance) Build() + { + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var job = new DownloadJob( + DownloadKind.Depot, "test:1", 1, "Test", "Depot files", null, + (_, _, _) => Task.FromResult(new DownloadedFile("x", "x")), + (_, _, _) => Task.FromResult(new JobResult(true, null))); + + var item = new DownloadItem(job) { UtcNow = () => now }; + return (item, seconds => now = now.AddSeconds(seconds)); + } + + /// + /// A single file so large it reports nothing for minutes must still yield its true average rate. + /// This is the original bug: the age trim stripped the window down to the newest sample, the + /// "fewer than two samples" guard returned early, and the speed stayed frozen at a stale value. + /// + [Fact] + public void LongGapBetweenFiles_ReportsTheRealAverage() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + + item.ApplySample(0, total); + advance(166); + item.ApplySample(8_315 * MB, total); // ATS's 8.3 GB file, start to finish + + Assert.InRange(item.BytesPerSecond, 45 * MB, 55 * MB); // 8.3 GB / 166 s ~= 50 MB/s + } + + /// + /// The other half of the same bug: the rate must not merely be non-zero, it must MOVE. A stale + /// reading looks plausible on screen, which is exactly why the report said "unstable" rather than + /// "stuck" — the number was a real speed, just one measured minutes earlier. + /// + [Fact] + public void AfterAFastStart_ALongSlowFileUpdatesTheReading() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + long read = 0; + + // Dense stream of small files at ~100 MB/s. + for (int i = 0; i < 30; i++) + { + advance(0.1); + read += 10 * MB; + item.ApplySample(read, total); + } + double fast = item.BytesPerSecond; + Assert.InRange(fast, 90 * MB, 110 * MB); + + // Then one huge file that averages half that. + advance(166); + read += 8_315 * MB; + item.ApplySample(read, total); + + Assert.NotEqual(fast, item.BytesPerSecond); + Assert.InRange(item.BytesPerSecond, 40 * MB, 60 * MB); + } + + /// + /// Several concurrent files completing milliseconds apart must not be divided by that interval. + /// Unguarded this produced multi-GB/s readings — the visible "spike" half of the report. + /// + [Fact] + public void BurstOfCompletions_DoesNotSpike() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + long read = 0; + + item.ApplySample(read, total); + advance(166); + read += 8_315 * MB; + item.ApplySample(read, total); + + // Eight parallel downloads all landing at once, 10 ms apart. + for (int i = 0; i < 8; i++) + { + advance(0.01); + read += 1 * MB; + item.ApplySample(read, total); + } + + Assert.InRange(item.BytesPerSecond, 1 * MB, 200 * MB); // sane; pre-fix this read in GB/s + } + + /// A well-shaped depot (many small files) must still measure accurately. + [Fact] + public void DenseSteadyStream_MatchesTheActualRate() + { + var (item, advance) = Build(); + long total = 3_000 * MB; + long read = 0; + + for (int i = 0; i < 100; i++) // 10 s at 20 MB/s + { + advance(0.1); + read += 2 * MB; + item.ApplySample(read, total); + } + + Assert.InRange(item.BytesPerSecond, 18 * MB, 22 * MB); + } + + /// ETA follows the rate, so it must be sane once the rate is. + [Fact] + public void Eta_FollowsTheMeasuredRate() + { + var (item, advance) = Build(); + long total = 1_000 * MB; + + item.ApplySample(0, total); + advance(10); + item.ApplySample(500 * MB, total); // 50 MB/s, 500 MB left => ~10 s + + Assert.NotNull(item.Eta); + Assert.InRange(item.Eta!.Value.TotalSeconds, 8, 12); + } + + /// A retry must not inherit the previous attempt's window. + [Fact] + public void ResetMetrics_ClearsTheWindow() + { + var (item, advance) = Build(); + long total = 1_000 * MB; + + item.ApplySample(0, total); + advance(10); + item.ApplySample(500 * MB, total); + Assert.True(item.BytesPerSecond > 0); + + item.ResetMetrics(); + Assert.Equal(0, item.BytesPerSecond); + Assert.Equal(0, item.BytesRead); + Assert.Null(item.Eta); + + // And the cleared window must not resurrect the old samples as a bogus first reading. + advance(1); + item.ApplySample(0, total); + Assert.Equal(0, item.BytesPerSecond); + } +} From 66cb56bd4364fd79b72cbd1315d0b3c370675ef3 Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Sun, 30 Aug 2026 11:15:10 +1000 Subject: [PATCH 18/29] Update README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index cef0e15..d0e709a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ You can find release builds on the [luatools website](https://lua.tools/app) or - [Millennium](https://steambrew.app/): the Steam plugin framework whose injection API this app polyfills when Millennium isn't installed - [Velopack](https://velopack.io/): installer and auto-update framework +- [DepotDownloaderMod](https://github.com/SteamAutoCracks/DepotDownloaderMod): downloads depot content + from Steam's CDN, powering the Depots page's Download action. A fork of + [DepotDownloader](https://github.com/SteamRE/DepotDownloader), fetched and run as a standalone tool +- [SteamAutoCrack](https://github.com/SteamAutoCracks/Steam-auto-crack): fetched and launched from the + Downloads page +- [Steamless](https://github.com/atom0s/Steamless): removes SteamStub DRM from game executables +- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud redirection, used by the + mode install flow ## Licence From 28adc00652529fbfa0f19dcabafb050fa2f620a9 Mon Sep 17 00:00:00 2001 From: madoiscool <110845835+madoiscool@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:22:04 +1000 Subject: [PATCH 19/29] oops fogor --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d0e709a..d008919 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,8 @@ You can find release builds on the [luatools website](https://lua.tools/app) or [DepotDownloader](https://github.com/SteamRE/DepotDownloader), fetched and run as a standalone tool - [SteamAutoCrack](https://github.com/SteamAutoCracks/Steam-auto-crack): fetched and launched from the Downloads page -- [Steamless](https://github.com/atom0s/Steamless): removes SteamStub DRM from game executables -- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud redirection, used by the - mode install flow +- [Steamless](https://github.com/atom0s/Steamless): removes SteamStub from game executables +- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud revival project, can be turned on via the mode page to page. ## Licence From ad04cabde4aff41590ad40981b9428e9ece052d2 Mon Sep 17 00:00:00 2001 From: madoiscool <110845835+madoiscool@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:22:25 +1000 Subject: [PATCH 20/29] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d008919..d2b7149 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ You can find release builds on the [luatools website](https://lua.tools/app) or - [SteamAutoCrack](https://github.com/SteamAutoCracks/Steam-auto-crack): fetched and launched from the Downloads page - [Steamless](https://github.com/atom0s/Steamless): removes SteamStub from game executables -- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud revival project, can be turned on via the mode page to page. +- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud revival project, can be turned on via the mode page ## Licence From ce4754187d97be12117ef6b4f612fcb7e46fe675 Mon Sep 17 00:00:00 2001 From: gattoooo <103853435+gattoooo@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:42:33 +0200 Subject: [PATCH 21/29] Restyle mode selection badges with icons and clearer colours Adds an icon to each mode badge, swaps Active to green and Recommended to blue, and reorders them so the active mode reads first. Co-authored-by: gattoooo --- src/LuaToolsGui/Views/ModeView.xaml | 99 ++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/src/LuaToolsGui/Views/ModeView.xaml b/src/LuaToolsGui/Views/ModeView.xaml index 9ce31e9..cb5f118 100644 --- a/src/LuaToolsGui/Views/ModeView.xaml +++ b/src/LuaToolsGui/Views/ModeView.xaml @@ -74,60 +74,97 @@ FontSize="15" FontWeight="SemiBold" Text="{Binding Title}" /> - + - + Visibility="{Binding IsActive, Converter={StaticResource BoolToVis}}"> + + + + - + - + Visibility="{Binding IsRecommended, Converter={StaticResource BoolToVis}}"> + + + + - + - + Visibility="{Binding IsExperimental, Converter={StaticResource BoolToVis}}"> + + + + + - + Visibility="{Binding SupportsCloudRedirect, Converter={StaticResource BoolToVis}}"> + + + + + disabled) unless BetterSteamTools Nightly is the active mode. --> Date: Thu, 3 Sep 2026 22:55:05 -0500 Subject: [PATCH 22/29] fire --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d2b7149..bf564a9 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,6 @@ You can find release builds on the [luatools website](https://lua.tools/app) or ## Licence MIT. See [LICENSE](LICENSE). + +928c14bad5bbc258894b050af1e17ba8 + From e5e4a58639b1f0f87be9855e47b0fc828951254f Mon Sep 17 00:00:00 2001 From: peeblyweeb <74932007+PeeblyWeeb@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:05:27 -0500 Subject: [PATCH 23/29] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bf564a9..6842f24 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@

- luatools + lt +

# LuaTools From e26e027787eff9d986e04f21b0d3b86f43414590 Mon Sep 17 00:00:00 2001 From: Brandon Hernandez <142270679+Brandher58@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:11:26 -0700 Subject: [PATCH 24/29] Add revert for applied fixes and a My games filter Applying a fix now backs up the files it overwrites into .luatools-fix/ and records them in a manifest, so it can be reverted. Adds a My games toggle that filters the listing to appids with a lua in stplug-in. --- src/LuaToolsGui/Models/FixModels.cs | 17 ++ src/LuaToolsGui/Resources/Strings.Designer.cs | 14 ++ src/LuaToolsGui/Resources/Strings.es.resx | 14 ++ src/LuaToolsGui/Resources/Strings.resx | 14 ++ .../Services/Downloads/ManifestJobFactory.cs | 162 +++++++++++++- src/LuaToolsGui/ViewModels/FixesViewModel.cs | 124 +++++++++- src/LuaToolsGui/Views/FixesView.xaml | 211 +++++++++++++----- 7 files changed, 488 insertions(+), 68 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..a6eaa14 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -298,6 +298,20 @@ 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)); + 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 f49178d..4e4f2e6 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -315,6 +315,20 @@ 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. + 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 340b033..4d1bfba 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -349,6 +349,20 @@ 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. + 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/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..556d84e 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") : ""; @@ -107,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; @@ -121,6 +133,7 @@ public FixesViewModel( this.queue = queue; this.jobs = jobs; this.library = library; + this.steam = steam; InitPageSize(settings.FixesPageSize); } @@ -147,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))] @@ -187,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; } @@ -197,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 } } @@ -205,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)); @@ -214,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(); } @@ -223,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 @@ -297,9 +353,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 +407,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 +479,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..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}" /> - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + @@ -487,9 +530,10 @@ - + + @@ -530,6 +584,53 @@ + + + + + + + + + + + + + + + From a6aacb7c36c2ba4839d00272740f6b69d1bba617 Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Tue, 8 Sep 2026 15:03:08 +1000 Subject: [PATCH 25/29] Add Fixes translations; secure manifest handling ty claude Add new "Fixes" localization keys (Applied hint, My Games, counts, revert flow texts, and error messages) across all language .resx files. Improve ManifestJobFactory to prevent zip-slip by resolving paths inside the game folder, back up files keyed by full relative path (not filename), and validate manifest backup paths on revert. Revert now preserves backups/manifests on partial failure (so retries are possible) and only deletes them after a full successful revert. --- src/LuaToolsGui/Resources/Strings.ar.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.bg.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.cs.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.da.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.de.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.el.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.es-419.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.fi.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.fr.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.hu.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.id.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.it.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.ja.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.ko.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.nb.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.nl.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.pl.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.pt-BR.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.pt-PT.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.ro.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.ru.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.sv.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.th.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.tr.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.uk.resx | 14 ++++ src/LuaToolsGui/Resources/Strings.vi.resx | 14 ++++ .../Resources/Strings.zh-Hans.resx | 14 ++++ .../Resources/Strings.zh-Hant.resx | 14 ++++ .../Services/Downloads/ManifestJobFactory.cs | 64 ++++++++++++++++--- 29 files changed, 446 insertions(+), 10 deletions(-) diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 8bb97b8..65a5944 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -629,4 +629,18 @@ لا يوجد مفتاح فك تشفير في ملف Lua للـ depot ‏{0}. بيئة تشغيل مشتركة — مثبّتة غالبًا بالفعل. حدّدها للتنزيل على أي حال. هل تريد إزالة كل الإدخالات ({0}) من سجل التنزيلات؟ لن تتأثر الملفات التي تم تنزيلها. + تم تطبيق هذا الإصلاح بالفعل. تراجع عنه إذا أردت تثبيت إصلاح آخر. + ألعابي + {0} من ألعابك لديها إصلاحات. مرّر القائمة لعرض الباقي. + لم تتم إضافة أي ملفات lua بعد. أضف (أو ثبّت) لعبة لتظهر هنا. + تراجع + إلغاء + هل تريد التراجع عن "{0}"؟ ستتم استعادة الملفات المعدّلة من النسخة الاحتياطية وحذف الملفات التي أضافها الإصلاح. + هل تريد التراجع عن إصلاح {0}؟ + تم التراجع عن الإصلاح + تمت استعادة {0} وإزالة {1} من الملفات المضافة. + تعذّر التراجع عن الإصلاح + لا يوجد سجل لتطبيق هذا الإصلاح. ربما تم التراجع عنه بالفعل. + تم التراجع عن الإصلاح جزئيًا + تعذّرت استعادة {0} من الملفات. أغلق اللعبة وحاول مرة أخرى. diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index bf12f47..78134c0 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -603,4 +603,18 @@ В Lua няма ключ за дешифриране за depot {0}. Споделена среда за изпълнение — обикновено вече е инсталирана. Отметнете, за да я изтеглите. Да се премахнат ли всички {0} записа от историята на изтеглянията? Изтеглените файлове не се засягат. + Тази корекция вече е приложена. Върнете я, ако искате да инсталирате друга. + Моите игри + {0} от вашите игри имат корекции. Превъртете списъка за останалите. + Все още няма добавени lua файлове. Добавете (или инсталирайте) игра, за да се появи тук. + Връщане + Отказ + Да се върне ли „{0}“? Променените файлове ще бъдат възстановени от резервното копие, а добавените от корекцията файлове ще бъдат изтрити. + Да се върне ли корекцията за {0}? + Корекцията е върната + Възстановени {0}, премахнати {1} добавени файла. + Корекцията не можа да бъде върната + Няма запис тази корекция да е била прилагана. Може вече да е върната. + Корекцията е върната частично + {0} файл(а) не можаха да бъдат възстановени. Затворете играта и опитайте отново. diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 1163975..d0d411c 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -603,4 +603,18 @@ Zrušit - pokračovat ve stahování V Lua není dešifrovací klíč pro depot {0}. Sdílený runtime — obvykle již nainstalován. Zaškrtnutím jej přesto stáhnete. Odstranit všech {0} záznamů z historie stahování? Stažené soubory zůstanou zachovány. + Tato oprava je již použita. Vraťte ji zpět, pokud chcete nainstalovat jinou. + Moje hry + {0} z vašich her má opravy. Zbytek zobrazíte posunutím seznamu. + Zatím nebyly přidány žádné soubory lua. Přidejte (nebo nainstalujte) hru, aby se zde zobrazila. + Vrátit zpět + Zrušit + Vrátit zpět „{0}“? Změněné soubory budou obnoveny ze zálohy a soubory přidané opravou budou smazány. + Vrátit zpět opravu pro {0}? + Oprava vrácena zpět + Obnoveno {0}, odstraněno {1} přidaných souborů. + Opravu se nepodařilo vrátit zpět + O použití této opravy neexistuje záznam. Možná už byla vrácena zpět. + Oprava vrácena zpět částečně + {0} soubor(ů) se nepodařilo obnovit. Zavřete hru a zkuste to znovu. diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 57129a4..5b11030 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -603,4 +603,18 @@ Annuller - fortsæt download Ingen dekrypteringsnøgle i Lua til depot {0}. Delt runtime — normalt allerede installeret. Sæt flueben for at hente alligevel. Fjern alle {0} poster fra downloadhistorikken? Hentede filer påvirkes ikke. + Denne rettelse er allerede anvendt. Fortryd den, hvis du vil installere en anden. + Mine spil + {0} af dine spil har rettelser. Rul i listen for at se resten. + Der er endnu ikke tilføjet nogen lua-filer. Tilføj (eller installer) et spil for at se det her. + Fortryd + Annuller + Fortryd "{0}"? Ændrede filer gendannes fra sikkerhedskopien, og filer tilføjet af rettelsen slettes. + Fortryd rettelsen til {0}? + Rettelse fortrudt + {0} gendannet, {1} tilføjede filer fjernet. + Kunne ikke fortryde rettelsen + Ingen registrering af at denne rettelse er anvendt. Den er måske allerede fortrudt. + Rettelse delvist fortrudt + {0} fil(er) kunne ikke gendannes. Luk spillet og prøv igen. diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index 3054c0d..e37598a 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -603,4 +603,18 @@ Abbrechen - weiter herunterladen Kein Entschlüsselungsschlüssel im Lua für Depot {0}. Gemeinsame Laufzeit — meist bereits installiert. Zum Herunterladen trotzdem ankreuzen. Alle {0} Einträge aus dem Download-Verlauf entfernen? Heruntergeladene Dateien bleiben erhalten. + Dieser Fix ist bereits angewendet. Mache ihn rückgängig, um einen anderen zu installieren. + Meine Spiele + {0} deiner Spiele haben Fixes. Scrolle die Übersicht für den Rest. + Noch keine Luas hinzugefügt. Füge ein Spiel hinzu (oder installiere eines), um es hier zu sehen. + Rückgängig + Abbrechen + „{0}“ rückgängig machen? Geänderte Dateien werden aus dem Backup wiederhergestellt und vom Fix hinzugefügte Dateien gelöscht. + Fix für {0} rückgängig machen? + Fix rückgängig gemacht + {0} wiederhergestellt, {1} hinzugefügte Datei(en) entfernt. + Fix konnte nicht rückgängig gemacht werden + Kein Nachweis, dass dieser Fix angewendet wurde. Möglicherweise wurde er bereits rückgängig gemacht. + Fix teilweise rückgängig gemacht + {0} Datei(en) konnten nicht wiederhergestellt werden. Schließe das Spiel und versuche es erneut. diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index 4850027..f5fdabc 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -603,4 +603,18 @@ Δεν υπάρχει κλειδί αποκρυπτογράφησης στο Lua για το depot {0}. Κοινόχρηστο runtime — συνήθως ήδη εγκατεστημένο. Επιλέξτε για λήψη ούτως ή άλλως. Να αφαιρεθούν και οι {0} καταχωρίσεις από το ιστορικό λήψεων; Τα ληφθέντα αρχεία δεν επηρεάζονται. + Αυτή η διόρθωση έχει ήδη εφαρμοστεί. Αναιρέστε την αν θέλετε να εγκαταστήσετε άλλη. + Τα παιχνίδια μου + {0} από τα παιχνίδια σας έχουν διορθώσεις. Κυλήστε τη λίστα για τα υπόλοιπα. + Δεν έχετε προσθέσει ακόμη lua. Προσθέστε (ή εγκαταστήστε) ένα παιχνίδι για να εμφανιστεί εδώ. + Αναίρεση + Άκυρο + Αναίρεση του «{0}»; Τα τροποποιημένα αρχεία θα επαναφερθούν από το αντίγραφο ασφαλείας και τα αρχεία που πρόσθεσε η διόρθωση θα διαγραφούν. + Αναίρεση της διόρθωσης για {0}; + Η διόρθωση αναιρέθηκε + Επαναφέρθηκαν {0}, αφαιρέθηκαν {1} πρόσθετα αρχεία. + Δεν ήταν δυνατή η αναίρεση της διόρθωσης + Δεν υπάρχει καταγραφή ότι εφαρμόστηκε αυτή η διόρθωση. Ίσως έχει ήδη αναιρεθεί. + Η διόρθωση αναιρέθηκε μερικώς + Δεν ήταν δυνατή η επαναφορά {0} αρχείων. Κλείστε το παιχνίδι και δοκιμάστε ξανά. diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index 9f24190..c4aea00 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -603,4 +603,18 @@ Cancelar: seguir descargando No hay clave de descifrado en el Lua para el depot {0}. Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. + Este parche ya está aplicado. Revertilo si querés instalar otro. + Mis juegos + {0} de tus juegos tienen parches. Desplazá la grilla para ver el resto. + Todavía no agregaste ningún lua. Agregá (o instalá) un juego para verlo acá. + Revertir + Cancelar + ¿Revertir "{0}"? Los archivos modificados se restaurarán desde la copia de seguridad y los archivos agregados por el parche se eliminarán. + ¿Revertir el parche de {0}? + Parche revertido + Se restauraron {0} y se eliminaron {1} archivo(s) agregado(s). + No se pudo revertir el parche + No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. + Parche revertido parcialmente + No se pudieron restaurar {0} archivo(s). Cerrá el juego y probá de nuevo. diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index 98f667a..b03e03f 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -603,4 +603,18 @@ Peruuta - jatka lataamista Lua-tiedostossa ei ole salauksenpurkuavainta depotille {0}. Jaettu suoritusympäristö — yleensä jo asennettu. Valitse ladataksesi silti. Poistetaanko kaikki {0} merkintää lataushistoriasta? Ladattuihin tiedostoihin tämä ei vaikuta. + Tämä korjaus on jo käytössä. Peru se, jos haluat asentaa toisen. + Omat pelit + {0} pelilläsi on korjauksia. Vieritä luetteloa nähdäksesi loput. + Lua-tiedostoja ei ole vielä lisätty. Lisää (tai asenna) peli, niin se näkyy tässä. + Peru + Peruuta + Perutaanko ”{0}”? Muokatut tiedostot palautetaan varmuuskopiosta ja korjauksen lisäämät tiedostot poistetaan. + Perutaanko pelin {0} korjaus? + Korjaus peruttu + {0} palautettu, {1} lisättyä tiedostoa poistettu. + Korjausta ei voitu perua + Ei merkintää siitä, että tämä korjaus olisi käytössä. Se on ehkä jo peruttu. + Korjaus peruttu osittain + {0} tiedostoa ei voitu palauttaa. Sulje peli ja yritä uudelleen. diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 9bd3003..660ef1b 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -603,4 +603,18 @@ Annuler - continuer le téléchargement Aucune clé de déchiffrement dans le Lua pour le depot {0}. Runtime partagé — généralement déjà installé. Cochez pour le télécharger quand même. Supprimer les {0} entrées de l'historique des téléchargements ? Les fichiers téléchargés ne sont pas affectés. + Ce correctif est déjà appliqué. Annulez-le pour en installer un autre. + Mes jeux + {0} de vos jeux ont des correctifs. Faites défiler la grille pour voir les autres. + Aucun lua ajouté pour l'instant. Ajoutez (ou installez) un jeu pour le voir ici. + Annuler le correctif + Annuler + Annuler « {0} » ? Les fichiers modifiés seront restaurés depuis la sauvegarde et les fichiers ajoutés par le correctif seront supprimés. + Annuler le correctif de {0} ? + Correctif annulé + {0} restauré(s), {1} fichier(s) ajouté(s) supprimé(s). + Impossible d'annuler le correctif + Aucune trace de l'application de ce correctif. Il a peut-être déjà été annulé. + Correctif partiellement annulé + Impossible de restaurer {0} fichier(s). Fermez le jeu et réessayez. diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index 184a58e..98a33de 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -603,4 +603,18 @@ Mégse - letöltés folytatása A Lua nem tartalmaz visszafejtő kulcsot a(z) {0} depot-hoz. Megosztott futtatókörnyezet — általában már telepítve van. Jelölje be, ha mégis letöltené. Eltávolítja mind a(z) {0} bejegyzést a letöltési előzményekből? A letöltött fájlokat ez nem érinti. + Ez a javítás már alkalmazva van. Vond vissza, ha másikat szeretnél telepíteni. + Saját játékok + {0} játékodhoz van javítás. A többiért görgesd a listát. + Még nincs hozzáadott lua. Adj hozzá (vagy telepíts) egy játékot, hogy itt megjelenjen. + Visszavonás + Mégse + Visszavonod a(z) „{0}” javítást? A módosított fájlok visszaállnak a biztonsági másolatból, a javítás által hozzáadott fájlok pedig törlődnek. + Visszavonod a(z) {0} javítását? + Javítás visszavonva + {0} visszaállítva, {1} hozzáadott fájl eltávolítva. + A javítást nem sikerült visszavonni + Nincs nyoma annak, hogy ezt a javítást alkalmazták volna. Lehet, hogy már visszavonták. + A javítás részben lett visszavonva + {0} fájlt nem sikerült visszaállítani. Zárd be a játékot, és próbáld újra. diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index 2bff851..5fe0c46 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -603,4 +603,18 @@ Batal - lanjutkan mengunduh Tidak ada kunci dekripsi di Lua untuk depot {0}. Runtime bersama — biasanya sudah terpasang. Centang untuk tetap mengunduh. Hapus semua {0} entri dari riwayat unduhan? Berkas yang sudah diunduh tidak terpengaruh. + Perbaikan ini sudah diterapkan. Kembalikan dulu jika ingin memasang yang lain. + Game saya + {0} game Anda memiliki perbaikan. Gulir daftar untuk melihat sisanya. + Belum ada lua yang ditambahkan. Tambahkan (atau pasang) sebuah game agar muncul di sini. + Kembalikan + Batal + Kembalikan "{0}"? Berkas yang diubah akan dipulihkan dari cadangan dan berkas yang ditambahkan perbaikan akan dihapus. + Kembalikan perbaikan untuk {0}? + Perbaikan dikembalikan + {0} dipulihkan, {1} berkas tambahan dihapus. + Tidak dapat mengembalikan perbaikan + Tidak ada catatan perbaikan ini pernah diterapkan. Mungkin sudah dikembalikan. + Perbaikan dikembalikan sebagian + {0} berkas tidak dapat dipulihkan. Tutup game lalu coba lagi. diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index 3d89b23..ba5f128 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -603,4 +603,18 @@ Annulla - continua a scaricare Nessuna chiave di decrittazione nel Lua per il depot {0}. Runtime condiviso — di solito già installato. Spunta per scaricarlo comunque. Rimuovere tutte le {0} voci dalla cronologia dei download? I file scaricati non vengono toccati. + Questa fix è già applicata. Annullala se vuoi installarne un'altra. + I miei giochi + {0} dei tuoi giochi hanno fix. Scorri la griglia per vedere gli altri. + Nessun lua aggiunto finora. Aggiungi (o installa) un gioco per vederlo qui. + Annulla fix + Annulla + Annullare "{0}"? I file modificati verranno ripristinati dal backup e i file aggiunti dalla fix verranno eliminati. + Annullare la fix di {0}? + Fix annullata + {0} ripristinati, {1} file aggiunti rimossi. + Impossibile annullare la fix + Nessuna traccia dell'applicazione di questa fix. Potrebbe essere già stata annullata. + Fix annullata parzialmente + Impossibile ripristinare {0} file. Chiudi il gioco e riprova. diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index f59f34d..0ac69d5 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -603,4 +603,18 @@ Steam\config\stplug-in から .lua ファイルを削除します。Lua に depot {0} の復号キーがありません。 共有ランタイム — 通常はインストール済みです。必要ならチェックしてください。 ダウンロード履歴から {0} 件すべてを削除しますか?ダウンロード済みのファイルには影響しません。 + この修正は適用済みです。別のものを入れるには元に戻してください。 + マイゲーム + あなたのゲームのうち {0} 本に修正があります。ほかはグリッドをスクロールしてご覧ください。 + lua がまだありません。ゲームを追加(またはインストール)すると表示されます。 + 元に戻す + キャンセル + 「{0}」を元に戻しますか?変更されたファイルはバックアップから復元され、修正が追加したファイルは削除されます。 + {0} の修正を元に戻しますか? + 修正を元に戻しました + {0} 件を復元し、追加された {1} 件を削除しました。 + 修正を元に戻せませんでした + この修正を適用した記録がありません。すでに元に戻されている可能性があります。 + 修正は一部だけ元に戻りました + {0} 個のファイルを復元できませんでした。ゲームを閉じてやり直してください。 diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index a2294a5..01ffde4 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -603,4 +603,18 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. Lua에 depot {0}의 복호화 키가 없습니다. 공유 런타임 — 대개 이미 설치되어 있습니다. 그래도 받으려면 선택하세요. 다운로드 기록에서 {0}개 항목을 모두 제거할까요? 내려받은 파일은 영향을 받지 않습니다. + 이 수정은 이미 적용되어 있습니다. 다른 것을 설치하려면 되돌리세요. + 내 게임 + 보유한 게임 중 {0}개에 수정이 있습니다. 나머지는 목록을 스크롤해 확인하세요. + 아직 추가된 lua가 없습니다. 게임을 추가하거나 설치하면 여기에 표시됩니다. + 되돌리기 + 취소 + "{0}"을(를) 되돌릴까요? 변경된 파일은 백업에서 복원되고 수정이 추가한 파일은 삭제됩니다. + {0}의 수정을 되돌릴까요? + 수정을 되돌렸습니다 + {0}개 복원, 추가된 {1}개 삭제됨. + 수정을 되돌리지 못했습니다 + 이 수정이 적용된 기록이 없습니다. 이미 되돌렸을 수 있습니다. + 수정이 일부만 되돌려졌습니다 + {0}개 파일을 복원하지 못했습니다. 게임을 닫고 다시 시도하세요. diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 5c61f94..d13589f 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -603,4 +603,18 @@ Avbryt - fortsett nedlastingen Ingen dekrypteringsnøkkel i Lua for depot {0}. Delt kjøretid — vanligvis allerede installert. Huk av for å laste ned likevel. Fjerne alle {0} oppføringer fra nedlastingsloggen? Nedlastede filer påvirkes ikke. + Denne rettelsen er allerede brukt. Angre den hvis du vil installere en annen. + Mine spill + {0} av spillene dine har rettelser. Bla i listen for å se resten. + Ingen lua-filer lagt til ennå. Legg til (eller installer) et spill for å se det her. + Angre + Avbryt + Angre «{0}»? Endrede filer gjenopprettes fra sikkerhetskopien, og filer lagt til av rettelsen slettes. + Angre rettelsen for {0}? + Rettelse angret + {0} gjenopprettet, {1} tillagte filer fjernet. + Kunne ikke angre rettelsen + Ingen registrering av at denne rettelsen er brukt. Den kan allerede være angret. + Rettelse delvis angret + {0} fil(er) kunne ikke gjenopprettes. Lukk spillet og prøv igjen. diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index 1821279..d89510b 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -603,4 +603,18 @@ Annuleren - doorgaan met downloaden Geen ontsleutelingssleutel in de Lua voor depot {0}. Gedeelde runtime — meestal al geïnstalleerd. Vink aan om toch te downloaden. Alle {0} items uit de downloadgeschiedenis verwijderen? Gedownloade bestanden blijven staan. + Deze fix is al toegepast. Draai hem terug als je een andere wilt installeren. + Mijn games + {0} van je games hebben fixes. Scroll door het overzicht voor de rest. + Nog geen lua's toegevoegd. Voeg een game toe (of installeer er een) om hem hier te zien. + Terugdraaien + Annuleren + "{0}" terugdraaien? Gewijzigde bestanden worden hersteld vanaf de back-up en bestanden die de fix heeft toegevoegd worden verwijderd. + Fix voor {0} terugdraaien? + Fix teruggedraaid + {0} hersteld, {1} toegevoegde bestand(en) verwijderd. + Kan fix niet terugdraaien + Geen registratie dat deze fix is toegepast. Mogelijk is hij al teruggedraaid. + Fix gedeeltelijk teruggedraaid + {0} bestand(en) konden niet worden hersteld. Sluit de game en probeer opnieuw. diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index cfd84fa..7387103 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -603,4 +603,18 @@ Anuluj - kontynuuj pobieranie Brak klucza deszyfrującego w Lua dla depotu {0}. Współdzielone środowisko — zwykle już zainstalowane. Zaznacz, aby i tak pobrać. Usunąć wszystkie {0} wpisy z historii pobierania? Pobrane pliki pozostaną nienaruszone. + Ta poprawka jest już zastosowana. Cofnij ją, aby zainstalować inną. + Moje gry + {0} z twoich gier ma poprawki. Przewiń listę, aby zobaczyć resztę. + Nie dodano jeszcze żadnych plików lua. Dodaj (lub zainstaluj) grę, aby ją tu zobaczyć. + Cofnij + Anuluj + Cofnąć „{0}”? Zmodyfikowane pliki zostaną przywrócone z kopii zapasowej, a pliki dodane przez poprawkę usunięte. + Cofnąć poprawkę dla {0}? + Poprawka cofnięta + Przywrócono {0}, usunięto {1} dodanych plików. + Nie udało się cofnąć poprawki + Brak zapisu o zastosowaniu tej poprawki. Mogła już zostać cofnięta. + Poprawka cofnięta częściowo + Nie udało się przywrócić {0} plików. Zamknij grę i spróbuj ponownie. diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index c5801cd..439e707 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -603,4 +603,18 @@ Cancelar - continuar baixando Sem chave de descriptografia no Lua para o depot {0}. Runtime compartilhado — normalmente já instalado. Marque para baixar mesmo assim. Remover todas as {0} entradas do histórico de downloads? Os arquivos baixados não são afetados. + Esta correção já está aplicada. Reverta se quiser instalar outra. + Meus jogos + {0} dos seus jogos têm correções. Role a grade para ver o restante. + Nenhum lua adicionado ainda. Adicione (ou instale) um jogo para vê-lo aqui. + Reverter + Cancelar + Reverter "{0}"? Os arquivos modificados serão restaurados do backup e os arquivos adicionados pela correção serão excluídos. + Reverter a correção de {0}? + Correção revertida + {0} restaurado(s), {1} arquivo(s) adicionado(s) removido(s). + Não foi possível reverter a correção + Não há registro desta correção ter sido aplicada. Pode já ter sido revertida. + Correção revertida parcialmente + Não foi possível restaurar {0} arquivo(s). Feche o jogo e tente novamente. diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index 0835d98..3c403e3 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -603,4 +603,18 @@ Cancelar - continuar a transferir Sem chave de desencriptação no Lua para o depot {0}. Runtime partilhado — normalmente já instalado. Marque para transferir na mesma. Remover todas as {0} entradas do histórico de transferências? Os ficheiros transferidos não são afetados. + Esta correção já está aplicada. Reverta se quiser instalar outra. + Os meus jogos + {0} dos seus jogos têm correções. Desloque a grelha para ver os restantes. + Ainda não adicionou nenhum lua. Adicione (ou instale) um jogo para o ver aqui. + Reverter + Cancelar + Reverter "{0}"? Os ficheiros modificados serão restaurados a partir da cópia de segurança e os ficheiros adicionados pela correção serão eliminados. + Reverter a correção de {0}? + Correção revertida + {0} restaurado(s), {1} ficheiro(s) adicionado(s) removido(s). + Não foi possível reverter a correção + Não há registo de esta correção ter sido aplicada. Pode já ter sido revertida. + Correção revertida parcialmente + Não foi possível restaurar {0} ficheiro(s). Feche o jogo e tente novamente. diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 5c8d6c8..4661e26 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -603,4 +603,18 @@ Anulare - continuă descărcarea Nu există cheie de decriptare în Lua pentru depot {0}. Runtime partajat — de obicei deja instalat. Bifează pentru a-l descărca oricum. Elimini toate cele {0} intrări din istoricul descărcărilor? Fișierele descărcate nu sunt afectate. + Această reparație este deja aplicată. Anuleaz-o dacă vrei să instalezi alta. + Jocurile mele + {0} dintre jocurile tale au reparații. Derulează lista pentru restul. + Încă nu ai adăugat niciun lua. Adaugă (sau instalează) un joc ca să apară aici. + Anulează reparația + Renunță + Anulezi „{0}”? Fișierele modificate vor fi restaurate din copia de rezervă, iar fișierele adăugate de reparație vor fi șterse. + Anulezi reparația pentru {0}? + Reparație anulată + {0} restaurate, {1} fișiere adăugate eliminate. + Reparația nu a putut fi anulată + Nu există nicio înregistrare că această reparație a fost aplicată. Poate a fost deja anulată. + Reparație anulată parțial + {0} fișier(e) nu au putut fi restaurate. Închide jocul și încearcă din nou. diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index e43eb9c..53c447b 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -603,4 +603,18 @@ В Lua нет ключа расшифровки для depot {0}. Общая среда выполнения — обычно уже установлена. Отметьте, чтобы всё равно скачать. Удалить все записи ({0}) из истории загрузок? Загруженные файлы не затрагиваются. + Это исправление уже применено. Откатите его, чтобы установить другое. + Мои игры + Исправления есть для {0} ваших игр. Прокрутите список, чтобы увидеть остальные. + Файлы lua ещё не добавлены. Добавьте (или установите) игру, чтобы увидеть её здесь. + Откатить + Отмена + Откатить «{0}»? Изменённые файлы будут восстановлены из резервной копии, а добавленные исправлением файлы удалены. + Откатить исправление для {0}? + Исправление откачено + Восстановлено {0}, удалено добавленных файлов: {1}. + Не удалось откатить исправление + Нет записи о применении этого исправления. Возможно, его уже откатили. + Исправление откачено частично + Не удалось восстановить файлов: {0}. Закройте игру и повторите попытку. diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index 4ea7e47..20b47bb 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -603,4 +603,18 @@ Avbryt - fortsätt hämta Ingen dekrypteringsnyckel i Lua för depot {0}. Delad körtid — vanligtvis redan installerad. Kryssa i för att hämta ändå. Ta bort alla {0} poster från hämtningshistoriken? Hämtade filer påverkas inte. + Den här korrigeringen är redan tillämpad. Ångra den om du vill installera en annan. + Mina spel + {0} av dina spel har korrigeringar. Bläddra i listan för att se resten. + Inga lua-filer tillagda ännu. Lägg till (eller installera) ett spel för att se det här. + Ångra + Avbryt + Ångra ”{0}”? Ändrade filer återställs från säkerhetskopian och filer som korrigeringen lagt till tas bort. + Ångra korrigeringen för {0}? + Korrigering ångrad + {0} återställda, {1} tillagda filer borttagna. + Kunde inte ångra korrigeringen + Ingen notering om att den här korrigeringen tillämpats. Den kan redan vara ångrad. + Korrigering delvis ångrad + {0} fil(er) kunde inte återställas. Stäng spelet och försök igen. diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index f45496a..0dc3247 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -603,4 +603,18 @@ ไม่มีคีย์ถอดรหัสใน Lua สำหรับ depot {0} รันไทม์ที่ใช้ร่วมกัน — ปกติติดตั้งไว้แล้ว ติ๊กเพื่อดาวน์โหลดอยู่ดี ต้องการลบรายการทั้งหมด {0} รายการออกจากประวัติการดาวน์โหลดหรือไม่ ไฟล์ที่ดาวน์โหลดแล้วจะไม่ได้รับผลกระทบ + แก้ไขนี้ถูกใช้งานอยู่แล้ว ย้อนกลับก่อนหากต้องการติดตั้งอันอื่น + เกมของฉัน + เกมของคุณ {0} เกมมีตัวแก้ไข เลื่อนรายการเพื่อดูเกมที่เหลือ + ยังไม่ได้เพิ่ม lua เพิ่ม (หรือติดตั้ง) เกมเพื่อให้แสดงที่นี่ + ย้อนกลับ + ยกเลิก + ย้อนกลับ "{0}" หรือไม่ ไฟล์ที่ถูกแก้ไขจะถูกกู้คืนจากข้อมูลสำรอง และไฟล์ที่ตัวแก้ไขเพิ่มไว้จะถูกลบ + ย้อนกลับตัวแก้ไขของ {0} หรือไม่ + ย้อนกลับตัวแก้ไขแล้ว + กู้คืน {0} รายการ ลบไฟล์ที่เพิ่ม {1} รายการ + ย้อนกลับตัวแก้ไขไม่สำเร็จ + ไม่พบบันทึกว่าเคยใช้ตัวแก้ไขนี้ อาจถูกย้อนกลับไปแล้ว + ย้อนกลับตัวแก้ไขได้บางส่วน + กู้คืนไฟล์ {0} รายการไม่สำเร็จ กรุณาปิดเกมแล้วลองใหม่ diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index bfeb407..372e100 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -603,4 +603,18 @@ Hayır - durdur ama dosyaları koru Lua'da {0} depot'u için şifre çözme anahtarı yok. Paylaşılan çalışma zamanı — genelde zaten kurulu. Yine de indirmek için işaretleyin. İndirme geçmişindeki {0} kaydın tümü kaldırılsın mı? İndirilen dosyalar etkilenmez. + Bu düzeltme zaten uygulanmış. Başka birini kurmak için geri alın. + Oyunlarım + Oyunlarınızdan {0} tanesinde düzeltme var. Kalanları görmek için listeyi kaydırın. + Henüz lua eklenmedi. Burada görmek için bir oyun ekleyin (veya kurun). + Geri al + İptal + "{0}" geri alınsın mı? Değiştirilen dosyalar yedekten geri yüklenecek, düzeltmenin eklediği dosyalar silinecek. + {0} için düzeltme geri alınsın mı? + Düzeltme geri alındı + {0} geri yüklendi, eklenen {1} dosya kaldırıldı. + Düzeltme geri alınamadı + Bu düzeltmenin uygulandığına dair kayıt yok. Zaten geri alınmış olabilir. + Düzeltme kısmen geri alındı + {0} dosya geri yüklenemedi. Oyunu kapatıp tekrar deneyin. diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 8e9a528..d246968 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -603,4 +603,18 @@ У Lua немає ключа розшифрування для depot {0}. Спільне середовище виконання — зазвичай уже встановлене. Позначте, щоб усе одно завантажити. Вилучити всі записи ({0}) з історії завантажень? Завантажені файли не зачіпаються. + Це виправлення вже застосовано. Відкотіть його, щоб встановити інше. + Мої ігри + Виправлення є для {0} ваших ігор. Прогорніть список, щоб побачити решту. + Файли lua ще не додано. Додайте (або встановіть) гру, щоб побачити її тут. + Відкотити + Скасувати + Відкотити «{0}»? Змінені файли буде відновлено з резервної копії, а додані виправленням файли видалено. + Відкотити виправлення для {0}? + Виправлення відкочено + Відновлено {0}, вилучено доданих файлів: {1}. + Не вдалося відкотити виправлення + Немає запису про застосування цього виправлення. Можливо, його вже відкотили. + Виправлення відкочено частково + Не вдалося відновити файлів: {0}. Закрийте гру та спробуйте ще раз. diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index e8ce5f5..198f594 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -603,4 +603,18 @@ Hủy - tiếp tục tải Không có khóa giải mã trong Lua cho depot {0}. Runtime dùng chung — thường đã được cài. Đánh dấu để vẫn tải xuống. Xóa tất cả {0} mục khỏi lịch sử tải xuống? Các tệp đã tải không bị ảnh hưởng. + Bản sửa lỗi này đã được áp dụng. Hoàn tác nếu bạn muốn cài bản khác. + Trò chơi của tôi + {0} trò chơi của bạn có bản sửa lỗi. Cuộn danh sách để xem phần còn lại. + Chưa thêm lua nào. Thêm (hoặc cài đặt) một trò chơi để thấy nó ở đây. + Hoàn tác + Hủy + Hoàn tác "{0}"? Các tệp đã sửa đổi sẽ được khôi phục từ bản sao lưu và các tệp do bản sửa lỗi thêm vào sẽ bị xóa. + Hoàn tác bản sửa lỗi cho {0}? + Đã hoàn tác bản sửa lỗi + Đã khôi phục {0}, đã xóa {1} tệp được thêm. + Không thể hoàn tác bản sửa lỗi + Không có ghi nhận nào về việc áp dụng bản sửa lỗi này. Có thể nó đã được hoàn tác. + Bản sửa lỗi được hoàn tác một phần + Không thể khôi phục {0} tệp. Hãy đóng trò chơi và thử lại. diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index 39890c0..489a3eb 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -623,4 +623,18 @@ Lua 中没有 depot {0} 的解密密钥。 共享运行库 — 通常已安装。如仍需下载请勾选。 要从下载历史中移除全部 {0} 条记录吗?已下载的文件不会受影响。 + 此修复已应用。若要安装其他修复,请先还原。 + 我的游戏 + 你的 {0} 款游戏有可用修复。滚动列表可浏览其余内容。 + 尚未添加 lua,添加或安装一款游戏后会显示在这里。 + 还原 + 取消 + 要还原“{0}”吗?修改过的文件将从备份恢复,修复添加的文件将被删除。 + 还原 {0} 的修复? + 修复已还原 + 已恢复 {0} 个,已删除 {1} 个新增文件。 + 无法还原修复 + 没有应用此修复的记录,可能已经还原过了。 + 修复部分还原 + 有 {0} 个文件无法恢复。请关闭游戏后重试。 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 0d70526..cff5139 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -603,4 +603,18 @@ Lua 中沒有 depot {0} 的解密金鑰。 共用執行階段 — 通常已安裝。如仍需下載請勾選。 要從下載紀錄中移除全部 {0} 筆記錄嗎?已下載的檔案不會受影響。 + 此修復已套用。若要安裝其他修復,請先還原。 + 我的遊戲 + 你的 {0} 款遊戲有可用修復。捲動清單可瀏覽其餘內容。 + 尚未新增 lua,新增或安裝一款遊戲後會顯示在這裡。 + 還原 + 取消 + 要還原「{0}」嗎?修改過的檔案將從備份復原,修復新增的檔案將被刪除。 + 還原 {0} 的修復? + 修復已還原 + 已復原 {0} 個,已刪除 {1} 個新增檔案。 + 無法還原修復 + 沒有套用此修復的紀錄,可能已經還原過了。 + 修復部分還原 + 有 {0} 個檔案無法復原。請關閉遊戲後重試。 diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index cedc86b..0379265 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -593,15 +593,25 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId, foreach (var entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; - string dest = Path.Combine(installDir, entry.FullName); string relPath = entry.FullName.Replace('\\', '/'); + // A zip entry naming its way out of the game folder is never legitimate; skip it and + // count it, rather than writing wherever it points. + if (ResolveInside(installDir, entry.FullName) is not { } dest) { failed++; continue; } + try { if (File.Exists(dest)) { - // Back up the original — never clobber a known-good .bak - string bakRel = $"{fixKey}/{Path.GetFileName(dest)}.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); if (!File.Exists(bakAbs)) { @@ -679,6 +689,30 @@ internal static string SafeFixKey(string fixId) => internal static string GetFixManifestPath(string installDir, string fixId) => Path.Combine(installDir, FixManifestDir, $"{SafeFixKey(fixId)}.json"); + /// + /// Resolve a relative path against the game folder, or null when it would escape it. + /// + /// + /// Both callers take the relative part from data we do not control: zip entry names on apply, and the + /// revert manifest on revert. `Path.Combine(installDir, "../../../Windows/System32/x.dll")` happily + /// resolves outside the game — the classic zip-slip shape — and the revert path DELETES what it + /// resolves. The manifest also lives in a user-writable folder, and a fix archive can plant one for a + /// different fix key that we would not overwrite. So neither side is trusted; both go through here. + /// Same containment check as DepotDownloaderService.TryDeleteCreatedFiles. + /// + private static string? ResolveInside(string root, string relative) + { + try + { + string rootFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string full = Path.GetFullPath(Path.Combine(rootFull, relative)); + return full.StartsWith(rootFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? full + : null; + } + catch { return null; } // malformed path (illegal chars, too long): treat as out of bounds + } + /// Read a fix manifest from disk, or null if it doesn't exist. internal static DenuvoFixManifest? ReadFixManifest(string manifestPath) { @@ -712,12 +746,16 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) foreach (var entry in manifest.Files) { - string dest = Path.Combine(installDir, entry.RelativePath); + // The manifest sits in a user-writable folder and a fix archive can plant one, so its + // paths get the same containment check the extract does. This branch DELETES. + if (ResolveInside(installDir, entry.RelativePath) is not { } dest) { errors++; continue; } + try { if (entry.Action == "modified" && entry.BackupPath is { } bakRel) { - string bakAbs = Path.Combine(installDir, FixManifestDir, bakRel); + if (ResolveInside(Path.Combine(installDir, FixManifestDir), bakRel) + is not { } bakAbs) { errors++; continue; } if (File.Exists(bakAbs)) { File.Copy(bakAbs, dest, overwrite: true); @@ -737,18 +775,24 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) 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) { + // Backups and manifest stay put ON PURPOSE. The usual reason a revert fails is a locked + // file because the game is running, and deleting the .bak files here would leave the user + // half-reverted with no way to ever finish. Keeping them means the revert is simply + // retryable once the game is closed. Same principle the apply path already follows: it + // writes the manifest even on partial failure so the backups stay recoverable. 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); } + // Fully reverted, so the backups have served their purpose and the manifest is what makes the + // Revert button appear — both go, and the fix reads as un-applied again. + 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 { } + 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); From 4d44df14848738ae0b28be54d319dc7117854add Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Wed, 9 Sep 2026 21:07:58 +1000 Subject: [PATCH 26/29] fixes hashes and manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce robust applied-fix tooling and a one-time depotcache migration. Added AppliedFixIndexService (applied-fixes.json), DepotCacheMigrationService (moves manifests from config\depotcache → depotcache), and FileHash (SHA-256 helpers). Reworked Denuvo fix handling: rename "manifest"→"record", four-phase apply (plan/commit/apply/settle), per-file hashes, safer backups, and conflict detection on revert. Updated ManifestJobFactory, DepotDownloaderService, SteamService, SteamLibraryService, FixesViewModel, and resources (new revert/localization keys). Register services and run migration at startup. Added unit tests for migration and file-hash behaviour. Co-Authored-By: Brandon Hernandez <142270679+Brandher58@users.noreply.github.com> --- src/LuaToolsGui/App.xaml.cs | 6 + src/LuaToolsGui/Models/FixModels.cs | 65 ++++- src/LuaToolsGui/Resources/Strings.Designer.cs | 4 +- src/LuaToolsGui/Resources/Strings.ar.resx | 4 +- src/LuaToolsGui/Resources/Strings.bg.resx | 4 +- src/LuaToolsGui/Resources/Strings.cs.resx | 4 +- src/LuaToolsGui/Resources/Strings.da.resx | 4 +- src/LuaToolsGui/Resources/Strings.de.resx | 4 +- src/LuaToolsGui/Resources/Strings.el.resx | 4 +- src/LuaToolsGui/Resources/Strings.es-419.resx | 4 +- src/LuaToolsGui/Resources/Strings.es.resx | 4 +- src/LuaToolsGui/Resources/Strings.fi.resx | 4 +- src/LuaToolsGui/Resources/Strings.fr.resx | 4 +- src/LuaToolsGui/Resources/Strings.hu.resx | 4 +- src/LuaToolsGui/Resources/Strings.id.resx | 4 +- src/LuaToolsGui/Resources/Strings.it.resx | 4 +- src/LuaToolsGui/Resources/Strings.ja.resx | 4 +- src/LuaToolsGui/Resources/Strings.ko.resx | 4 +- src/LuaToolsGui/Resources/Strings.nb.resx | 4 +- src/LuaToolsGui/Resources/Strings.nl.resx | 4 +- src/LuaToolsGui/Resources/Strings.pl.resx | 4 +- src/LuaToolsGui/Resources/Strings.pt-BR.resx | 4 +- src/LuaToolsGui/Resources/Strings.pt-PT.resx | 4 +- src/LuaToolsGui/Resources/Strings.resx | 4 +- src/LuaToolsGui/Resources/Strings.ro.resx | 4 +- src/LuaToolsGui/Resources/Strings.ru.resx | 4 +- src/LuaToolsGui/Resources/Strings.sv.resx | 4 +- src/LuaToolsGui/Resources/Strings.th.resx | 4 +- src/LuaToolsGui/Resources/Strings.tr.resx | 4 +- src/LuaToolsGui/Resources/Strings.uk.resx | 4 +- src/LuaToolsGui/Resources/Strings.vi.resx | 4 +- .../Resources/Strings.zh-Hans.resx | 4 +- .../Resources/Strings.zh-Hant.resx | 4 +- .../Services/AppliedFixIndexService.cs | 205 ++++++++++++++ .../Services/DepotCacheMigrationService.cs | 170 ++++++++++++ .../Services/DepotDownloaderService.cs | 31 ++- .../Services/Downloads/ManifestJobFactory.cs | 258 +++++++++++++----- src/LuaToolsGui/Services/FileHash.cs | 46 ++++ src/LuaToolsGui/Services/LuaInstaller.cs | 5 +- src/LuaToolsGui/Services/ManifestFile.cs | 2 +- .../Services/SteamLibraryService.cs | 59 ++++ src/LuaToolsGui/Services/SteamService.cs | 26 +- src/LuaToolsGui/ViewModels/FixesViewModel.cs | 38 ++- .../DepotCacheMigrationTests.cs | 253 +++++++++++++++++ tests/LuaToolsGui.Tests/FileHashTests.cs | 113 ++++++++ 45 files changed, 1279 insertions(+), 122 deletions(-) create mode 100644 src/LuaToolsGui/Services/AppliedFixIndexService.cs create mode 100644 src/LuaToolsGui/Services/DepotCacheMigrationService.cs create mode 100644 src/LuaToolsGui/Services/FileHash.cs create mode 100644 tests/LuaToolsGui.Tests/DepotCacheMigrationTests.cs create mode 100644 tests/LuaToolsGui.Tests/FileHashTests.cs diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index 41374c1..f719494 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -43,6 +43,8 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); // one per page (Home, Add) @@ -480,6 +482,10 @@ unlocker.SelectedMode is (UnlockerMode.Ost or UnlockerMode.Bst) // Warm the hardware-appid blacklist (refreshes from GitHub if the cache is stale). Fire-and-forget. _ = _host.Services.GetRequiredService().EnsureFreshAsync(); + + // Rescue manifests this app used to write into config\depotcache, which Steam never reads. Silent, + // idempotent, and costs nothing once the folder is gone. See DepotCacheMigrationService. + _ = _host.Services.GetRequiredService().RunAsync(); } protected override async void OnExit(ExitEventArgs e) diff --git a/src/LuaToolsGui/Models/FixModels.cs b/src/LuaToolsGui/Models/FixModels.cs index 621b49d..43a14eb 100644 --- a/src/LuaToolsGui/Models/FixModels.cs +++ b/src/LuaToolsGui/Models/FixModels.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace LuaToolsGui.Models; @@ -57,19 +57,74 @@ public class DenuvoDownloadResponse [JsonPropertyName("url")] public string Url { get; set; } = ""; } -// ── Fix revert manifest (written to .luatools-fix/ inside the game folder) ── +// ── Fix revert record (written to .luatools-fix/ inside the game folder) ── +// +// Deliberately NOT called a manifest: in this app that word means a Steam depot manifest (depotcache, +// ResolveManifestPath, the Fixes page's own Manifest button), and having a second meaning made people +// read "no record of this fix" as "your depot manifests are gone". The JsonPropertyName values below +// are the on-disk contract and must not change with the type names. -public class DenuvoFixManifest +/// +/// What one applied fix changed, written into the game's own folder so it travels with the install. +/// +/// +/// The per-file hashes are what make several fixes on one game safe. Fix B applied over fix A backs up +/// A's file, so the backups CHAIN and must be unwound newest-first. Reverting A first would restore the +/// pre-A original over B's file and leave the game with a mix neither fix expects. Rather than police +/// the order, each entry records the hash of what the fix wrote: if the file on disk no longer matches, +/// something replaced it after us and the revert stops instead of clobbering it. That catches the +/// out-of-order case, and also game updates and hand-edits, which no ordering rule would. +/// +/// Older records predate the hashes and leave them null. Those revert exactly as they always did — an +/// absent hash means "cannot verify", never "verification failed". +/// +public class DenuvoFixRecord { [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; } = []; + [JsonPropertyName("files")] public List Files { get; set; } = []; } -public class DenuvoFixManifestEntry +public class DenuvoFixRecordEntry { [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/ + + /// + /// SHA-256 of the file as the fix left it. Lets a revert prove the file on disk is still the one this + /// fix wrote before touching it — see DenuvoFixRecord for why that matters. + /// + [JsonPropertyName("hashAfter")] public string? HashAfter { get; set; } + + /// + /// SHA-256 of the original, pre-fix file. Null for "added" entries (there was no original). + /// Used to verify a restored backup really is the original and not a corrupt copy. + /// + [JsonPropertyName("hashBefore")] public string? HashBefore { get; set; } +} + +// ── Applied-fix index (%AppData%\LuaToolsGui\applied-fixes.json) ── +// +// A HINT, never the truth. The per-game .luatools-fix/.json inside the install folder is +// authoritative; this is one flat list so "what fixes are applied?" doesn't have to walk every game +// folder on every library drive (measured at ~10s cold across three drives, versus reading one small +// file). Safe to delete: a missing or stale index rebuilds from a scan, and every entry is confirmed +// against its real record before being shown. It must never be the only place a fix is recorded, or +// deleting it would strand backups with nothing pointing at them. + +public class AppliedFixIndex +{ + [JsonPropertyName("entries")] public List Entries { get; set; } = []; +} + +public class AppliedFixIndexEntry +{ + [JsonPropertyName("appId")] public long AppId { get; set; } + [JsonPropertyName("fixId")] public string FixId { get; set; } = ""; + [JsonPropertyName("gameName")] public string GameName { get; set; } = ""; + [JsonPropertyName("installDir")] public string InstallDir { get; set; } = ""; + [JsonPropertyName("appliedAt")] public string AppliedAt { get; set; } = ""; + [JsonPropertyName("fileCount")] public int FileCount { get; set; } } diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index a6eaa14..51974f0 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -307,7 +307,9 @@ public static class Strings 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_Revert_NoRecord => Get(nameof(Fixes_Revert_NoRecord)); + public static string Fixes_Revert_Conflict => Get(nameof(Fixes_Revert_Conflict)); + public static string Fixes_Revert_Conflict_Body => Get(nameof(Fixes_Revert_Conflict_Body)); 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)); diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 65a5944..093b4f9 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -640,7 +640,9 @@ تم التراجع عن الإصلاح تمت استعادة {0} وإزالة {1} من الملفات المضافة. تعذّر التراجع عن الإصلاح - لا يوجد سجل لتطبيق هذا الإصلاح. ربما تم التراجع عنه بالفعل. + لا يوجد سجل لتطبيق هذا الإصلاح. ربما تم التراجع عنه بالفعل. + لا يمكن التراجع عن الإصلاح بأمان + تغيّر {0} منذ تطبيق هذا الإصلاح، لذا فإن استعادته ستستبدل تلك التغييرات. إذا طبّقت إصلاحًا آخر فوقه، فتراجع عنه أولاً. الملفات المتأثرة: {1}. تم التراجع عن الإصلاح جزئيًا تعذّرت استعادة {0} من الملفات. أغلق اللعبة وحاول مرة أخرى. diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 78134c0..8f59b22 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -614,7 +614,9 @@ Корекцията е върната Възстановени {0}, премахнати {1} добавени файла. Корекцията не можа да бъде върната - Няма запис тази корекция да е била прилагана. Може вече да е върната. + Няма запис тази корекция да е била прилагана. Може вече да е върната. + Поправката не може да се върне безопасно + {0} е променен, откакто беше приложена тази поправка, така че възстановяването ще презапише тези промени. Ако сте приложили друга поправка отгоре, първо върнете нея. Засегнати са {1} файла. Корекцията е върната частично {0} файл(а) не можаха да бъдат възстановени. Затворете играта и опитайте отново. diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index d0d411c..0647ca8 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -614,7 +614,9 @@ Zrušit - pokračovat ve stahování Oprava vrácena zpět Obnoveno {0}, odstraněno {1} přidaných souborů. Opravu se nepodařilo vrátit zpět - O použití této opravy neexistuje záznam. Možná už byla vrácena zpět. + O použití této opravy neexistuje záznam. Možná už byla vrácena zpět. + Opravu nelze bezpečně vrátit + Soubor {0} se od použití této opravy změnil, takže obnovení by tyto změny přepsalo. Pokud jste navrch použili jinou opravu, vraťte nejprve ji. Dotčeno {1} souborů. Oprava vrácena zpět částečně {0} soubor(ů) se nepodařilo obnovit. Zavřete hru a zkuste to znovu. diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 5b11030..35c7454 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -614,7 +614,9 @@ Annuller - fortsæt download Rettelse fortrudt {0} gendannet, {1} tilføjede filer fjernet. Kunne ikke fortryde rettelsen - Ingen registrering af at denne rettelse er anvendt. Den er måske allerede fortrudt. + Ingen registrering af at denne rettelse er anvendt. Den er måske allerede fortrudt. + Rettelsen kan ikke fortrydes sikkert + {0} er ændret, siden denne rettelse blev anvendt, så en gendannelse ville overskrive de ændringer. Hvis du har anvendt en anden rettelse ovenpå, så fortryd den først. {1} fil(er) berørt. Rettelse delvist fortrudt {0} fil(er) kunne ikke gendannes. Luk spillet og prøv igen. diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index e37598a..738ebdb 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -614,7 +614,9 @@ Abbrechen - weiter herunterladen Fix rückgängig gemacht {0} wiederhergestellt, {1} hinzugefügte Datei(en) entfernt. Fix konnte nicht rückgängig gemacht werden - Kein Nachweis, dass dieser Fix angewendet wurde. Möglicherweise wurde er bereits rückgängig gemacht. + Kein Nachweis, dass dieser Fix angewendet wurde. Möglicherweise wurde er bereits rückgängig gemacht. + Fix kann nicht sicher zurückgesetzt werden + {0} wurde seit dem Anwenden dieses Fixes geändert; eine Wiederherstellung würde diese Änderungen überschreiben. Falls du einen weiteren Fix darüber angewendet hast, setze zuerst diesen zurück. {1} Datei(en) betroffen. Fix teilweise rückgängig gemacht {0} Datei(en) konnten nicht wiederhergestellt werden. Schließe das Spiel und versuche es erneut. diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index f5fdabc..af801f6 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -614,7 +614,9 @@ Η διόρθωση αναιρέθηκε Επαναφέρθηκαν {0}, αφαιρέθηκαν {1} πρόσθετα αρχεία. Δεν ήταν δυνατή η αναίρεση της διόρθωσης - Δεν υπάρχει καταγραφή ότι εφαρμόστηκε αυτή η διόρθωση. Ίσως έχει ήδη αναιρεθεί. + Δεν υπάρχει καταγραφή ότι εφαρμόστηκε αυτή η διόρθωση. Ίσως έχει ήδη αναιρεθεί. + Δεν είναι δυνατή η ασφαλής επαναφορά της διόρθωσης + Το {0} έχει αλλάξει από τότε που εφαρμόστηκε αυτή η διόρθωση, οπότε η επαναφορά θα αντικαταστήσει αυτές τις αλλαγές. Αν εφαρμόσατε άλλη διόρθωση από πάνω, επαναφέρετε πρώτα εκείνη. Επηρεάζονται {1} αρχεία. Η διόρθωση αναιρέθηκε μερικώς Δεν ήταν δυνατή η επαναφορά {0} αρχείων. Κλείστε το παιχνίδι και δοκιμάστε ξανά. diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index c4aea00..658a93e 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -614,7 +614,9 @@ Cancelar: seguir descargando Parche revertido Se restauraron {0} y se eliminaron {1} archivo(s) agregado(s). No se pudo revertir el parche - No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. + No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. + No se puede revertir el fix de forma segura + {0} cambió desde que se aplicó este fix, así que restaurarlo sobrescribiría esos cambios. Si aplicaste otro fix encima, revierte ese primero. {1} archivo(s) afectado(s). Parche revertido parcialmente No se pudieron restaurar {0} archivo(s). Cerrá el juego y probá de nuevo. diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index 4e4f2e6..74d5eeb 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -324,7 +324,9 @@ Esto elimina los archivos .lua de Steam\config\stplug-in. 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. + No hay registro de que este parche se haya aplicado. Puede que ya se haya revertido. + No se puede revertir el fix de forma segura + {0} ha cambiado desde que se aplicó este fix, así que restaurarlo sobrescribiría esos cambios. Si aplicaste otro fix encima, revierte ese primero. {1} archivo(s) afectado(s). Este parche ya está aplicado. Revierte para instalar otro. Mis juegos {0} de tus juegos tienen parches. Desplázate para ver el resto. diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index b03e03f..783691b 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -614,7 +614,9 @@ Peruuta - jatka lataamista Korjaus peruttu {0} palautettu, {1} lisättyä tiedostoa poistettu. Korjausta ei voitu perua - Ei merkintää siitä, että tämä korjaus olisi käytössä. Se on ehkä jo peruttu. + Ei merkintää siitä, että tämä korjaus olisi käytössä. Se on ehkä jo peruttu. + Korjausta ei voi peruuttaa turvallisesti + {0} on muuttunut tämän korjauksen käyttöönoton jälkeen, joten palauttaminen korvaisi nuo muutokset. Jos otit päälle toisen korjauksen, peru ensin se. {1} tiedosto(a) koskee. Korjaus peruttu osittain {0} tiedostoa ei voitu palauttaa. Sulje peli ja yritä uudelleen. diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 660ef1b..0874247 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -614,7 +614,9 @@ Annuler - continuer le téléchargement Correctif annulé {0} restauré(s), {1} fichier(s) ajouté(s) supprimé(s). Impossible d'annuler le correctif - Aucune trace de l'application de ce correctif. Il a peut-être déjà été annulé. + Aucune trace de l'application de ce correctif. Il a peut-être déjà été annulé. + Impossible d'annuler ce correctif en toute sécurité + {0} a changé depuis l'application de ce correctif ; le restaurer écraserait ces modifications. Si vous avez appliqué un autre correctif par-dessus, annulez-le d'abord. {1} fichier(s) concerné(s). Correctif partiellement annulé Impossible de restaurer {0} fichier(s). Fermez le jeu et réessayez. diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index 98a33de..d7d6d6e 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -614,7 +614,9 @@ Mégse - letöltés folytatása Javítás visszavonva {0} visszaállítva, {1} hozzáadott fájl eltávolítva. A javítást nem sikerült visszavonni - Nincs nyoma annak, hogy ezt a javítást alkalmazták volna. Lehet, hogy már visszavonták. + Nincs nyoma annak, hogy ezt a javítást alkalmazták volna. Lehet, hogy már visszavonták. + A javítás nem vonható vissza biztonságosan + A(z) {0} megváltozott a javítás alkalmazása óta, ezért a visszaállítás felülírná ezeket a módosításokat. Ha másik javítást is alkalmaztál rá, előbb azt vond vissza. {1} fájl érintett. A javítás részben lett visszavonva {0} fájlt nem sikerült visszaállítani. Zárd be a játékot, és próbáld újra. diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index 5fe0c46..ce783f7 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -614,7 +614,9 @@ Batal - lanjutkan mengunduh Perbaikan dikembalikan {0} dipulihkan, {1} berkas tambahan dihapus. Tidak dapat mengembalikan perbaikan - Tidak ada catatan perbaikan ini pernah diterapkan. Mungkin sudah dikembalikan. + Tidak ada catatan perbaikan ini pernah diterapkan. Mungkin sudah dikembalikan. + Fix tidak dapat dikembalikan dengan aman + {0} telah berubah sejak fix ini diterapkan, jadi memulihkannya akan menimpa perubahan tersebut. Jika Anda menerapkan fix lain di atasnya, kembalikan yang itu dulu. {1} berkas terpengaruh. Perbaikan dikembalikan sebagian {0} berkas tidak dapat dipulihkan. Tutup game lalu coba lagi. diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index ba5f128..2797ab2 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -614,7 +614,9 @@ Annulla - continua a scaricare Fix annullata {0} ripristinati, {1} file aggiunti rimossi. Impossibile annullare la fix - Nessuna traccia dell'applicazione di questa fix. Potrebbe essere già stata annullata. + Nessuna traccia dell'applicazione di questa fix. Potrebbe essere già stata annullata. + Impossibile ripristinare il fix in sicurezza + {0} è cambiato da quando questo fix è stato applicato, quindi ripristinarlo sovrascriverebbe quelle modifiche. Se hai applicato un altro fix sopra, ripristina prima quello. {1} file interessati. Fix annullata parzialmente Impossibile ripristinare {0} file. Chiudi il gioco e riprova. diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index 0ac69d5..d502532 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -614,7 +614,9 @@ Steam\config\stplug-in から .lua ファイルを削除します。修正を元に戻しました {0} 件を復元し、追加された {1} 件を削除しました。 修正を元に戻せませんでした - この修正を適用した記録がありません。すでに元に戻されている可能性があります。 + この修正を適用した記録がありません。すでに元に戻されている可能性があります。 + 修正を安全に元に戻せません + この修正の適用後に {0} が変更されているため、復元するとその変更が上書きされます。別の修正を上に適用した場合は、先にそちらを元に戻してください。対象は {1} 個のファイルです。 修正は一部だけ元に戻りました {0} 個のファイルを復元できませんでした。ゲームを閉じてやり直してください。 diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index 01ffde4..d3fce87 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -614,7 +614,9 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 수정을 되돌렸습니다 {0}개 복원, 추가된 {1}개 삭제됨. 수정을 되돌리지 못했습니다 - 이 수정이 적용된 기록이 없습니다. 이미 되돌렸을 수 있습니다. + 이 수정이 적용된 기록이 없습니다. 이미 되돌렸을 수 있습니다. + 픽스를 안전하게 되돌릴 수 없음 + 이 픽스를 적용한 뒤 {0} 이(가) 변경되어, 복원하면 해당 변경 사항을 덮어씁니다. 다른 픽스를 위에 적용했다면 그것부터 되돌리세요. {1}개 파일이 영향을 받습니다. 수정이 일부만 되돌려졌습니다 {0}개 파일을 복원하지 못했습니다. 게임을 닫고 다시 시도하세요. diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index d13589f..2daa452 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -614,7 +614,9 @@ Avbryt - fortsett nedlastingen Rettelse angret {0} gjenopprettet, {1} tillagte filer fjernet. Kunne ikke angre rettelsen - Ingen registrering av at denne rettelsen er brukt. Den kan allerede være angret. + Ingen registrering av at denne rettelsen er brukt. Den kan allerede være angret. + Fiksen kan ikke tilbakestilles trygt + {0} er endret siden denne fiksen ble brukt, så gjenoppretting ville overskrive de endringene. Hvis du har brukt en annen fiks oppå, tilbakestill den først. {1} fil(er) berørt. Rettelse delvis angret {0} fil(er) kunne ikke gjenopprettes. Lukk spillet og prøv igjen. diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index d89510b..eed0fbd 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -614,7 +614,9 @@ Annuleren - doorgaan met downloaden Fix teruggedraaid {0} hersteld, {1} toegevoegde bestand(en) verwijderd. Kan fix niet terugdraaien - Geen registratie dat deze fix is toegepast. Mogelijk is hij al teruggedraaid. + Geen registratie dat deze fix is toegepast. Mogelijk is hij al teruggedraaid. + Fix kan niet veilig worden teruggedraaid + {0} is gewijzigd sinds deze fix is toegepast, dus herstellen zou die wijzigingen overschrijven. Als je er een andere fix overheen hebt toegepast, draai die dan eerst terug. {1} bestand(en) getroffen. Fix gedeeltelijk teruggedraaid {0} bestand(en) konden niet worden hersteld. Sluit de game en probeer opnieuw. diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index 7387103..00b2879 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -614,7 +614,9 @@ Anuluj - kontynuuj pobieranie Poprawka cofnięta Przywrócono {0}, usunięto {1} dodanych plików. Nie udało się cofnąć poprawki - Brak zapisu o zastosowaniu tej poprawki. Mogła już zostać cofnięta. + Brak zapisu o zastosowaniu tej poprawki. Mogła już zostać cofnięta. + Nie można bezpiecznie cofnąć poprawki + Plik {0} zmienił się od zastosowania tej poprawki, więc przywrócenie nadpisałoby te zmiany. Jeśli zastosowano na wierzchu inną poprawkę, cofnij najpierw ją. Dotyczy {1} plik(ów). Poprawka cofnięta częściowo Nie udało się przywrócić {0} plików. Zamknij grę i spróbuj ponownie. diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index 439e707..08cde61 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -614,7 +614,9 @@ Cancelar - continuar baixando Correção revertida {0} restaurado(s), {1} arquivo(s) adicionado(s) removido(s). Não foi possível reverter a correção - Não há registro desta correção ter sido aplicada. Pode já ter sido revertida. + Não há registro desta correção ter sido aplicada. Pode já ter sido revertida. + Não é possível reverter o fix com segurança + {0} mudou desde que este fix foi aplicado, então restaurá-lo sobrescreveria essas mudanças. Se você aplicou outro fix por cima, reverta aquele primeiro. {1} arquivo(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} arquivo(s). Feche o jogo e tente novamente. diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index 3c403e3..768f61c 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -614,7 +614,9 @@ Cancelar - continuar a transferir Correção revertida {0} restaurado(s), {1} ficheiro(s) adicionado(s) removido(s). Não foi possível reverter a correção - Não há registo de esta correção ter sido aplicada. Pode já ter sido revertida. + Não há registo de esta correção ter sido aplicada. Pode já ter sido revertida. + Não é possível reverter o fix em segurança + {0} mudou desde que este fix foi aplicado, pelo que restaurá-lo iria sobrescrever essas alterações. Se aplicou outro fix por cima, reverta esse primeiro. {1} ficheiro(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} ficheiro(s). Feche o jogo e tente novamente. diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 4d1bfba..fe4c107 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -358,7 +358,9 @@ This deletes the .lua files from Steam\config\stplug-in. 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. + No record of this fix being applied. It may already have been reverted. + Fix can't be reverted safely + {0} has changed since this fix was applied, so restoring it would overwrite those changes. If you applied another fix on top, revert that one first. {1} file(s) affected. 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. diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 4661e26..6a8df52 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -614,7 +614,9 @@ Anulare - continuă descărcarea Reparație anulată {0} restaurate, {1} fișiere adăugate eliminate. Reparația nu a putut fi anulată - Nu există nicio înregistrare că această reparație a fost aplicată. Poate a fost deja anulată. + Nu există nicio înregistrare că această reparație a fost aplicată. Poate a fost deja anulată. + Fix-ul nu poate fi anulat în siguranță + {0} s-a modificat de la aplicarea acestui fix, așa că restaurarea ar suprascrie acele modificări. Dacă ai aplicat alt fix peste, anulează-l mai întâi pe acela. {1} fișier(e) afectate. Reparație anulată parțial {0} fișier(e) nu au putut fi restaurate. Închide jocul și încearcă din nou. diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index 53c447b..be68125 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -614,7 +614,9 @@ Исправление откачено Восстановлено {0}, удалено добавленных файлов: {1}. Не удалось откатить исправление - Нет записи о применении этого исправления. Возможно, его уже откатили. + Нет записи о применении этого исправления. Возможно, его уже откатили. + Не удаётся безопасно откатить фикс + Файл {0} изменился после применения этого фикса, и восстановление перезапишет эти изменения. Если поверх был применён другой фикс, сначала откатите его. Затронуто файлов: {1}. Исправление откачено частично Не удалось восстановить файлов: {0}. Закройте игру и повторите попытку. diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index 20b47bb..ef19712 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -614,7 +614,9 @@ Avbryt - fortsätt hämta Korrigering ångrad {0} återställda, {1} tillagda filer borttagna. Kunde inte ångra korrigeringen - Ingen notering om att den här korrigeringen tillämpats. Den kan redan vara ångrad. + Ingen notering om att den här korrigeringen tillämpats. Den kan redan vara ångrad. + Fixen kan inte återställas säkert + {0} har ändrats sedan den här fixen tillämpades, så en återställning skulle skriva över de ändringarna. Om du tillämpat en annan fix ovanpå, återställ den först. {1} fil(er) påverkas. Korrigering delvis ångrad {0} fil(er) kunde inte återställas. Stäng spelet och försök igen. diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index 0dc3247..72faf46 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -614,7 +614,9 @@ ย้อนกลับตัวแก้ไขแล้ว กู้คืน {0} รายการ ลบไฟล์ที่เพิ่ม {1} รายการ ย้อนกลับตัวแก้ไขไม่สำเร็จ - ไม่พบบันทึกว่าเคยใช้ตัวแก้ไขนี้ อาจถูกย้อนกลับไปแล้ว + ไม่พบบันทึกว่าเคยใช้ตัวแก้ไขนี้ อาจถูกย้อนกลับไปแล้ว + ไม่สามารถย้อนกลับแพตช์ได้อย่างปลอดภัย + {0} มีการเปลี่ยนแปลงหลังจากใช้แพตช์นี้ การกู้คืนจะเขียนทับการเปลี่ยนแปลงนั้น หากคุณใช้แพตช์อื่นทับไว้ ให้ย้อนกลับแพตช์นั้นก่อน มีไฟล์ที่ได้รับผลกระทบ {1} ไฟล์ ย้อนกลับตัวแก้ไขได้บางส่วน กู้คืนไฟล์ {0} รายการไม่สำเร็จ กรุณาปิดเกมแล้วลองใหม่ diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 372e100..823f0c0 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -614,7 +614,9 @@ Hayır - durdur ama dosyaları koru Düzeltme geri alındı {0} geri yüklendi, eklenen {1} dosya kaldırıldı. Düzeltme geri alınamadı - Bu düzeltmenin uygulandığına dair kayıt yok. Zaten geri alınmış olabilir. + Bu düzeltmenin uygulandığına dair kayıt yok. Zaten geri alınmış olabilir. + Düzeltme güvenle geri alınamıyor + Bu düzeltme uygulandıktan sonra {0} değişti; geri yüklemek bu değişikliklerin üzerine yazar. Üstüne başka bir düzeltme uyguladıysanız önce onu geri alın. {1} dosya etkilendi. Düzeltme kısmen geri alındı {0} dosya geri yüklenemedi. Oyunu kapatıp tekrar deneyin. diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index d246968..9f3ef67 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -614,7 +614,9 @@ Виправлення відкочено Відновлено {0}, вилучено доданих файлів: {1}. Не вдалося відкотити виправлення - Немає запису про застосування цього виправлення. Можливо, його вже відкотили. + Немає запису про застосування цього виправлення. Можливо, його вже відкотили. + Не вдається безпечно скасувати фікс + Файл {0} змінився після застосування цього фікса, тож відновлення перезапише ці зміни. Якщо згори застосовано інший фікс, спершу скасуйте його. Порушено файлів: {1}. Виправлення відкочено частково Не вдалося відновити файлів: {0}. Закрийте гру та спробуйте ще раз. diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index 198f594..c1439fa 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -614,7 +614,9 @@ Hủy - tiếp tục tải Đã hoàn tác bản sửa lỗi Đã khôi phục {0}, đã xóa {1} tệp được thêm. Không thể hoàn tác bản sửa lỗi - Không có ghi nhận nào về việc áp dụng bản sửa lỗi này. Có thể nó đã được hoàn tác. + Không có ghi nhận nào về việc áp dụng bản sửa lỗi này. Có thể nó đã được hoàn tác. + Không thể hoàn tác bản vá một cách an toàn + {0} đã thay đổi kể từ khi bản vá này được áp dụng, nên khôi phục sẽ ghi đè các thay đổi đó. Nếu bạn đã áp dụng bản vá khác lên trên, hãy hoàn tác bản đó trước. {1} tệp bị ảnh hưởng. Bản sửa lỗi được hoàn tác một phần Không thể khôi phục {0} tệp. Hãy đóng trò chơi và thử lại. diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index 489a3eb..abb3467 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -634,7 +634,9 @@ 修复已还原 已恢复 {0} 个,已删除 {1} 个新增文件。 无法还原修复 - 没有应用此修复的记录,可能已经还原过了。 + 没有应用此修复的记录,可能已经还原过了。 + 无法安全还原此修复 + 自应用此修复以来,{0} 已被更改,还原会覆盖这些更改。如果你在其上应用了另一个修复,请先还原那一个。共影响 {1} 个文件。 修复部分还原 有 {0} 个文件无法恢复。请关闭游戏后重试。 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index cff5139..3116e50 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -614,7 +614,9 @@ 修復已還原 已復原 {0} 個,已刪除 {1} 個新增檔案。 無法還原修復 - 沒有套用此修復的紀錄,可能已經還原過了。 + 沒有套用此修復的紀錄,可能已經還原過了。 + 無法安全還原此修復 + 自套用此修復以來,{0} 已被變更,還原會覆寫這些變更。如果你在其上套用了另一個修復,請先還原那一個。共影響 {1} 個檔案。 修復部分還原 有 {0} 個檔案無法復原。請關閉遊戲後重試。 diff --git a/src/LuaToolsGui/Services/AppliedFixIndexService.cs b/src/LuaToolsGui/Services/AppliedFixIndexService.cs new file mode 100644 index 0000000..58c1960 --- /dev/null +++ b/src/LuaToolsGui/Services/AppliedFixIndexService.cs @@ -0,0 +1,205 @@ +using System.IO; +using System.Text.Json; +using LuaToolsGui.Models; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services; + +/// +/// One flat list of every applied fix, so "what is applied right now?" is a single small file read +/// instead of a walk over every game folder on every library drive. +/// +/// +/// +/// This is a hint, never the truth. The authoritative record of a fix is +/// <install>\.luatools-fix\<key>.json, which lives with the game and travels with it. +/// Every read here is confirmed against that file before it is believed, and anything that no longer +/// checks out is dropped. Delete this index and nothing is lost: reconstructs +/// it by scanning, which is exactly what the index exists to avoid doing routinely (~10s cold across +/// three drives, versus reading one small file). +/// +/// +/// The index can be wrong in both directions and both are handled. Stale entries — the game was +/// uninstalled, the folder deleted, the fix reverted by another copy of the app — fail the confirm and +/// are pruned. Missing entries — a fix applied by an older build, or a game folder moved in — are found +/// by a rebuild. Nothing here is ever the only place a fix is recorded, or deleting this file would +/// strand backups with nothing pointing at them. +/// +/// +public class AppliedFixIndexService(SteamLibraryService library, ILogger log) +{ + private static readonly string Dir = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui"); + + private static readonly string FilePath = Path.Combine(Dir, "applied-fixes.json"); + + private static readonly JsonSerializerOptions JsonOpts = + new() { PropertyNameCaseInsensitive = true, WriteIndented = true }; + + private readonly SemaphoreSlim _gate = new(1, 1); + + // ── Reading ────────────────────────────────────────────────────── + + /// + /// Every fix that is really applied, newest first. Entries whose record no longer exists on disk are + /// dropped from the returned list AND from the file, so the index self-heals as games come and go. + /// + public async Task> ListAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct); + try + { + var index = Load(); + var live = new List(index.Entries.Count); + bool pruned = false; + + foreach (var e in index.Entries) + { + if (ct.IsCancellationRequested) break; + if (RecordExists(e)) live.Add(e); + else pruned = true; + } + + if (pruned) Save(new AppliedFixIndex { Entries = live }); + + return live + .OrderByDescending(e => e.AppliedAt, StringComparer.Ordinal) // ISO-8601, so ordinal is chronological + .ToList(); + } + finally { _gate.Release(); } + } + + /// + /// Rebuild from scratch by scanning every installed game for a .luatools-fix folder. + /// + /// + /// The slow path the index exists to avoid, kept because it is the only way to recover from a deleted + /// index or to pick up fixes applied by a build that predates it. Off the UI thread; the caller + /// decides when it is worth paying for. + /// + public async Task> RebuildAsync(CancellationToken ct = default) + { + var found = await Task.Run(() => Scan(ct), ct); + + await _gate.WaitAsync(ct); + try { Save(new AppliedFixIndex { Entries = found }); } + finally { _gate.Release(); } + + log.LogDebug("Rebuilt the applied-fix index by scanning: {Count} fix(es)", found.Count); + return found; + } + + // ── Writing ────────────────────────────────────────────────────── + + /// Record a fix as applied, replacing any existing entry for the same game + fix. + public void Add(long appId, string fixId, string gameName, string installDir, string appliedAt, int fileCount) + { + Mutate(entries => + { + entries.RemoveAll(e => e.AppId == appId && e.FixId == fixId); + entries.Add(new AppliedFixIndexEntry + { + AppId = appId, + FixId = fixId, + GameName = gameName, + InstallDir = installDir, + AppliedAt = appliedAt, + FileCount = fileCount, + }); + }); + } + + /// Drop a fix from the index. Safe to call when it was never in there. + public void Remove(long appId, string fixId) => + Mutate(entries => entries.RemoveAll(e => e.AppId == appId && e.FixId == fixId)); + + // ── Internals ──────────────────────────────────────────────────── + + /// + /// Read-modify-write under the lock. Best-effort throughout: the index is a cache, so a failed write + /// must never take down the apply or revert that triggered it — the real record is already on disk. + /// + private void Mutate(Action> change) + { + _gate.Wait(); + try + { + var index = Load(); + change(index.Entries); + Save(index); + } + catch (Exception ex) { log.LogDebug(ex, "Could not update the applied-fix index"); } + finally { _gate.Release(); } + } + + private static AppliedFixIndex Load() + { + try + { + if (!File.Exists(FilePath)) return new AppliedFixIndex(); + return JsonSerializer.Deserialize(File.ReadAllText(FilePath), JsonOpts) + ?? new AppliedFixIndex(); + } + catch { return new AppliedFixIndex(); } // corrupt or half-written: start clean, a rebuild restores it + } + + private static void Save(AppliedFixIndex index) + { + try + { + Directory.CreateDirectory(Dir); + File.WriteAllText(FilePath, JsonSerializer.Serialize(index, JsonOpts)); + } + catch { /* best effort: this is a cache */ } + } + + /// Confirm an index entry against the record that actually governs the fix. + private static bool RecordExists(AppliedFixIndexEntry e) + { + try + { + return !string.IsNullOrEmpty(e.InstallDir) + && File.Exists(Downloads.ManifestJobFactory.GetFixRecordPath(e.InstallDir, e.FixId)); + } + catch { return false; } + } + + /// Walk installed games looking for fix records. The expensive path, hence the index. + private List Scan(CancellationToken ct) + { + var found = new List(); + + foreach (var game in library.EnumerateInstalled()) + { + if (ct.IsCancellationRequested) break; + + string installDir = game.InstallDir; + string fixDir = Path.Combine(installDir, Downloads.ManifestJobFactory.FixRecordDir); + string[] records; + try + { + if (!Directory.Exists(fixDir)) continue; + records = Directory.GetFiles(fixDir, "*.json"); + } + catch { continue; } + + foreach (string path in records) + { + var record = Downloads.ManifestJobFactory.ReadFixRecord(path); + if (record is null || string.IsNullOrEmpty(record.FixId)) continue; + + found.Add(new AppliedFixIndexEntry + { + AppId = record.AppId != 0 ? record.AppId : game.AppId, + FixId = record.FixId, + GameName = game.Name, + InstallDir = installDir, + AppliedAt = record.AppliedAt, + FileCount = record.Files.Count, + }); + } + } + + return found; + } +} diff --git a/src/LuaToolsGui/Services/DepotCacheMigrationService.cs b/src/LuaToolsGui/Services/DepotCacheMigrationService.cs new file mode 100644 index 0000000..260d6cf --- /dev/null +++ b/src/LuaToolsGui/Services/DepotCacheMigrationService.cs @@ -0,0 +1,170 @@ +using System.IO; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services; + +/// What a migration pass did. All counts, no exceptions: the pass is best-effort by design. +/// Manifests relocated into the real depotcache, where Steam can finally see them. +/// Skipped because the real folder already had that exact file. +/// Skipped because the file is not a valid manifest matching its own name. +/// Tried and failed (locked, permissions, disk). +public readonly record struct DepotCacheMigrationResult(int Moved, int AlreadyPresent, int Rejected, int Failed) +{ + /// True when nothing at all was found to consider, so there is nothing worth reporting. + public bool IsEmpty => Moved == 0 && AlreadyPresent == 0 && Rejected == 0 && Failed == 0; +} + +/// +/// Moves manifests out of the old, wrong config\depotcache into the real depotcache. +/// +/// +/// +/// Until 2026-09-09 this app wrote every .manifest to <Steam>\config\depotcache. Steam +/// reads <Steam>\depotcache — a sibling of steamapps, not a child of config. See +/// for the evidence. A manifest in the wrong folder is invisible +/// to Steam, so a pinned depot resolves to nothing and the download simply never starts, with no error +/// shown anywhere. Fixing the path only helps future writes; anything already installed stays stranded +/// until it is moved, which is what this does. +/// +/// +/// Deliberately silent. There is no toast and no setting: the user did nothing wrong, there is nothing for +/// them to decide, and a repair that needs explaining is worse than one that just happens. It logs, and +/// only at Information level when it actually moved something. +/// +/// +/// Safe to run on every launch. It is idempotent, never overwrites, and touches nothing outside the two +/// depotcache folders. +/// +/// +public class DepotCacheMigrationService(SteamService steam, ILogger log) +{ + /// Run the pass off the calling thread. Never throws. + public Task RunAsync(CancellationToken ct = default) => + Task.Run(() => Run(ct), ct); + + /// Run the pass against the located Steam install. Never throws. + public DepotCacheMigrationResult Run(CancellationToken ct = default) => + steam.LegacyDepotCacheDir is { } legacyDir && steam.DepotCacheDir is { } realDir + ? Migrate(legacyDir, realDir, log, ct) + : default; // Steam not located + + /// + /// The whole pass, against two explicit folders. Never throws — every failure is counted and the + /// sweep continues, because one locked file must not strand the rest. + /// + public static DepotCacheMigrationResult Migrate( + string legacyDir, string realDir, ILogger log, CancellationToken ct = default) + { + // Paranoia, not a real case: if these ever resolved to the same folder, moving a file onto itself + // would delete it. + if (string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(legacyDir)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(realDir)), + StringComparison.OrdinalIgnoreCase)) + return default; + + string[] files; + try + { + if (!Directory.Exists(legacyDir)) return default; // the common case after the first run + files = Directory.GetFiles(legacyDir, "*.manifest"); + } + catch (Exception ex) + { + log.LogDebug(ex, "Legacy depotcache at {Dir} is unreadable; skipping migration", legacyDir); + return default; + } + + if (files.Length == 0) + { + TryRemoveIfEmpty(legacyDir, log); + return default; + } + + int moved = 0, present = 0, rejected = 0, failed = 0; + + foreach (string src in files) + { + if (ct.IsCancellationRequested) break; + + string name = Path.GetFileName(src); + string dest = Path.Combine(realDir, name); + + // The name is content-addressed (_), so a file already there IS this file. Leave + // the stale copy alone rather than deleting it: reclaiming disk is not worth a destructive + // step in a silent startup task. + try { if (File.Exists(dest)) { present++; continue; } } + catch { failed++; continue; } + + // Only move something that really is the manifest its name claims. A truncated or half-written + // file that reaches the real depotcache is sticky — LuaInstaller skips a destination that + // exists, so a bad entry would survive every later fetch and break that depot permanently. + // Better to strand it here, where it already was and where it harms nothing. + if (!ParseName(name, out long depotId, out string gid) || !ManifestFile.Matches(src, depotId, gid)) + { + rejected++; + continue; + } + + try + { + Directory.CreateDirectory(realDir); // only once there is something to put in it + File.Move(src, dest); + moved++; + } + catch (Exception ex) + { + failed++; + log.LogDebug(ex, "Could not move stranded manifest {Name} into {Dir}", name, realDir); + } + } + + if (moved > 0) + log.LogInformation( + "Moved {Moved} stranded manifest(s) from {Legacy} into {Real} " + + "({Present} already there, {Rejected} not valid, {Failed} failed)", + moved, legacyDir, realDir, present, rejected, failed); + else if (failed > 0) + log.LogDebug("Depotcache migration moved nothing; {Failed} failed", failed); + + TryRemoveIfEmpty(legacyDir, log); + + return new DepotCacheMigrationResult(moved, present, rejected, failed); + } + + /// + /// Split <depot>_<gid>.manifest. Returns false for anything else, which is the + /// point: an unrecognised name cannot be validated, so it is not moved. + /// + private static bool ParseName(string fileName, out long depotId, out string gid) + { + depotId = 0; + gid = ""; + + string stem = Path.GetFileNameWithoutExtension(fileName); + int split = stem.IndexOf('_'); + if (split <= 0 || split == stem.Length - 1) return false; + + if (!long.TryParse(stem[..split], out depotId)) return false; + + gid = stem[(split + 1)..]; + return ulong.TryParse(gid, out _); + } + + /// + /// Drop the legacy folder once it holds nothing, so a fully migrated install stops scanning it. + /// Only ever deletes an empty directory. + /// + private static void TryRemoveIfEmpty(string dir, ILogger log) + { + try + { + if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) + Directory.Delete(dir); + } + catch (Exception ex) + { + log.LogDebug(ex, "Could not remove the empty legacy depotcache at {Dir}", dir); + } + } +} diff --git a/src/LuaToolsGui/Services/DepotDownloaderService.cs b/src/LuaToolsGui/Services/DepotDownloaderService.cs index 4995b3a..724139c 100644 --- a/src/LuaToolsGui/Services/DepotDownloaderService.cs +++ b/src/LuaToolsGui/Services/DepotDownloaderService.cs @@ -316,11 +316,32 @@ public IReadOnlyDictionary ResolveKeys(long appId) return ManifestFile.Matches(path, depotId, manifestId) ? path : null; } - /// The name a depot's manifest has in depotcache, whether or not it is there. - private string? CachedManifestPath(long depotId, string manifestId) => - steam.DepotCacheDir is { } dir - ? Path.Combine(dir, $"{depotId}_{manifestId}.manifest") - : null; + /// + /// The name a depot's manifest has in depotcache, whether or not it is there. + /// + /// + /// Prefers the real depotcache, then falls back to a file left in the old, wrong + /// config\depotcache (see SteamService.DepotCacheDir). The fallback is a read + /// convenience only — it keeps existing installs from re-fetching thousands of manifests at once — + /// so a hit there is still a real file the caller can hand to the downloader. Writes always go to + /// the real folder, which is the only one Steam itself reads. + /// + private string? CachedManifestPath(long depotId, string manifestId) + { + string name = $"{depotId}_{manifestId}.manifest"; + + if (steam.DepotCacheDir is not { } dir) return null; + string path = Path.Combine(dir, name); + if (File.Exists(path)) return path; + + if (steam.LegacyDepotCacheDir is { } legacyDir) + { + string legacy = Path.Combine(legacyDir, name); + if (File.Exists(legacy)) return legacy; + } + + return path; // nothing on disk: name the real location, so callers report/fetch the right path + } /// /// Delete a cached manifest that failed validation, so a re-fetch can actually replace it. diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index 0379265..43d055d 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -24,7 +24,8 @@ public class ManifestJobFactory( ToastService toast, DepotDownloaderService depotTool, SteamDepotInfo depotInfo, - SteamAutoCrackService sac) + SteamAutoCrackService sac, + AppliedFixIndexService fixIndex) { // ── Job builders ───────────────────────────────────────────────── @@ -563,6 +564,27 @@ private JobResult InstallDenuvoManifest(DownloadedFile file, long appId, string /// 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. + /// + /// Runs in four phases, because the revert record is the ONLY thing that makes a fix undoable and + /// FixesViewModel keys the Revert button off that file existing: + /// + /// + /// Plan — decide modified-vs-added from File.Exists alone. Nothing is written. + /// Commit — write a provisional record listing every planned entry. If this fails the + /// apply stops here, untouched. Previously the record was written LAST inside an empty + /// catch {}, so a locked or unwritable path silently "succeeded" and left .bak files with + /// nothing indexing them and no Revert button that would ever appear. + /// Apply — back up, extract, and build the real entry list with hashes. + /// Settle — rewrite the record with what actually happened. + /// + /// + /// Phase 4 is not tidiness. The provisional record describes INTENT, and an entry whose backup threw + /// would claim a .bak that was never written — which the revert now (correctly) treats as a + /// hard error, making a partial apply permanently un-revertable. It is also the only place + /// HashAfter can come from, since that is unknowable until the file has been extracted. + /// So the record starts as a promise and ends as a fact, and a crash in between still leaves + /// something the user can revert. + /// private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId, string gameName) { try @@ -576,20 +598,21 @@ 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); + string fixDir = Path.Combine(installDir, FixRecordDir); + string recordPath = Path.Combine(fixDir, $"{fixKey}.json"); - var manifest = new DenuvoFixManifest + var record = new DenuvoFixRecord { AppId = appId, FixId = fixId, AppliedAt = DateTimeOffset.UtcNow.ToString("o"), }; + // ── Phase 1: plan. File.Exists only; nothing on disk changes yet. ────────────────────── using var archive = ZipFile.OpenRead(file.FilePath); + var plan = new List<(string RelPath, string Dest, string? BakRel, ZipArchiveEntry Entry)>(); int failed = 0; + foreach (var entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; @@ -599,55 +622,86 @@ 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; } + // Backups are 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 in phase 3 + // 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. + plan.Add((relPath, dest, File.Exists(dest) ? $"{fixKey}/{relPath}.bak" : null, entry)); + } + + // ── Phase 2: commit. Nothing has been touched yet, so failing here costs nothing. ────── + foreach (var (relPath, _, bakRel, _) in plan) + record.Files.Add(new DenuvoFixRecordEntry + { + RelativePath = relPath, + Action = bakRel is null ? "added" : "modified", + BackupPath = bakRel, + }); + + try + { + Directory.CreateDirectory(fixDir); + File.WriteAllText(recordPath, SerializeRecord(record)); + } + catch (Exception ex) + { + toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true); + return new JobResult(false, ex.Message); + } + + // ── Phase 3: apply, recording what really happened rather than what was planned. ─────── + var applied = new List(plan.Count); + + foreach (var (relPath, dest, bakRel, entry) in plan) + { try { - if (File.Exists(dest)) + DenuvoFixRecordEntry recordEntry; + if (bakRel is not null) { - // 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); - if (!File.Exists(bakAbs)) + string bakAbs = Path.Combine(fixDir, bakRel); + if (!File.Exists(bakAbs)) // never clobber a known-good .bak { Directory.CreateDirectory(Path.GetDirectoryName(bakAbs)!); File.Copy(dest, bakAbs, overwrite: false); } - manifest.Files.Add(new DenuvoFixManifestEntry + // Hash the ORIGINAL before it is overwritten; there is no second chance. + recordEntry = new DenuvoFixRecordEntry { RelativePath = relPath, Action = "modified", BackupPath = bakRel, - }); + HashBefore = FileHash.Sha256(dest), + }; } else { - manifest.Files.Add(new DenuvoFixManifestEntry - { - RelativePath = relPath, - Action = "added", - }); + recordEntry = new DenuvoFixRecordEntry { RelativePath = relPath, Action = "added" }; } Directory.CreateDirectory(Path.GetDirectoryName(dest)!); entry.ExtractToFile(dest, overwrite: true); + + // Hash what we just wrote. This is what a later revert checks the file against to + // prove nothing has replaced it since — another fix layered on top, a game update, a + // hand-edit. Set after the extract so it describes the file as it really landed. + recordEntry.HashAfter = FileHash.Sha256(dest); + applied.Add(recordEntry); } - catch { failed++; } + catch { failed++; } // entry stays OUT of `applied`, so the settled record won't claim it } - // 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 */ } + // ── Phase 4: settle. Replace the promise with the facts. ─────────────────────────────── + record.Files = applied; + try { File.WriteAllText(recordPath, SerializeRecord(record)); } + catch { /* the provisional record from phase 2 stands: over-broad, but still revertable */ } + + // Index it even on partial failure, for the same reason the record is written: files were + // backed up, so the fix IS applied and must be listable and revertable. The index is only a + // hint, so a failure to update it is not a failure to apply. + fixIndex.Add(appId, fixId, gameName, installDir, record.AppliedAt, record.Files.Count); if (failed > 0) { @@ -675,28 +729,33 @@ private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, string fixId, private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; - internal const string FixManifestDir = ".luatools-fix"; + internal const string FixRecordDir = ".luatools-fix"; + + /// The revert record's on-disk form. Indented because users do open these by hand. + private static string SerializeRecord(DenuvoFixRecord record) => + System.Text.Json.JsonSerializer.Serialize( + record, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); /// /// 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. + /// raw id in the record 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"); + /// Resolve the revert-record path for a given fix inside a game's install folder. + internal static string GetFixRecordPath(string installDir, string fixId) => + Path.Combine(installDir, FixRecordDir, $"{SafeFixKey(fixId)}.json"); /// /// Resolve a relative path against the game folder, or null when it would escape it. /// /// /// Both callers take the relative part from data we do not control: zip entry names on apply, and the - /// revert manifest on revert. `Path.Combine(installDir, "../../../Windows/System32/x.dll")` happily + /// revert record on revert. `Path.Combine(installDir, "../../../Windows/System32/x.dll")` happily /// resolves outside the game — the classic zip-slip shape — and the revert path DELETES what it - /// resolves. The manifest also lives in a user-writable folder, and a fix archive can plant one for a + /// resolves. The record also lives in a user-writable folder, and a fix archive can plant one for a /// different fix key that we would not overwrite. So neither side is trusted; both go through here. /// Same containment check as DepotDownloaderService.TryDeleteCreatedFiles. /// @@ -713,40 +772,55 @@ internal static string GetFixManifestPath(string installDir, string fixId) => catch { return null; } // malformed path (illegal chars, too long): treat as out of bounds } - /// Read a fix manifest from disk, or null if it doesn't exist. - internal static DenuvoFixManifest? ReadFixManifest(string manifestPath) + /// Read a fix's revert record from disk, or null if it doesn't exist. + internal static DenuvoFixRecord? ReadFixRecord(string recordPath) { try { - if (!File.Exists(manifestPath)) return null; - string json = File.ReadAllText(manifestPath); - return System.Text.Json.JsonSerializer.Deserialize(json, JsonOpts); + if (!File.Exists(recordPath)) return null; + string json = File.ReadAllText(recordPath); + 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. + /// files, then clean up the backup directory and the revert record. /// + /// + /// This method is the SOLE emitter of revert feedback — done, partial, conflict, game-not-found, + /// no-record and the catch-all — matching the apply path. Callers must not add a toast of their own: + /// the view model used to show one on any failure, so every partial revert (the common case, from a + /// locked file while the game is running) fired two toasts for a single action. + /// 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 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 recordPath = GetFixRecordPath(installDir, fixId); + var record = ReadFixRecord(recordPath); + if (record is null) + { + string err = Resources.Strings.Fixes_Revert_NoRecord; + toast.Show(Resources.Strings.Fixes_Revert_Failed, err, error: true); + return new JobResult(false, err); + } - int restored = 0, deleted = 0, errors = 0; + int restored = 0, deleted = 0, errors = 0, conflicts = 0; + string? conflictFile = null; - foreach (var entry in manifest.Files) + foreach (var entry in record.Files) { - // The manifest sits in a user-writable folder and a fix archive can plant one, so its + // The record sits in a user-writable folder and a fix archive can plant one, so its // paths get the same containment check the extract does. This branch DELETES. if (ResolveInside(installDir, entry.RelativePath) is not { } dest) { errors++; continue; } @@ -754,19 +828,55 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) { if (entry.Action == "modified" && entry.BackupPath is { } bakRel) { - if (ResolveInside(Path.Combine(installDir, FixManifestDir), bakRel) + if (ResolveInside(Path.Combine(installDir, FixRecordDir), bakRel) is not { } bakAbs) { errors++; continue; } - if (File.Exists(bakAbs)) + if (!File.Exists(bakAbs)) + { + // The record says this file was modified, so a backup MUST exist. Missing + // means it was never written or something removed it, and the original is + // gone for good — the one case where the fixed file silently stays in place. + // Counting it as an error is the only honest outcome: reporting success here + // would tell the user their game is clean when it is still patched. + errors++; + continue; + } + + // Only put the original back if the file on disk is still the one THIS fix wrote. + // Two hashes count as ours: HashAfter (untouched since we applied) and HashBefore + // (an earlier attempt already restored it, so a retry must not treat it as + // foreign). Anything else means something replaced the file after us — most + // likely a second fix layered on top, whose own backup holds our version — and + // restoring would throw that away. Both hashes null is an old record, and + // FileHash.Matches reads null as "nothing to check", so those revert as before. + if (File.Exists(dest) + && !FileHash.Matches(dest, entry.HashAfter) + && !FileHash.Matches(dest, entry.HashBefore)) { - File.Copy(bakAbs, dest, overwrite: true); - File.Delete(bakAbs); - restored++; + conflicts++; + conflictFile ??= entry.RelativePath; + continue; } + + // The .bak is NOT deleted here, deliberately. Backups are removed as a group once + // the whole revert succeeds (see below). Deleting per-file would make a retry + // after a partial failure impossible to tell apart from a lost backup: every + // already-restored entry would come back as a missing .bak and fail forever. + // Leaving them means a retry just re-copies, which is idempotent. + File.Copy(bakAbs, dest, overwrite: true); + restored++; } else if (entry.Action == "added") { if (File.Exists(dest)) { + // This fix created the file, so deleting it is normally right — but not if + // someone has since replaced it. Then the file is theirs, not ours. + if (!FileHash.Matches(dest, entry.HashAfter)) + { + conflicts++; + conflictFile ??= entry.RelativePath; + continue; + } File.Delete(dest); deleted++; } @@ -775,23 +885,37 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) catch { errors++; } } + // Reported before plain errors because it is the only outcome the user can act on, and the + // action is specific: revert the fix that was applied later, then come back to this one. + if (conflicts > 0) + { + string conflictErr = string.Format( + Resources.Strings.Fixes_Revert_Conflict_Body, conflictFile, conflicts); + toast.Show(Resources.Strings.Fixes_Revert_Conflict, conflictErr, error: true); + return new JobResult(false, conflictErr); + } + if (errors > 0) { - // Backups and manifest stay put ON PURPOSE. The usual reason a revert fails is a locked + // Backups and record stay put ON PURPOSE. The usual reason a revert fails is a locked // file because the game is running, and deleting the .bak files here would leave the user // half-reverted with no way to ever finish. Keeping them means the revert is simply // retryable once the game is closed. Same principle the apply path already follows: it - // writes the manifest even on partial failure so the backups stay recoverable. + // writes the record even on partial failure so the backups stay recoverable. 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); } - // Fully reverted, so the backups have served their purpose and the manifest is what makes the + // Fully reverted, so the backups have served their purpose and the record is what makes the // Revert button appear — both go, and the fix reads as un-applied again. - string backupDir = Path.Combine(installDir, FixManifestDir, SafeFixKey(fixId)); + string backupDir = Path.Combine(installDir, FixRecordDir, SafeFixKey(fixId)); try { if (Directory.Exists(backupDir)) Directory.Delete(backupDir, recursive: true); } catch { } - try { if (File.Exists(manifestPath)) File.Delete(manifestPath); } catch { } + try { if (File.Exists(recordPath)) File.Delete(recordPath); } catch { } + + // Drop it from the index last, so the index is never emptier than the truth. If this fails the + // stale entry is pruned on the next read anyway, since its record file is now gone. + fixIndex.Remove(appId, fixId); string message = string.Format(Resources.Strings.Fixes_Revert_Done_Body, restored, deleted); toast.Show(Resources.Strings.Fixes_Revert_Done, message); @@ -799,7 +923,9 @@ public JobResult RevertDenuvoFix(long appId, string fixId, string gameName) } catch (Exception ex) { - toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true); + // "Couldn't revert", not "Couldn't apply" — this is the revert path, and the old title said + // the opposite of what had just happened. + toast.Show(Resources.Strings.Fixes_Revert_Failed, ex.Message, error: true); return new JobResult(false, ex.Message); } } diff --git a/src/LuaToolsGui/Services/FileHash.cs b/src/LuaToolsGui/Services/FileHash.cs new file mode 100644 index 0000000..8452370 --- /dev/null +++ b/src/LuaToolsGui/Services/FileHash.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Security.Cryptography; + +namespace LuaToolsGui.Services; + +/// +/// SHA-256 of a file, as lowercase hex, or null when it cannot be read. +/// +/// +/// Used to prove a file on disk is still the one a fix wrote. SHA-256 rather than something faster +/// because it is in the BCL (no dependency), hardware-accelerated on every CPU this app targets +/// (measured ~2.1 GB/s here), and only ever runs over the handful of files a fix actually touches — +/// never the whole game. Disk read dominates, so a cheaper hash would buy nothing measurable. +/// +/// Null on failure, never an exception: a locked or vanished file must degrade to "cannot verify" and +/// let the caller decide, not abort a revert halfway. +/// +public static class FileHash +{ + /// SHA-256 as lowercase hex, or null if the file is missing or unreadable. + public static string? Sha256(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + try + { + using var fs = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 64 * 1024, FileOptions.SequentialScan); + return Convert.ToHexString(SHA256.HashData(fs)).ToLowerInvariant(); + } + catch { return null; } + } + + /// + /// True when hashes to . + /// + /// + /// True when is null or empty — an absent hash means the record + /// predates hashing, which is "nothing to check", not "check failed". Callers rely on this to keep + /// reverting fixes applied by older builds. + /// + public static bool Matches(string? path, string? expected) + { + if (string.IsNullOrEmpty(expected)) return true; + return string.Equals(Sha256(path), expected, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/LuaToolsGui/Services/LuaInstaller.cs b/src/LuaToolsGui/Services/LuaInstaller.cs index e4801d4..1284356 100644 --- a/src/LuaToolsGui/Services/LuaInstaller.cs +++ b/src/LuaToolsGui/Services/LuaInstaller.cs @@ -13,7 +13,10 @@ public record InstallResult(bool LuaInstalled, int ManifestCount, IReadOnlyList< /// /// Installs downloaded lua/manifest files into Steam: <appid>.lua → config\stplug-in, -/// *.manifest → config\depotcache. Best-effort per file (a locked file doesn't abort the rest). +/// *.manifest → depotcache. Best-effort per file (a locked file doesn't abort the rest). +/// +/// Note the asymmetry, it is not a typo: stplug-in is under config (it is SteamTools'), depotcache is +/// NOT (it is Steam's own, a sibling of steamapps). See SteamService.DepotCacheDir. /// /// When the "Auto Update Apps" setting is on (default), setManifestid() lines in the lua are /// commented out so the app isn't pinned to a version and Steam keeps it updated. When off, the diff --git a/src/LuaToolsGui/Services/ManifestFile.cs b/src/LuaToolsGui/Services/ManifestFile.cs index 1bbc3a0..c927b29 100644 --- a/src/LuaToolsGui/Services/ManifestFile.cs +++ b/src/LuaToolsGui/Services/ManifestFile.cs @@ -17,7 +17,7 @@ namespace LuaToolsGui.Services; /// /// /// Whether the payload's filenames are still encrypted with the depot key. Usually FALSE — Steam stores -/// them decrypted in config\depotcache — which is exactly why key checking cannot rely on this. +/// them decrypted in depotcache — which is exactly why key checking cannot rely on this. /// /// /// The manifest's own id. Together with this is the file's self-declared diff --git a/src/LuaToolsGui/Services/SteamLibraryService.cs b/src/LuaToolsGui/Services/SteamLibraryService.cs index f33d432..a2143ce 100644 --- a/src/LuaToolsGui/Services/SteamLibraryService.cs +++ b/src/LuaToolsGui/Services/SteamLibraryService.cs @@ -20,6 +20,15 @@ public partial class SteamLibraryService(SteamService steam) [GeneratedRegex(@"""installdir""\s*""([^""]+)""", RegexOptions.IgnoreCase)] private static partial Regex InstallDirRegex(); + [GeneratedRegex(@"""appid""\s*""(\d+)""", RegexOptions.IgnoreCase)] + private static partial Regex AppIdRegex(); + + [GeneratedRegex(@"""name""\s*""([^""]*)""", RegexOptions.IgnoreCase)] + private static partial Regex NameRegex(); + + /// An installed game, as its appmanifest describes it. + public record InstalledGame(long AppId, string Name, string InstallDir); + /// /// Full path to the game's install folder (…\steamapps\common\<installdir>) if it exists on /// disk, else null (game not installed / Steam not found / unreadable). @@ -48,6 +57,56 @@ public partial class SteamLibraryService(SteamService steam) return null; } + /// + /// Every installed game across every library, from the appmanifests. Lazy, and skips anything whose + /// folder isn't actually on disk. + /// + /// + /// Enumerating the .acf files is the cheap part (~39 ms across three drives here); what costs is + /// whatever the caller then does per game. Yields rather than returning a list so a caller looking + /// for one thing can stop early. + /// + public IEnumerable EnumerateInstalled() + { + string? steamRoot = steam.EffectivePath; + if (steamRoot is null) yield break; + + foreach (string library in GetLibraryRoots(steamRoot)) + { + string steamapps = Path.Combine(library, "steamapps"); + string[] acfs; + try + { + if (!Directory.Exists(steamapps)) continue; + acfs = Directory.GetFiles(steamapps, "appmanifest_*.acf"); + } + catch { continue; } + + foreach (string acf in acfs) + { + InstalledGame? game = null; + try + { + string text = File.ReadAllText(acf); + + var idm = AppIdRegex().Match(text); + var dirm = InstallDirRegex().Match(text); + if (!idm.Success || !dirm.Success) continue; + if (!long.TryParse(idm.Groups[1].Value, out long appId)) continue; + + string full = Path.Combine(steamapps, "common", Unescape(dirm.Groups[1].Value)); + if (!Directory.Exists(full)) continue; + + var nm = NameRegex().Match(text); + game = new InstalledGame(appId, nm.Success ? nm.Groups[1].Value : appId.ToString(), full); + } + catch { /* unreadable or malformed acf: skip this one, not the whole library */ } + + if (game is not null) yield return game; + } + } + } + /// Every Steam library root (the main install plus any added libraries). private static IEnumerable GetLibraryRoots(string steamRoot) { diff --git a/src/LuaToolsGui/Services/SteamService.cs b/src/LuaToolsGui/Services/SteamService.cs index fcd0ba5..26c940b 100644 --- a/src/LuaToolsGui/Services/SteamService.cs +++ b/src/LuaToolsGui/Services/SteamService.cs @@ -74,8 +74,32 @@ public static string? SteamLanguage public string? StPlugInDir => EffectivePath is { } p ? Path.Combine(p, "config", "stplug-in") : null; - /// Full path to config\depotcache (where .manifest files go), or null if Steam isn't located. + /// + /// Full path to depotcache (where .manifest files go), or null if Steam isn't located. + /// + /// + /// This is <Steam>\depotcache, a SIBLING of steamapps — NOT config\depotcache. + /// Only stplug-in lives under config; that folder is SteamTools'. depotcache is Steam's own, and + /// Steam builds the path as %s/depotcache/%d_%llu.manifest off the install root + /// (the literal is in steamclient64.dll, listed beside /common, /downloading, /temp and /workshop). + /// + /// This was wrong until 2026-09-09 and it silently broke downloads: a manifest written to + /// config\depotcache is invisible to Steam, so a pinned depot resolves to nothing and the download + /// never starts, with no error anywhere. Measured on a real install — of 2,314 files in + /// config\depotcache, 2,313 were byte-identical copies of the real depotcache with mtimes preserved + /// (a stale one-off mirror), and the single file that was ONLY there was the manifest for the one + /// app that would not download. Meanwhile 394 manifests Steam had fetched itself existed only in + /// the real folder. So the read side was as wrong as the write side: cache hits on files Steam + /// cannot see, cache misses on files it already has. + /// public string? DepotCacheDir => + EffectivePath is { } p ? Path.Combine(p, "depotcache") : null; + + /// + /// The old, wrong location. Read-only fallback so manifests already sitting there still count as + /// cached instead of every install re-fetching its whole depot list once. Never written to. + /// + public string? LegacyDepotCacheDir => EffectivePath is { } p ? Path.Combine(p, "config", "depotcache") : null; /// Open a store/steam URL or file path with the shell (browser, Steam client, Explorer). diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index 556d84e..6decfa3 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -82,7 +82,7 @@ public partial class FixItemVm(DenuvoFix f) : ObservableObject private bool _gameInstalled; - /// True when the fix has been applied (its revert manifest exists on disk). Drives the Revert button. + /// True when the fix has been applied (its revert record exists on disk). Drives the Revert button. [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasApplied), nameof(CanDownloadFix), nameof(FixHint))] private bool _isApplied; @@ -177,9 +177,26 @@ protected override void OnPageSliced(IReadOnlyList 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); + /// + /// How many of the user's added games actually appear in the fix listing. + /// + /// + /// The count must be the INTERSECTION, not _installedAppIds.Count. The string reads "{0} of + /// your games have fixes", but the raw count is every game with a lua added — so a library with 243 + /// added games advertised 243 fixes while the filtered grid showed a dozen. This mirrors exactly what + /// ApplyFilter puts on screen when My games is on. + /// + 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) { @@ -359,9 +376,9 @@ private async Task OpenGame(FixGameCardVm game) foreach (var f in _allFixes) { f.GameInstalled = installDir is not null; - // Whether this specific fix has been applied (revert manifest on disk). + // Whether this specific fix has been applied (its revert record on disk). f.IsApplied = installDir is not null - && File.Exists(ManifestJobFactory.GetFixManifestPath(installDir, f.Id)); + && File.Exists(ManifestJobFactory.GetFixRecordPath(installDir, f.Id)); } // Build the per-game filter pills from the distinct tags across this game's fixes. @@ -435,13 +452,10 @@ 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; + // RevertDenuvoFix owns every revert toast (done / partial / conflict / not-found / no-record). + // Showing another one here meant a failed revert fired two toasts for one action. + if (result.Ok) fix.IsApplied = false; } private (FixItemVm Fix, FixGameCardVm Game)? _pendingRevert; diff --git a/tests/LuaToolsGui.Tests/DepotCacheMigrationTests.cs b/tests/LuaToolsGui.Tests/DepotCacheMigrationTests.cs new file mode 100644 index 0000000..e557347 --- /dev/null +++ b/tests/LuaToolsGui.Tests/DepotCacheMigrationTests.cs @@ -0,0 +1,253 @@ +using System.Buffers.Binary; +using System.IO; +using LuaToolsGui.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LuaToolsGui.Tests; + +/// +/// The one-time rescue of manifests this app used to write into config\depotcache, a folder Steam +/// never reads. +/// +/// +/// Worth testing carefully because it is the rare startup task that MOVES a user's files, silently, with +/// no UI and nothing to confirm. Two failure modes matter more than the happy path: +/// +/// +/// Moving a corrupt file into the real depotcache is permanent damage, not a retryable error. +/// LuaInstaller skips a destination that already exists, so a bad entry survives every later fetch +/// and that depot can never download again. +/// Overwriting a good file that Steam put there itself would destroy something we did not create. +/// +/// +/// So the tests below mostly pin down what the migration REFUSES to do. +/// +public class DepotCacheMigrationTests : IDisposable +{ + private readonly string _root = + Path.Combine(Path.GetTempPath(), "lt-depotcache-" + Guid.NewGuid().ToString("N")); + + private string Legacy => Path.Combine(_root, "config", "depotcache"); + private string Real => Path.Combine(_root, "depotcache"); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + GC.SuppressFinalize(this); + } + + private DepotCacheMigrationResult Run() => + DepotCacheMigrationService.Migrate(Legacy, Real, NullLogger.Instance); + + // ── Fixtures ───────────────────────────────────────────────────── + + /// + /// A byte-for-byte minimal real manifest: the 8-byte section header (metadata magic + length) + /// followed by a protobuf carrying just depot id (field 1) and gid (field 2). This is what + /// ManifestFile.TryRead actually looks for, so a synthetic one exercises the true gate + /// without committing a binary fixture or depending on the machine having Steam installed. + /// + private static byte[] ManifestBytes(long depotId, ulong gid) + { + var meta = new List(); + meta.Add(0x08); // field 1 (depot id), varint + meta.AddRange(Varint((ulong)depotId)); + meta.Add(0x10); // field 2 (gid), varint + meta.AddRange(Varint(gid)); + + var file = new byte[8 + meta.Count]; + BinaryPrimitives.WriteUInt32LittleEndian(file, 0x1F4812BE); // metadata magic + BinaryPrimitives.WriteUInt32LittleEndian(file.AsSpan(4), (uint)meta.Count); + meta.CopyTo(file, 8); + return file; + } + + private static IEnumerable Varint(ulong v) + { + while (v >= 0x80) { yield return (byte)(v | 0x80); v >>= 7; } + yield return (byte)v; + } + + private string WriteLegacy(string name, byte[] bytes) + { + Directory.CreateDirectory(Legacy); + string path = Path.Combine(Legacy, name); + File.WriteAllBytes(path, bytes); + return path; + } + + private string WriteReal(string name, byte[] bytes) + { + Directory.CreateDirectory(Real); + string path = Path.Combine(Real, name); + File.WriteAllBytes(path, bytes); + return path; + } + + // ── The happy path ─────────────────────────────────────────────── + + /// A manifest stranded where Steam cannot see it ends up where Steam can. + [Fact] + public void StrandedManifest_IsMovedIntoTheRealFolder() + { + byte[] bytes = ManifestBytes(4889481, 4624935753769628021); + WriteLegacy("4889481_4624935753769628021.manifest", bytes); + + var result = Run(); + + Assert.Equal(1, result.Moved); + Assert.Equal(0, result.Failed); + Assert.True(File.Exists(Path.Combine(Real, "4889481_4624935753769628021.manifest"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(Real, "4889481_4624935753769628021.manifest"))); + } + + /// Once everything is rescued, the empty legacy folder goes, so later launches skip early. + [Fact] + public void EmptiedLegacyFolder_IsRemoved() + { + WriteLegacy("1231561_6402543383554775381.manifest", ManifestBytes(1231561, 6402543383554775381)); + + Run(); + + Assert.False(Directory.Exists(Legacy)); + } + + /// Running twice must be indistinguishable from running once. + [Fact] + public void SecondRun_IsANoOp() + { + WriteLegacy("632361_5715419509320521739.manifest", ManifestBytes(632361, 5715419509320521739)); + + Assert.Equal(1, Run().Moved); + + var again = Run(); + Assert.True(again.IsEmpty); + Assert.True(File.Exists(Path.Combine(Real, "632361_5715419509320521739.manifest"))); + } + + /// No legacy folder at all — the normal state — must not even create the real one. + [Fact] + public void MissingLegacyFolder_IsANoOp() + { + var result = Run(); + + Assert.True(result.IsEmpty); + Assert.False(Directory.Exists(Real)); + } + + // ── What it refuses to do ──────────────────────────────────────── + + /// + /// A file Steam already has is never overwritten. The name is content-addressed, so the copy in the + /// legacy folder is the same bytes anyway — and a write there could fail on a file Steam holds open. + /// + [Fact] + public void ManifestAlreadyInTheRealFolder_IsNotOverwritten() + { + const string name = "2587891_452405881105540002.manifest"; + byte[] real = ManifestBytes(2587891, 452405881105540002); + WriteReal(name, real); + WriteLegacy(name, [0xDE, 0xAD, 0xBE, 0xEF]); // deliberately different, to prove it is not copied + + var result = Run(); + + Assert.Equal(0, result.Moved); + Assert.Equal(1, result.AlreadyPresent); + Assert.Equal(real, File.ReadAllBytes(Path.Combine(Real, name))); + } + + /// + /// A truncated or half-written file must stay put. Promoting it would poison that depot forever, + /// since the installer skips a destination that already exists. + /// + [Fact] + public void CorruptFile_IsNotMoved() + { + WriteLegacy("702350_7446681591222407330.manifest", [0x00, 0x01, 0x02, 0x03]); + + var result = Run(); + + Assert.Equal(0, result.Moved); + Assert.Equal(1, result.Rejected); + Assert.True(File.Exists(Path.Combine(Legacy, "702350_7446681591222407330.manifest"))); + } + + /// + /// A valid manifest under the wrong name is still wrong: the name is the lookup key, so moving it + /// would answer a future cache probe with someone else's depot. + /// + [Fact] + public void ManifestWhoseContentContradictsItsName_IsNotMoved() + { + WriteLegacy("991352_48810978170646456.manifest", ManifestBytes(883719, 2758490342177349494)); + + var result = Run(); + + Assert.Equal(0, result.Moved); + Assert.Equal(1, result.Rejected); + } + + [Theory] + [InlineData("notamanifest.manifest")] // no separator + [InlineData("_123.manifest")] // empty depot + [InlineData("883719_.manifest")] // empty gid + [InlineData("abc_123.manifest")] // non-numeric depot + [InlineData("883719_notagid.manifest")] // non-numeric gid + public void UnrecognisedName_IsNotMoved(string name) + { + WriteLegacy(name, ManifestBytes(883719, 2758490342177349494)); + + var result = Run(); + + Assert.Equal(0, result.Moved); + Assert.Equal(1, result.Rejected); + Assert.True(File.Exists(Path.Combine(Legacy, name))); + } + + /// Anything that is not a .manifest is none of this task's business. + [Fact] + public void UnrelatedFiles_AreLeftAlone() + { + Directory.CreateDirectory(Legacy); + File.WriteAllText(Path.Combine(Legacy, "notes.txt"), "keep me"); + + var result = Run(); + + Assert.True(result.IsEmpty); + Assert.Equal("keep me", File.ReadAllText(Path.Combine(Legacy, "notes.txt"))); + Assert.True(Directory.Exists(Legacy)); // not empty, so not removed + } + + /// + /// If both paths ever resolved to the same folder, a move would delete the file. Guarded, and pinned + /// here so nobody removes the guard as dead code. + /// + [Fact] + public void SameFolderForBoth_IsRefused() + { + Directory.CreateDirectory(Real); + string path = Path.Combine(Real, "883719_2758490342177349494.manifest"); + File.WriteAllBytes(path, ManifestBytes(883719, 2758490342177349494)); + + var result = DepotCacheMigrationService.Migrate(Real, Real, NullLogger.Instance); + + Assert.True(result.IsEmpty); + Assert.True(File.Exists(path)); + } + + /// A rejected file keeps the folder alive, so nothing silently disappears with it. + [Fact] + public void LeftoversKeepTheLegacyFolder() + { + WriteLegacy("702350_7446681591222407330.manifest", [0x00]); // rejected + WriteLegacy("991352_48810978170646456.manifest", ManifestBytes(991352, 48810978170646456)); // moved + + var result = Run(); + + Assert.Equal(1, result.Moved); + Assert.Equal(1, result.Rejected); + Assert.True(Directory.Exists(Legacy)); + Assert.True(File.Exists(Path.Combine(Legacy, "702350_7446681591222407330.manifest"))); + } +} diff --git a/tests/LuaToolsGui.Tests/FileHashTests.cs b/tests/LuaToolsGui.Tests/FileHashTests.cs new file mode 100644 index 0000000..ee32243 --- /dev/null +++ b/tests/LuaToolsGui.Tests/FileHashTests.cs @@ -0,0 +1,113 @@ +using System.IO; +using LuaToolsGui.Services; +using Xunit; + +namespace LuaToolsGui.Tests; + +/// +/// The hash check a fix revert leans on to decide whether the file on disk is still the one it wrote. +/// +/// +/// The behaviour that matters most here is the one that looks like a bug: +/// returns TRUE for a null expected hash. Records written before hashing existed have no hashes, and an +/// absent hash has to mean "nothing to check" rather than "check failed" — otherwise every fix applied by +/// an older build would become permanently un-revertable the moment the user updated. +/// +public class FileHashTests : IDisposable +{ + private readonly string _dir = + Path.Combine(Path.GetTempPath(), "lt-hash-" + Guid.NewGuid().ToString("N")); + + public FileHashTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + GC.SuppressFinalize(this); + } + + private string Write(string name, string content) + { + string p = Path.Combine(_dir, name); + File.WriteAllText(p, content); + return p; + } + + /// A known vector, so a future "optimisation" can't quietly change the algorithm. + [Fact] + public void Sha256_MatchesTheKnownVectorForAbc() + { + string p = Path.Combine(_dir, "abc.bin"); + File.WriteAllBytes(p, "abc"u8.ToArray()); + + Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", FileHash.Sha256(p)); + } + + [Fact] + public void Sha256_IsNullForAMissingFile() => + Assert.Null(FileHash.Sha256(Path.Combine(_dir, "nope.bin"))); + + [Fact] + public void Sha256_IsNullForNullOrEmptyPath() + { + Assert.Null(FileHash.Sha256(null)); + Assert.Null(FileHash.Sha256(" ")); + } + + [Fact] + public void Sha256_IsReadableWhileAnotherHandleIsOpen() + { + string p = Write("locked.bin", "payload"); + + // FileShare.ReadWrite: a game or another tool holding the file must not turn into "cannot verify". + using var hold = new FileStream(p, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + + Assert.NotNull(FileHash.Sha256(p)); + } + + [Fact] + public void Matches_IsTrueForTheSameContent() + { + string p = Write("a.bin", "same"); + Assert.True(FileHash.Matches(p, FileHash.Sha256(p))); + } + + [Fact] + public void Matches_IsFalseOnceTheContentChanges() + { + string p = Write("b.bin", "before"); + string? original = FileHash.Sha256(p); + + File.WriteAllText(p, "after"); + + Assert.False(FileHash.Matches(p, original)); + } + + /// Hex casing is an encoding detail, not a difference in content. + [Fact] + public void Matches_IgnoresHexCase() + { + string p = Write("c.bin", "case"); + Assert.True(FileHash.Matches(p, FileHash.Sha256(p)!.ToUpperInvariant())); + } + + /// + /// The compatibility rule, spelled out: no recorded hash means the check passes. Pre-hash fix records + /// must keep reverting exactly as they did before hashing was added. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void Matches_TreatsAnAbsentHashAsNothingToCheck(string? expected) + { + string p = Write("d.bin", "whatever"); + Assert.True(FileHash.Matches(p, expected)); + } + + /// But a real expected hash against a missing file is a genuine mismatch, not a pass. + [Fact] + public void Matches_IsFalseWhenTheFileIsGoneButAHashWasExpected() => + Assert.False(FileHash.Matches( + Path.Combine(_dir, "missing.bin"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); +} From 5e8c8abca18c8f28629d2c59ea1aaa0cf1549e75 Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Fri, 25 Sep 2026 17:09:52 +1000 Subject: [PATCH 27/29] Fix Steam appdetails matching by appid Steam can return appdetails keyed by a child app id instead of the requested app, which previously caused silent cache misses or wrong-game data. This change resolves entries by validating data.steam_appid, refuses ambiguous or malformed payloads rather than guessing, logs once per app when no match can be proven, and adds regression tests covering child-keyed, direct-key, and refusal edge cases. --- src/LuaToolsGui/Services/SteamAppInfoCache.cs | 114 ++++++++++++++++-- .../SteamAppInfoCacheTests.cs | 100 +++++++++++++++ 2 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 tests/LuaToolsGui.Tests/SteamAppInfoCacheTests.cs diff --git a/src/LuaToolsGui/Services/SteamAppInfoCache.cs b/src/LuaToolsGui/Services/SteamAppInfoCache.cs index 09f5aed..3264a15 100644 --- a/src/LuaToolsGui/Services/SteamAppInfoCache.cs +++ b/src/LuaToolsGui/Services/SteamAppInfoCache.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using LuaToolsGui.Models; +using Microsoft.Extensions.Logging; namespace LuaToolsGui.Services; @@ -84,9 +85,12 @@ public class SteamAppInfoCache private const int MaxPerWindow = 190; // small safety margin under the measured 200 private DateTime _lastPersist = DateTime.MinValue; - public SteamAppInfoCache(CacheService cache) + private readonly ILogger _log; + + public SteamAppInfoCache(CacheService cache, ILogger log) { _cache2 = cache; + _log = log; // Restore the rolling window from a previous run so we don't burst fresh into a counting window. var now = DateTime.UtcNow; foreach (long ms in _cache2.GetSteamApiRequestTimes()) @@ -132,6 +136,68 @@ public static string GuessHeaderImageUrl(long appid) => catch { return null; } // corrupt/partial blob → treat as not-cached } + // ── appdetails response shape ──────────────────────────────────── + + /// + /// Pick out the appdetails entry that actually describes . + /// Null when no entry in the response can be PROVEN to be this app's. + /// + /// + /// appdetails used to be keyed by the appid you asked for. Since ~Sep 2026, any app that HAS child + /// apps (a DLC, a soundtrack) comes back keyed by one of those CHILD ids instead: appids=500 answers + /// under "1660740" ("Left 4 Dead - Uncensored", whose parent is 500 and which itself returns + /// success:false when queried on its own). Measured the same way for 730→2678630, 620→323180, + /// 220→323140, 243470→293061 and 1091500→2441600. Apps with no child apps (10, 240, 271590, 5080530) + /// and EVERY success:false body are still keyed by the requested id. The inner data.steam_appid always + /// echoes the id we asked for; it is the only trustworthy anchor left, and filters=basic changes none + /// of this. Do not "simplify" the scan away. + /// + /// There is deliberately no "if the object has exactly one entry, take it" fallback. It would look + /// like it fixes more cases, but its failure mode is silent and permanent: the wrong game's name, + /// header image, genres and release date get written to details\<appid>.json, which is the single + /// on-disk source of truth for this app from then on. Resolving nothing costs one wasted request and + /// self-heals on the next ship; resolving the wrong game needs the user to find and delete their + /// cache. Refuse rather than guess. + /// + /// Returns JsonElement? rather than throwing on purpose: GetProperty(appid) throwing + /// KeyNotFoundException into a bare catch is exactly what made this bug invisible for so long. + /// internal, not private, so the shape tolerance is unit-testable with no HTTP stub + /// (InternalsVisibleTo LuaToolsGui.Tests). + /// + internal static JsonElement? FindAppDetailsEntry(JsonElement root, long appid) + { + if (root.ValueKind != JsonValueKind.Object) return null; + + // 1. The direct key. Still correct for childless apps, and the ONLY way a success:false body can + // ever be matched (it carries no "data" object to anchor on). Refused only if it DOES carry a + // data object and that object names some other app. + if (root.TryGetProperty(appid.ToString(), out var direct) && + (!TryGetDataObject(direct, out var directData) || DataNamesApp(directData, appid))) + return direct; + + // 2. Child-keyed shape: the key is a DLC/soundtrack id, so scan for the entry whose + // data.steam_appid is the app we actually asked for. + foreach (var prop in root.EnumerateObject()) + if (TryGetDataObject(prop.Value, out var data) && DataNamesApp(data, appid)) + return prop.Value; + + return null; + } + + /// The entry's "data" object, if it has one. A success:false entry does not. + private static bool TryGetDataObject(JsonElement entry, out JsonElement data) + { + data = default; + return entry.ValueKind == JsonValueKind.Object && + entry.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object; + } + + /// True when this data blob's steam_appid is the app we asked for. A missing or non-numeric + /// steam_appid counts as "not proven", so it is refused rather than assumed. + private static bool DataNamesApp(JsonElement data, long appid) => + data.TryGetProperty("steam_appid", out var sa) && sa.ValueKind == JsonValueKind.Number && + sa.TryGetInt64(out long got) && got == appid; + /// Fetch an app's name + header image from Steam (throttled, retries on 429/403). Null on /// failure. Pulls the FULL appdetails payload and caches the whole blob (for filters). Name/header /// are derived from it, so each app is only ever fetched once. @@ -158,16 +224,25 @@ public static string GuessHeaderImageUrl(long appid) => if (!res.IsSuccessStatusCode) return null; using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - var entry = doc.RootElement.GetProperty(appid.ToString()); - if (!entry.GetProperty("success").GetBoolean()) + if (FindAppDetailsEntry(doc.RootElement, appid) is not { } entry) + { + // Steam changed the response shape again. Resolve nothing, cache nothing — a null + // here already means "couldn't resolve right now" to every caller. + LogUnresolvableOnce(appid); + return null; + } + + // success:false carries no "data" at all, and a success:true with no usable data object + // is just as empty. Either way Steam has nothing for this app. + if (!entry.TryGetProperty("success", out var okEl) || okEl.ValueKind != JsonValueKind.True || + !entry.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) { // Delisted/unavailable → no details to cache for filters; mark so backfill skips it. _ = SaveFullDetailsAsync(appid, "{}"); return null; } - var data = entry.GetProperty("data"); - string? name = data.GetProperty("name").GetString(); + string? name = data.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null; if (string.IsNullOrWhiteSpace(name)) return null; string? image = data.TryGetProperty("header_image", out var img) ? img.GetString() : null; @@ -286,6 +361,21 @@ public static string GuessHeaderImageUrl(long appid) => private readonly ConcurrentDictionary _filterCache = new(); private readonly ConcurrentDictionary _overviewCache = new(); + // Appids whose appdetails entry we could not identify at all this session. Used ONLY to keep the log + // to one line per app — deliberately nothing is persisted for them (see FindAppDetailsEntry: a "{}" + // marker here would permanently poison a perfectly good game's cache just to save a few requests). + private readonly ConcurrentDictionary _unresolvable = new(); + + /// Report, once per app per session, that no entry in the response could be proven to be + /// this app's. In normal operation this never fires; if it starts firing, Steam changed the response + /// shape again and needs another case. + private void LogUnresolvableOnce(long appid) + { + if (_unresolvable.TryAdd(appid, 0)) + _log.LogWarning("appdetails: no entry with steam_appid={AppId} in the response — Steam's " + + "response shape changed again. Nothing cached for this app.", appid); + } + /// /// Blurb + studio + genres from the cached blob, for the Manage flyout. Null when the app has no /// cached details yet, or the blob is the "{}" delisted marker. The caller collapses the section @@ -420,8 +510,17 @@ public async Task EnsureFullDetailsAsync(long appid, CancellationToken ct if (!res.IsSuccessStatusCode) return false; using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - var entry = doc.RootElement.GetProperty(appid.ToString()); - if (!entry.GetProperty("success").GetBoolean()) + if (FindAppDetailsEntry(doc.RootElement, appid) is not { } entry) + { + // Nothing landed on disk, so say so. Deliberately NOT the "{}" marker: that is + // permanent (HasFullDetails would return true forever, so nothing ever re-fetches), + // and poisoning a healthy game's cache is far worse than a few retried requests. + LogUnresolvableOnce(appid); + return false; + } + + if (!entry.TryGetProperty("success", out var okEl) || okEl.ValueKind != JsonValueKind.True || + !entry.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) { // Delisted / unavailable even in a neutral region → won't ever resolve. Cache an // empty marker so backfill stops retrying and it's not counted as "still fetching". @@ -429,7 +528,6 @@ public async Task EnsureFullDetailsAsync(long appid, CancellationToken ct return true; } - var data = entry.GetProperty("data"); await SaveFullDetailsAsync(appid, data.GetRawText()); // Opportunistically warm the session RAM index too if it's missing. diff --git a/tests/LuaToolsGui.Tests/SteamAppInfoCacheTests.cs b/tests/LuaToolsGui.Tests/SteamAppInfoCacheTests.cs new file mode 100644 index 0000000..f1277ce --- /dev/null +++ b/tests/LuaToolsGui.Tests/SteamAppInfoCacheTests.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using LuaToolsGui.Services; +using Xunit; + +namespace LuaToolsGui.Tests; + +/// +/// How an appdetails response is matched back to the appid that was requested. This is load-bearing and +/// it is not obvious: since ~Sep 2026 Steam keys the response by a CHILD app's id (a DLC or a soundtrack) +/// for any app that has one, while the inner data.steam_appid still echoes what was asked for. Before +/// that was handled, GetProperty(appid) threw KeyNotFoundException straight into a bare catch, so every +/// popular game silently failed to resolve and the Manage backfill re-requested it forever. +/// +public class SteamAppInfoCacheTests +{ + /// The returned element points into the document's buffer, so clone before it is disposed. + private static JsonElement? Find(string json, long appid) + { + using var doc = JsonDocument.Parse(json); + return SteamAppInfoCache.FindAppDetailsEntry(doc.RootElement, appid)?.Clone(); + } + + private static string Name(JsonElement? entry) => + entry!.Value.GetProperty("data").GetProperty("name").GetString()!; + + /// The bug itself: 500 ("Left 4 Dead") answers under 1660740 ("Left 4 Dead - Uncensored"). + [Fact] + public void ChildKeyedResponse_IsMatchedByInnerSteamAppid() + { + var entry = Find("""{"1660740":{"success":true,"data":{"steam_appid":500,"name":"Left 4 Dead"}}}""", 500); + + Assert.NotNull(entry); + Assert.Equal("Left 4 Dead", Name(entry)); + } + + /// Apps with no child apps are still keyed normally, and must keep working unchanged. + [Fact] + public void SameKeyedResponse_StillResolves() + { + var entry = Find("""{"10":{"success":true,"data":{"steam_appid":10,"name":"Counter-Strike"}}}""", 10); + + Assert.NotNull(entry); + Assert.Equal("Counter-Strike", Name(entry)); + } + + /// + /// A failed lookup has no "data" to anchor on, so it can only ever be found by its key. It has to be + /// found: that is what writes the "{}" delisted marker that stops the backfill retrying the app. + /// + [Fact] + public void FailedLookup_IsStillFoundByItsKey() + { + var entry = Find("""{"999999999":{"success":false}}""", 999999999); + + Assert.NotNull(entry); + Assert.False(entry!.Value.GetProperty("success").GetBoolean()); + } + + /// + /// The important refusal. There is exactly one entry and it looks plausible, but it describes a + /// different app. Taking it would write the wrong game's name and art into detailsŀ.json + /// permanently, so "no match" is the correct answer even though it resolves nothing. + /// + [Fact] + public void EntryForAnotherApp_IsRefusedRatherThanGuessed() => + Assert.Null(Find("""{"1660740":{"success":true,"data":{"steam_appid":1660740,"name":"Uncensored"}}}""", 500)); + + /// An unprovable payload is refused rather than assumed, in every malformed variant. + [Theory] + [InlineData("{}")] // empty object + [InlineData("[]")] // not an object at all + [InlineData("""{"1660740":{"success":true,"data":{"name":"L4D"}}}""")] // no steam_appid + [InlineData("""{"1660740":{"success":true,"data":{"steam_appid":"500"}}}""")] // string, not number + [InlineData("""{"1660740":{"success":true}}""")] // success but no data + [InlineData("""{"1660740":"nonsense"}""")] // entry isn't an object + public void UnprovablePayloads_AreRefused(string json) => Assert.Null(Find(json, 500)); + + /// + /// Documents the ordering: the direct key wins over the scan, so the cheap path stays the common path + /// and a sibling entry can never shadow the app's own. + /// + [Fact] + public void DirectKey_WinsOverTheScan() + { + var entry = Find(""" + {"500":{"success":true,"data":{"steam_appid":500,"name":"Left 4 Dead"}}, + "1660740":{"success":true,"data":{"steam_appid":500,"name":"Uncensored"}}} + """, 500); + + Assert.Equal("Left 4 Dead", Name(entry)); + } + + /// + /// If Steam ever keys an entry by our appid but fills it with another app's data, the key is not + /// trusted either — data.steam_appid is the anchor, not the key. + /// + [Fact] + public void DirectKeyDescribingAnotherApp_IsRefused() => + Assert.Null(Find("""{"500":{"success":true,"data":{"steam_appid":1660740,"name":"Uncensored"}}}""", 500)); +} From 5f729740a38e5d6e2b6e958ea6ba46e091b9d8f7 Mon Sep 17 00:00:00 2001 From: mendy-tools Date: Fri, 25 Sep 2026 17:58:20 +1000 Subject: [PATCH 28/29] Add Cloudflare DNS fallback This adds a per-connection DNS mode option for the app, with Auto/Always/Never behavior backed by a shared AppHttp handler and a Cloudflare DoH resolver. The change centralizes HTTP clients, keeps settings-driven resolution applied across the app, routes GitHub and update downloads through the same policy, and adds localized settings strings and tests covering resolver bypass, caching, and parsing. --- src/LuaToolsGui/App.xaml.cs | 6 + src/LuaToolsGui/Resources/Strings.Designer.cs | 5 + src/LuaToolsGui/Resources/Strings.ar.resx | 5 + src/LuaToolsGui/Resources/Strings.bg.resx | 5 + src/LuaToolsGui/Resources/Strings.cs.resx | 5 + src/LuaToolsGui/Resources/Strings.da.resx | 5 + src/LuaToolsGui/Resources/Strings.de.resx | 5 + src/LuaToolsGui/Resources/Strings.el.resx | 5 + src/LuaToolsGui/Resources/Strings.es-419.resx | 5 + src/LuaToolsGui/Resources/Strings.es.resx | 5 + src/LuaToolsGui/Resources/Strings.fi.resx | 5 + src/LuaToolsGui/Resources/Strings.fr.resx | 5 + src/LuaToolsGui/Resources/Strings.hu.resx | 5 + src/LuaToolsGui/Resources/Strings.id.resx | 5 + src/LuaToolsGui/Resources/Strings.it.resx | 5 + src/LuaToolsGui/Resources/Strings.ja.resx | 5 + src/LuaToolsGui/Resources/Strings.ko.resx | 5 + src/LuaToolsGui/Resources/Strings.nb.resx | 5 + src/LuaToolsGui/Resources/Strings.nl.resx | 5 + src/LuaToolsGui/Resources/Strings.pl.resx | 5 + src/LuaToolsGui/Resources/Strings.pt-BR.resx | 5 + src/LuaToolsGui/Resources/Strings.pt-PT.resx | 5 + src/LuaToolsGui/Resources/Strings.resx | 5 + src/LuaToolsGui/Resources/Strings.ro.resx | 5 + src/LuaToolsGui/Resources/Strings.ru.resx | 5 + src/LuaToolsGui/Resources/Strings.sv.resx | 5 + src/LuaToolsGui/Resources/Strings.th.resx | 5 + src/LuaToolsGui/Resources/Strings.tr.resx | 5 + src/LuaToolsGui/Resources/Strings.uk.resx | 5 + src/LuaToolsGui/Resources/Strings.vi.resx | 5 + .../Resources/Strings.zh-Hans.resx | 5 + .../Resources/Strings.zh-Hant.resx | 5 + src/LuaToolsGui/Services/AnalyticsService.cs | 4 +- src/LuaToolsGui/Services/AppHttp.cs | 160 +++++++++++++ src/LuaToolsGui/Services/AuthService.cs | 4 +- .../Services/CefInjectorService.cs | 4 +- src/LuaToolsGui/Services/CoverCache.cs | 4 +- src/LuaToolsGui/Services/DohResolver.cs | 143 ++++++++++++ src/LuaToolsGui/Services/DonateKeysService.cs | 4 +- src/LuaToolsGui/Services/GithubProxy.cs | 4 +- src/LuaToolsGui/Services/HubcapService.cs | 9 +- src/LuaToolsGui/Services/LuaToolsApiClient.cs | 10 +- .../Services/PluginInstallerService.cs | 2 +- .../Services/ProxiedFileDownloader.cs | 79 ++++++- src/LuaToolsGui/Services/SettingsService.cs | 20 ++ src/LuaToolsGui/Services/SteamAppInfoCache.cs | 4 +- src/LuaToolsGui/Services/SteamAppListCache.cs | 4 +- src/LuaToolsGui/Services/SteamDepotInfo.cs | 4 +- .../ViewModels/SettingsViewModel.cs | 26 +++ src/LuaToolsGui/Views/SettingsView.xaml | 60 +++-- tests/LuaToolsGui.Tests/DohResolverTests.cs | 214 ++++++++++++++++++ 51 files changed, 866 insertions(+), 54 deletions(-) create mode 100644 src/LuaToolsGui/Services/AppHttp.cs create mode 100644 src/LuaToolsGui/Services/DohResolver.cs create mode 100644 tests/LuaToolsGui.Tests/DohResolverTests.cs diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index f719494..792698f 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -225,6 +225,12 @@ protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); + // Point the shared HTTP handler at the DNS setting before anything makes a request. It calls + // this per connection rather than reading it now, so the order is not load-bearing — but doing + // it first means the very first call of the session already honours the user's choice. + var dnsSettings = _host.Services.GetRequiredService(); + AppHttp.ModeProvider = () => dnsSettings.DnsMode; + // Legacy cleanup: older builds staged downloads in ~/Downloads/LuaTools (they now stage in // %TEMP% and self-delete). Remove any leftovers from that user-visible folder, best-effort. // Also sweep the current %TEMP% staging folder: a crash mid-download, or an overwrite confirm diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 51974f0..a67f0f8 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -72,6 +72,11 @@ public static class Strings public static string Settings_Language => Get(nameof(Settings_Language)); public static string Settings_Language_Hint => Get(nameof(Settings_Language_Hint)); public static string Settings_Language_SystemDefault => Get(nameof(Settings_Language_SystemDefault)); + public static string Settings_Dns => Get(nameof(Settings_Dns)); + public static string Settings_Dns_Hint => Get(nameof(Settings_Dns_Hint)); + public static string Settings_Dns_Auto => Get(nameof(Settings_Dns_Auto)); + public static string Settings_Dns_Always => Get(nameof(Settings_Dns_Always)); + public static string Settings_Dns_Never => Get(nameof(Settings_Dns_Never)); public static string Settings_SteamLocation => Get(nameof(Settings_SteamLocation)); public static string Settings_Change => Get(nameof(Settings_Change)); public static string Settings_Open => Get(nameof(Settings_Open)); diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 093b4f9..5bafb68 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -111,6 +111,11 @@ اللغة اختر لغة عرض التطبيق. تُطبَّق بعد إعادة التشغيل. افتراضي النظام + تحليل DNS + إذا كان مزوّد خدمتك يحجب lua.tools، يمكن للتطبيق تحليل العناوين عبر Cloudflare (1.1.1.1) بدلاً من محلّل النظام. الوضع «تلقائي» لا ينتقل إلا بعد فشل DNS المعتاد. + تلقائي + استخدم Cloudflare دائمًا + استخدم DNS النظام فقط موقع Steam تغيير… فتح diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 8f59b22..d5577de 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -109,6 +109,11 @@ Език Изберете езика на приложението. Прилага се след рестартиране. По подразбиране от системата + DNS разрешаване + Ако доставчикът ви блокира lua.tools, приложението може да разрешава адреси през Cloudflare (1.1.1.1) вместо системния разрешител. „Автоматично“ превключва само след неуспех на обичайния DNS. + Автоматично + Винаги използвай Cloudflare + Само системен DNS Местоположение на Steam Промяна… Отваряне diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 0647ca8..4bfe589 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -109,6 +109,11 @@ Jazyk Vyberte jazyk zobrazení aplikace. Použije se po restartu. Výchozí systému + Překlad DNS + Pokud váš poskytovatel blokuje lua.tools, aplikace může adresy překládat přes Cloudflare (1.1.1.1) místo systémového překladače. „Automaticky“ se přepne až po selhání běžného DNS. + Automaticky + Vždy použít Cloudflare + Používat jen systémové DNS Umístění Steamu Změnit… Otevřít diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 35c7454..34a6e54 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -109,6 +109,11 @@ Sprog Vælg appens visningssprog. Anvendes efter en genstart. Systemstandard + DNS-opslag + Hvis din udbyder blokerer lua.tools, kan appen slå adresser op via Cloudflare (1.1.1.1) i stedet for systemets navneopslag. »Automatisk« skifter først, når dit normale DNS fejler. + Automatisk + Brug altid Cloudflare + Brug kun systemets DNS Steam-placering Skift… Åbn diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index 738ebdb..e5d79d5 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -109,6 +109,11 @@ Sprache Wähle die Anzeigesprache der App. Wird nach einem Neustart übernommen. Systemstandard + DNS-Auflösung + Wenn dein Anbieter lua.tools blockiert, kann die App Adressen über Cloudflare (1.1.1.1) statt über den System-Resolver auflösen. „Automatisch“ wechselt erst, wenn dein normales DNS fehlschlägt. + Automatisch + Immer Cloudflare verwenden + Nur System-DNS verwenden Steam-Speicherort Ändern… Öffnen diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index af801f6..7b344bd 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -109,6 +109,11 @@ Γλώσσα Επιλέξτε τη γλώσσα εμφάνισης της εφαρμογής. Εφαρμόζεται μετά από επανεκκίνηση. Προεπιλογή συστήματος + Ανάλυση DNS + Αν ο πάροχός σας μπλοκάρει το lua.tools, η εφαρμογή μπορεί να αναλύει διευθύνσεις μέσω Cloudflare (1.1.1.1) αντί για τον αναλυτή του συστήματος. Η «Αυτόματη» λειτουργία αλλάζει μόνο αφού αποτύχει το κανονικό DNS. + Αυτόματα + Πάντα Cloudflare + Μόνο DNS συστήματος Τοποθεσία Steam Αλλαγή… Άνοιγμα diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index 658a93e..8412488 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -109,6 +109,11 @@ Idioma Elige el idioma de la aplicación. Se aplica tras reiniciar. Predeterminado del sistema + Resolución DNS + Si tu proveedor bloquea lua.tools, la aplicación puede resolver direcciones mediante Cloudflare (1.1.1.1) en lugar del sistema. «Automático» solo cambia después de que falle tu DNS normal. + Automático + Usar siempre Cloudflare + Usar solo el DNS del sistema Ubicación de Steam Cambiar… Abrir diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index 74d5eeb..c0eecff 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -109,6 +109,11 @@ Idioma Elige el idioma de la aplicación. Se aplica tras reiniciar. Predeterminado del sistema + Resolución DNS + Si tu proveedor bloquea lua.tools, la aplicación puede resolver direcciones mediante Cloudflare (1.1.1.1) en lugar del sistema. «Automático» solo cambia después de que falle tu DNS normal. + Automático + Usar siempre Cloudflare + Usar solo el DNS del sistema Ubicación de Steam Cambiar… Abrir diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index 783691b..5ddea00 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -109,6 +109,11 @@ Kieli Valitse sovelluksen näyttökieli. Otetaan käyttöön uudelleenkäynnistyksen jälkeen. Järjestelmän oletus + DNS-selvitys + Jos operaattorisi estää lua.tools-sivuston, sovellus voi selvittää osoitteet Cloudflaren (1.1.1.1) kautta järjestelmän selvittäjän sijaan. ”Automaattinen” vaihtaa vasta, kun tavallinen DNS epäonnistuu. + Automaattinen + Käytä aina Cloudflarea + Käytä vain järjestelmän DNS:ää Steamin sijainti Muuta… Avaa diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 0874247..8bcece2 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -109,6 +109,11 @@ Langue Choisissez la langue d'affichage de l'application. Appliquée après un redémarrage. Par défaut du système + Résolution DNS + Si votre fournisseur bloque lua.tools, l'application peut résoudre les adresses via Cloudflare (1.1.1.1) plutôt que par le résolveur système. « Automatique » ne bascule qu'après l'échec de votre DNS habituel. + Automatique + Toujours utiliser Cloudflare + Utiliser uniquement le DNS système Emplacement de Steam Modifier… Ouvrir diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index d7d6d6e..e162642 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -109,6 +109,11 @@ Nyelv Válaszd ki az alkalmazás megjelenítési nyelvét. Újraindítás után lép érvénybe. Rendszer alapértelmezett + DNS-feloldás + Ha a szolgáltatód blokkolja a lua.tools-t, az alkalmazás a rendszer feloldója helyett a Cloudflare-en (1.1.1.1) keresztül oldhatja fel a címeket. Az „Automatikus” csak a szokásos DNS hibája után vált át. + Automatikus + Mindig a Cloudflare használata + Csak a rendszer DNS-e Steam helye Módosítás… Megnyitás diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index ce783f7..5600969 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -109,6 +109,11 @@ Bahasa Pilih bahasa tampilan aplikasi. Diterapkan setelah memulai ulang. Bawaan sistem + Resolusi DNS + Jika penyedia Anda memblokir lua.tools, aplikasi dapat mencari alamat melalui Cloudflare (1.1.1.1) alih-alih resolver sistem. «Otomatis» hanya beralih setelah DNS biasa gagal. + Otomatis + Selalu gunakan Cloudflare + Hanya gunakan DNS sistem Lokasi Steam Ubah… Buka diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index 2797ab2..0d50239 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -109,6 +109,11 @@ Lingua Scegli la lingua dell'app. Viene applicata dopo un riavvio. Predefinita di sistema + Risoluzione DNS + Se il tuo provider blocca lua.tools, l'app può risolvere gli indirizzi tramite Cloudflare (1.1.1.1) invece del resolver di sistema. «Automatico» passa solo dopo che il DNS normale fallisce. + Automatico + Usa sempre Cloudflare + Usa solo il DNS di sistema Percorso di Steam Cambia… Apri diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index d502532..f69e014 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -109,6 +109,11 @@ 言語 アプリの表示言語を選択します。再起動後に適用されます。 システム既定 + DNS 解決 + プロバイダーが lua.tools をブロックしている場合、システムのリゾルバーではなく Cloudflare (1.1.1.1) 経由でアドレスを解決できます。「自動」は通常の DNS が失敗したときにのみ切り替わります。 + 自動 + 常に Cloudflare を使う + システム DNS のみ Steamの場所 変更… 開く diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index d3fce87..05b0f4f 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -109,6 +109,11 @@ 언어 앱의 표시 언어를 선택하세요. 재시작 후 적용됩니다. 시스템 기본값 + DNS 확인 + 인터넷 사업자가 lua.tools를 차단하는 경우, 시스템 확인자 대신 Cloudflare(1.1.1.1)를 통해 주소를 조회할 수 있습니다. ‘자동’은 일반 DNS가 실패한 뒤에만 전환됩니다. + 자동 + 항상 Cloudflare 사용 + 시스템 DNS만 사용 Steam 위치 변경… 열기 diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 2daa452..65d32b9 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -109,6 +109,11 @@ Språk Velg appens visningsspråk. Tas i bruk etter en omstart. Systemstandard + DNS-oppslag + Hvis leverandøren din blokkerer lua.tools, kan appen slå opp adresser via Cloudflare (1.1.1.1) i stedet for systemets oppslag. «Automatisk» bytter først når vanlig DNS feiler. + Automatisk + Bruk alltid Cloudflare + Bruk bare systemets DNS Steam-plassering Endre… Åpne diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index eed0fbd..4be97ff 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -109,6 +109,11 @@ Taal Kies de weergavetaal van de app. Wordt toegepast na een herstart. Systeemstandaard + DNS-resolutie + Als je provider lua.tools blokkeert, kan de app adressen opzoeken via Cloudflare (1.1.1.1) in plaats van je systeemresolver. 'Automatisch' schakelt pas over nadat je normale DNS faalt. + Automatisch + Altijd Cloudflare gebruiken + Alleen systeem-DNS gebruiken Steam-locatie Wijzigen… Openen diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index 00b2879..4fdffbf 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -109,6 +109,11 @@ Język Wybierz język wyświetlania aplikacji. Stosowane po ponownym uruchomieniu. Domyślny systemu + Rozwiązywanie DNS + Jeśli Twój dostawca blokuje lua.tools, aplikacja może rozwiązywać adresy przez Cloudflare (1.1.1.1) zamiast resolwera systemowego. „Automatycznie” przełącza się dopiero po awarii zwykłego DNS. + Automatycznie + Zawsze używaj Cloudflare + Używaj tylko systemowego DNS Lokalizacja Steam Zmień… Otwórz diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index 08cde61..1ea76f4 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -109,6 +109,11 @@ Idioma Escolha o idioma de exibição do app. Aplicado após reiniciar. Padrão do sistema + Resolução de DNS + Se o seu provedor bloqueia lua.tools, o app pode resolver endereços pela Cloudflare (1.1.1.1) em vez do resolvedor do sistema. «Automático» só muda depois que o seu DNS normal falha. + Automático + Sempre usar a Cloudflare + Usar apenas o DNS do sistema Local do Steam Alterar… Abrir diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index 768f61c..d17fe9b 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -109,6 +109,11 @@ Idioma Escolha o idioma de apresentação da aplicação. Aplicado após reiniciar. Predefinição do sistema + Resolução de DNS + Se o seu fornecedor bloqueia lua.tools, a aplicação pode resolver endereços pela Cloudflare (1.1.1.1) em vez do sistema. «Automático» só muda depois de o seu DNS normal falhar. + Automático + Usar sempre a Cloudflare + Usar apenas o DNS do sistema Localização do Steam Alterar… Abrir diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index fe4c107..3799bb6 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -111,6 +111,11 @@ Language Choose the app's display language. Applies after a restart. System default + DNS resolution + If your provider blocks lua.tools, the app can look up addresses through Cloudflare (1.1.1.1) instead of your system resolver. Automatic only switches over after your normal DNS fails. + Automatic + Always use Cloudflare + Use system DNS only Steam location Change… Open diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 6a8df52..46963fb 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -109,6 +109,11 @@ Limbă Alege limba de afișare a aplicației. Se aplică după repornire. Implicit din sistem + Rezolvare DNS + Dacă furnizorul tău blochează lua.tools, aplicația poate rezolva adresele prin Cloudflare (1.1.1.1) în loc de rezolvatorul sistemului. „Automat” comută doar după ce DNS-ul obișnuit eșuează. + Automat + Folosește mereu Cloudflare + Folosește doar DNS-ul sistemului Locația Steam Schimbă… Deschide diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index be68125..841a407 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -109,6 +109,11 @@ Язык Выберите язык интерфейса приложения. Применяется после перезапуска. По умолчанию (система) + Разрешение DNS + Если провайдер блокирует lua.tools, приложение может определять адреса через Cloudflare (1.1.1.1) вместо системного резолвера. «Автоматически» переключается только после сбоя обычного DNS. + Автоматически + Всегда использовать Cloudflare + Только системный DNS Расположение Steam Изменить… Открыть diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index ef19712..d1a4aed 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -109,6 +109,11 @@ Språk Välj appens visningsspråk. Tillämpas efter en omstart. Systemstandard + DNS-uppslag + Om din leverantör blockerar lua.tools kan appen slå upp adresser via Cloudflare (1.1.1.1) i stället för systemets uppslag. ”Automatiskt” växlar först när din vanliga DNS misslyckas. + Automatiskt + Använd alltid Cloudflare + Använd endast systemets DNS Steam-plats Ändra… Öppna diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index 72faf46..784486e 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -109,6 +109,11 @@ ภาษา เลือกภาษาที่แสดงของแอป จะมีผลหลังจากรีสตาร์ท ค่าเริ่มต้นของระบบ + การแปลงชื่อ DNS + หากผู้ให้บริการของคุณบล็อก lua.tools แอปสามารถค้นหาที่อยู่ผ่าน Cloudflare (1.1.1.1) แทนตัวแปลงของระบบได้ โหมด “อัตโนมัติ” จะสลับเมื่อ DNS ปกติล้มเหลวเท่านั้น + อัตโนมัติ + ใช้ Cloudflare เสมอ + ใช้ DNS ของระบบเท่านั้น ตำแหน่ง Steam เปลี่ยน… เปิด diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 823f0c0..1da70de 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -109,6 +109,11 @@ Dil Uygulamanın görüntüleme dilini seçin. Yeniden başlatmadan sonra uygulanır. Sistem varsayılanı + DNS çözümleme + Sağlayıcınız lua.tools'u engelliyorsa, uygulama adresleri sistem çözümleyicisi yerine Cloudflare (1.1.1.1) üzerinden arayabilir. «Otomatik» yalnızca normal DNS başarısız olduktan sonra devreye girer. + Otomatik + Her zaman Cloudflare kullan + Yalnızca sistem DNS'ini kullan Steam konumu Değiştir… Aç diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 9f3ef67..2d5b7f4 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -109,6 +109,11 @@ Мова Виберіть мову інтерфейсу застосунку. Застосовується після перезапуску. За замовчуванням (система) + Розвʼязування DNS + Якщо провайдер блокує lua.tools, застосунок може визначати адреси через Cloudflare (1.1.1.1) замість системного розвʼязувача. «Автоматично» перемикається лише після збою звичайного DNS. + Автоматично + Завжди використовувати Cloudflare + Лише системний DNS Розташування Steam Змінити… Відкрити diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index c1439fa..a77b2e8 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -109,6 +109,11 @@ Ngôn ngữ Chọn ngôn ngữ hiển thị của ứng dụng. Áp dụng sau khi khởi động lại. Mặc định của hệ thống + Phân giải DNS + Nếu nhà mạng chặn lua.tools, ứng dụng có thể tra cứu địa chỉ qua Cloudflare (1.1.1.1) thay cho bộ phân giải hệ thống. «Tự động» chỉ chuyển sang sau khi DNS thông thường thất bại. + Tự động + Luôn dùng Cloudflare + Chỉ dùng DNS hệ thống Vị trí Steam Thay đổi… Mở diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index abb3467..0b0fde4 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -111,6 +111,11 @@ 语言 选择应用的显示语言。重启后生效。 跟随系统 + DNS 解析 + 如果你的网络运营商屏蔽了 lua.tools,应用可以改用 Cloudflare (1.1.1.1) 查询地址,而不使用系统解析器。“自动”仅在常规 DNS 失败后才切换。 + 自动 + 始终使用 Cloudflare + 仅使用系统 DNS Steam 位置 更改… 打开 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 3116e50..88268a4 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -109,6 +109,11 @@ 語言 選擇應用程式的顯示語言。重新啟動後生效。 跟隨系統 + DNS 解析 + 如果你的網路業者封鎖了 lua.tools,應用程式可以改用 Cloudflare (1.1.1.1) 查詢位址,而不使用系統解析器。「自動」僅在一般 DNS 失敗後才切換。 + 自動 + 一律使用 Cloudflare + 僅使用系統 DNS Steam 位置 變更… 開啟 diff --git a/src/LuaToolsGui/Services/AnalyticsService.cs b/src/LuaToolsGui/Services/AnalyticsService.cs index 66e3973..a002b6a 100644 --- a/src/LuaToolsGui/Services/AnalyticsService.cs +++ b/src/LuaToolsGui/Services/AnalyticsService.cs @@ -1,4 +1,4 @@ -using System.Net.Http; +using System.Net.Http; using System.Reflection; using System.Text; using System.Text.Json; @@ -12,7 +12,7 @@ namespace LuaToolsGui.Services; /// public class AnalyticsService { - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(10)); private static readonly string Version = Assembly.GetExecutingAssembly().GetCustomAttribute() diff --git a/src/LuaToolsGui/Services/AppHttp.cs b/src/LuaToolsGui/Services/AppHttp.cs new file mode 100644 index 0000000..4fc851c --- /dev/null +++ b/src/LuaToolsGui/Services/AppHttp.cs @@ -0,0 +1,160 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; + +namespace LuaToolsGui.Services; + +/// +/// The one factory for the whole app, so every outbound request shares a +/// single handler and therefore a single point of control over how host names get resolved. +/// +/// +/// +/// Why a static rather than a DI singleton: the thirteen clients it replaces are FIELD INITIALIZERS +/// (private readonly HttpClient _http = new() { … }), so they are constructed while the DI +/// container is still resolving services. Anything read at construction time would depend on service +/// ordering. is therefore read lazily inside the connect callback instead, which +/// runs per-connection, long after startup has settled. +/// +/// +/// Sharing one handler is also just correct: pooled connections are reused across services instead of +/// each one holding its own pool. +/// +/// +public static class AppHttp +{ + /// + /// Supplies the current mode. Set once at startup; invoked per connection, never cached. + /// + /// + /// A delegate rather than a reference so the handler has no dependency + /// on settings storage at all — it only needs to know a mode, and it asks at the moment it matters. + /// + public static Func? ModeProvider { get; set; } + + private static readonly DohResolver Resolver = new(); + + /// + /// Auto mode's latch: set only once DoH has actually succeeded where the system resolver failed. + /// + /// + /// Deliberately NOT set on any connect failure. A server being down, a dropped wifi link or a + /// timeout would otherwise flip the whole app onto DoH for the rest of the session on the strength + /// of one unrelated blip. Requiring DoH to succeed where the system failed is the only evidence + /// that actually distinguishes "DNS is being tampered with" from "the network hiccuped". + /// + private static volatile bool _systemDnsSuspect; + + private static readonly SocketsHttpHandler Handler = new() + { + // Bounds how long a mode change takes to matter: pooled connections outlive the setting, so + // without this a user who switches to Always keeps using system-resolved sockets until the + // server closes them. + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + ConnectCallback = ConnectAsync, + }; + + /// A client on the shared handler. The handler is never disposed with the client. + public static HttpClient Create(TimeSpan timeout, Uri? baseAddress = null) => + new(Handler, disposeHandler: false) { Timeout = timeout, BaseAddress = baseAddress }; + + /// The shared handler, for the few callers that need a handler rather than a client. + public static HttpMessageHandler SharedHandler => Handler; + + /// Current mode: "Auto" (default), "Always" or "Never". + private static string Mode + { + get + { + try { return ModeProvider?.Invoke() ?? "Auto"; } + catch { return "Auto"; } // a broken provider must not take down every request + } + } + + /// + /// Open the TCP connection for one request, choosing how the name is resolved. + /// + /// + /// Returns the RAW transport stream, before TLS. The handler performs the handshake itself against + /// the request's original host name, so SNI and certificate validation are untouched no matter + /// which resolver produced the address. That is the whole reason this is a connect callback rather + /// than URL rewriting to an IP, which would break both. + /// + private static async ValueTask ConnectAsync(SocketsHttpConnectionContext ctx, CancellationToken ct) + { + string host = ctx.DnsEndPoint.Host; + int port = ctx.DnsEndPoint.Port; + string mode = Mode; + + if (mode == "Never" || DohResolver.ShouldBypass(host)) + return await ConnectBySystem(host, port, ct); + + if (mode == "Always") + { + var addresses = await Resolver.ResolveAsync(host, ct); + if (addresses.Length > 0) + { + try { return await ConnectTo(addresses, port, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { /* DoH gave us addresses that don't work; the system may know better */ } + } + return await ConnectBySystem(host, port, ct); + } + + // ── Auto ────────────────────────────────────────────────────────────────────────────────── + // Once DoH has proven itself this session, skip the attempt we already know will fail. + if (_systemDnsSuspect) + { + var addresses = await Resolver.ResolveAsync(host, ct); + if (addresses.Length > 0) return await ConnectTo(addresses, port, ct); + return await ConnectBySystem(host, port, ct); + } + + try + { + return await ConnectBySystem(host, port, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception systemFailure) + { + // This catches the case that actually matters. ISP DNS blocking usually answers with a + // SINKHOLE address rather than NXDOMAIN, so the lookup "succeeds" and it is the connect + // that fails. Escalating on connect failure — not just on resolution failure — is what + // makes Auto work against real-world blocking. + var addresses = await Resolver.ResolveAsync(host, ct); + if (addresses.Length == 0) throw; + + Stream stream; + try { stream = await ConnectTo(addresses, port, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { throw systemFailure; } // DoH was no better: report the original, it is the real one + _systemDnsSuspect = true; + return stream; + } + } + + /// Let the socket do the name resolution, i.e. the system resolver. + private static async ValueTask ConnectBySystem(string host, int port, CancellationToken ct) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(host, port, ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch { socket.Dispose(); throw; } + } + + /// Connect to already-resolved addresses, trying each in turn. + private static async ValueTask ConnectTo(IPAddress[] addresses, int port, CancellationToken ct) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(addresses, port, ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch { socket.Dispose(); throw; } + } +} diff --git a/src/LuaToolsGui/Services/AuthService.cs b/src/LuaToolsGui/Services/AuthService.cs index bc7668e..db91d37 100644 --- a/src/LuaToolsGui/Services/AuthService.cs +++ b/src/LuaToolsGui/Services/AuthService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.Net; using System.Net.Http; @@ -15,7 +15,7 @@ public class AuthService private static readonly string AuthFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "auth.dat"); - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(30)); private readonly SemaphoreSlim _refreshLock = new(1, 1); private string? _accessToken; diff --git a/src/LuaToolsGui/Services/CefInjectorService.cs b/src/LuaToolsGui/Services/CefInjectorService.cs index bf8b7bb..e45dd98 100644 --- a/src/LuaToolsGui/Services/CefInjectorService.cs +++ b/src/LuaToolsGui/Services/CefInjectorService.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Net.Http; using System.Net.WebSockets; using System.Text; @@ -15,7 +15,7 @@ public class CefInjectorService : IHostedService private CancellationTokenSource? _cts; private string _luatoolsJs = ""; private string _polyfillJs = ""; - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(30)); // Persistent CDP WebSocket per tab (keyed by tab id), reused across calls so the fast RPC-drain // loop doesn't pay a connect handshake every tick. Opened lazily, evicted (and reopened next call) diff --git a/src/LuaToolsGui/Services/CoverCache.cs b/src/LuaToolsGui/Services/CoverCache.cs index 098366a..d1356e8 100644 --- a/src/LuaToolsGui/Services/CoverCache.cs +++ b/src/LuaToolsGui/Services/CoverCache.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using System.Net.Http; using System.Security.Cryptography; @@ -36,7 +36,7 @@ private static bool IsHeaderCapsulePlaceholder(byte[] b) return hash.Equals(PlaceholderSha256, StringComparison.OrdinalIgnoreCase); } - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(20) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(20)); private readonly SemaphoreSlim _ioGate = new(1, 1); private readonly ConcurrentDictionary> _inFlight = new(); private readonly ConcurrentDictionary _noCover = new(); // appids with no usable cover (this session) diff --git a/src/LuaToolsGui/Services/DohResolver.cs b/src/LuaToolsGui/Services/DohResolver.cs new file mode 100644 index 0000000..9b59ac8 --- /dev/null +++ b/src/LuaToolsGui/Services/DohResolver.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +namespace LuaToolsGui.Services; + +/// +/// Resolves host names through Cloudflare's DNS-over-HTTPS endpoint, for users whose ISP DNS-blocks +/// lua.tools. +/// +/// +/// +/// The endpoint is addressed by IP LITERAL (https://1.1.1.1/dns-query), never by name. Resolving +/// "one.one.one.one" would need working DNS, which is exactly what is missing. Cloudflare's certificate +/// carries 1.1.1.1 in its SANs, so TLS still validates normally against the literal. +/// +/// +/// This holds its OWN on a stock handler, deliberately not the shared one in +/// . Routing the resolver through the handler that calls the resolver would +/// recurse on the first lookup. +/// +/// +/// Every failure returns an empty array rather than throwing. A resolver that cannot answer must let +/// the caller fall back to the system resolver, not take down the request. +/// +/// +public class DohResolver +{ + /// IP literal on purpose. See the class remarks. + public const string Endpoint = "https://1.1.1.1/dns-query"; + + // A short floor stops a hostile-but-valid TTL of 0 turning every connection into a DoH round-trip; + // the cap keeps a very long TTL from pinning us to an address that has since moved. + private static readonly TimeSpan MinTtl = TimeSpan.FromSeconds(30); + private static readonly TimeSpan MaxTtl = TimeSpan.FromMinutes(5); + + private readonly HttpClient _http; + private readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + /// Injectable clock so cache expiry is testable without sleeping. + internal Func UtcNow { get; init; } = () => DateTimeOffset.UtcNow; + + public DohResolver(HttpMessageHandler? handler = null) + { + _http = handler is null + ? new HttpClient { Timeout = TimeSpan.FromSeconds(5) } + : new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) }; + _http.DefaultRequestHeaders.Accept.Add(new("application/dns-json")); + } + + /// + /// Names that must never go to DoH: it either cannot answer them or does not need to. + /// + /// + /// IP literals need no resolution at all. Loopback and single-label names are the local HTTP server, + /// the plugin port probe and the OAuth callback — a public resolver has no answer for those, and + /// asking would leak the name and add a round-trip to every local call. + /// + public static bool ShouldBypass(string? host) + { + if (string.IsNullOrWhiteSpace(host)) return true; + if (IPAddress.TryParse(host, out _)) return true; + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true; + if (host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)) return true; + if (host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) return true; + return !host.Contains('.'); // single-label: intranet, not something a public resolver knows + } + + /// + /// Addresses for , or an EMPTY array if DoH could not answer. Never throws + /// (except on caller cancellation). + /// + public async Task ResolveAsync(string host, CancellationToken ct = default) + { + if (ShouldBypass(host)) return []; + + if (_cache.TryGetValue(host, out var hit) && hit.Expires > UtcNow()) + return hit.Addresses; + + try + { + string url = $"{Endpoint}?name={Uri.EscapeDataString(host)}&type=A"; + using var resp = await _http.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) return []; + + string body = await resp.Content.ReadAsStringAsync(ct); + var (addresses, ttl) = Parse(body); + if (addresses.Length == 0) return []; + + var lifetime = ttl < MinTtl ? MinTtl : ttl > MaxTtl ? MaxTtl : ttl; + _cache[host] = (addresses, UtcNow() + lifetime); + return addresses; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { return []; } // unreachable, TLS refused, malformed: caller falls back to system DNS + } + + /// + /// Pull the A records out of a Cloudflare dns-json body. Returns empty on anything unexpected. + /// + /// + /// Public for tests: the parse is the part worth pinning down, and it needs no network to exercise. + /// A non-zero Status is a real DNS error (NXDOMAIN is 3) and must yield nothing, so the + /// caller falls back instead of treating "no such host" as an answer. + /// + public static (IPAddress[] Addresses, TimeSpan Ttl) Parse(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.ValueKind != JsonValueKind.Object) return ([], default); + if (!root.TryGetProperty("Status", out var status) || status.GetInt32() != 0) return ([], default); + if (!root.TryGetProperty("Answer", out var answer) || answer.ValueKind != JsonValueKind.Array) + return ([], default); + + var addresses = new List(); + int ttl = int.MaxValue; + + foreach (var entry in answer.EnumerateArray()) + { + // type 1 = A, 28 = AAAA. Anything else in the chain (5 = CNAME) is a step, not an answer. + if (!entry.TryGetProperty("type", out var type)) continue; + int t = type.GetInt32(); + if (t != 1 && t != 28) continue; + + if (!entry.TryGetProperty("data", out var data)) continue; + if (!IPAddress.TryParse(data.GetString(), out var ip)) continue; + + addresses.Add(ip); + if (entry.TryGetProperty("TTL", out var recordTtl) && recordTtl.TryGetInt32(out int v) && v < ttl) + ttl = v; + } + + if (addresses.Count == 0) return ([], default); + return ([.. addresses], TimeSpan.FromSeconds(ttl == int.MaxValue ? 0 : Math.Max(0, ttl))); + } + catch { return ([], default); } + } +} diff --git a/src/LuaToolsGui/Services/DonateKeysService.cs b/src/LuaToolsGui/Services/DonateKeysService.cs index e47bf13..2bdfceb 100644 --- a/src/LuaToolsGui/Services/DonateKeysService.cs +++ b/src/LuaToolsGui/Services/DonateKeysService.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; @@ -16,7 +16,7 @@ public partial class DonateKeysService(SettingsService settings, SteamService st { private const string DonationUrl = AppConfig.ManifestBackendUrl + "/donatekeys/send"; - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(15)); [GeneratedRegex(@"^\d{1,10}$")] private static partial Regex AppIdRegex(); diff --git a/src/LuaToolsGui/Services/GithubProxy.cs b/src/LuaToolsGui/Services/GithubProxy.cs index 90beea5..7076ca8 100644 --- a/src/LuaToolsGui/Services/GithubProxy.cs +++ b/src/LuaToolsGui/Services/GithubProxy.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Net.Http; namespace LuaToolsGui.Services; @@ -17,7 +17,7 @@ namespace LuaToolsGui.Services; public class GithubProxy { // 5-min timeout matches the old UnlockerService client (large asset downloads on slow mirrors). - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromMinutes(5)); /// Only github.com / api.github.com URLs get the mirror treatment. Anything else is left as-is. private static bool IsGithub(string url) => diff --git a/src/LuaToolsGui/Services/HubcapService.cs b/src/LuaToolsGui/Services/HubcapService.cs index 97d25de..4fac409 100644 --- a/src/LuaToolsGui/Services/HubcapService.cs +++ b/src/LuaToolsGui/Services/HubcapService.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; @@ -17,11 +17,8 @@ namespace LuaToolsGui.Services; public partial class HubcapService { // Hubcap-keyed downloads can be large manifest zips; allow a generous timeout like the lua.tools client. - private readonly HttpClient _http = new() - { - BaseAddress = new Uri(AppConfig.HubcapBaseUrl), - Timeout = TimeSpan.FromMinutes(5), - }; + private readonly HttpClient _http = + AppHttp.Create(TimeSpan.FromMinutes(5), new Uri(AppConfig.HubcapBaseUrl)); private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; diff --git a/src/LuaToolsGui/Services/LuaToolsApiClient.cs b/src/LuaToolsGui/Services/LuaToolsApiClient.cs index 68e2b2e..a9986df 100644 --- a/src/LuaToolsGui/Services/LuaToolsApiClient.cs +++ b/src/LuaToolsGui/Services/LuaToolsApiClient.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; @@ -17,11 +17,9 @@ public record DownloadedFile(string FilePath, string FileName); /// Typed client for the lua.tools web API, authenticated with a Supabase bearer token. public class LuaToolsApiClient(AuthService auth, SteamAppInfoCache appInfo, CoverCache covers) { - private readonly HttpClient _http = new() - { - BaseAddress = new Uri(AppConfig.ApiBaseUrl), - Timeout = TimeSpan.FromMinutes(5), // large manifest zips on slow connections - }; + private readonly HttpClient _http = AppHttp.Create( + TimeSpan.FromMinutes(5), // large manifest zips on slow connections + new Uri(AppConfig.ApiBaseUrl)); private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; diff --git a/src/LuaToolsGui/Services/PluginInstallerService.cs b/src/LuaToolsGui/Services/PluginInstallerService.cs index 944fcde..b8c6143 100644 --- a/src/LuaToolsGui/Services/PluginInstallerService.cs +++ b/src/LuaToolsGui/Services/PluginInstallerService.cs @@ -143,7 +143,7 @@ private static void RemoveCdpMarkerJunction(string path) p?.WaitForExit(5000); } - private static readonly HttpClient PortProbeHttp = new() { Timeout = TimeSpan.FromMilliseconds(800) }; + private static readonly HttpClient PortProbeHttp = AppHttp.Create(TimeSpan.FromMilliseconds(800)); /// Best-effort check for whether CDP's fixed port is occupied by something OTHER than Steam's /// own CDP server. A bare bind-test can't make that distinction: once the junction is doing its job and diff --git a/src/LuaToolsGui/Services/ProxiedFileDownloader.cs b/src/LuaToolsGui/Services/ProxiedFileDownloader.cs index 77661fe..b8cd392 100644 --- a/src/LuaToolsGui/Services/ProxiedFileDownloader.cs +++ b/src/LuaToolsGui/Services/ProxiedFileDownloader.cs @@ -1,3 +1,5 @@ +using System.IO; +using System.Net.Http; using Velopack.Sources; namespace LuaToolsGui.Services; @@ -8,11 +10,26 @@ namespace LuaToolsGui.Services; /// DownloadString/DownloadBytes) and the package download (the .nupkg, via DownloadFile). Each call tries /// the DIRECT GitHub URL first, then falls through the same mirrors as /// (), so the in-app self-update works where github.com is blocked. -/// Wraps Velopack's stock , only swapping the URL per attempt. +/// +/// It does the transfers itself rather than delegating to Velopack's stock +/// HttpClientFileDownloader, because that class builds its own +/// internally and so would bypass 's shared handler entirely. That would leave the +/// auto-updater on the system resolver while every other request honoured the DNS setting — stranding a +/// DNS-blocked user on whatever build they already had, which is the one thing they cannot fix from +/// inside the app. +/// /// public class ProxiedFileDownloader : IFileDownloader { - private readonly HttpClientFileDownloader _inner = new(); + private static readonly HttpClient Http = AppHttp.Create(TimeSpan.FromMinutes(30)); + + private static HttpRequestMessage Request(string url, IDictionary? headers) + { + var req = new HttpRequestMessage(HttpMethod.Get, url); + if (headers is not null) + foreach (var (k, v) in headers) req.Headers.TryAddWithoutValidation(k, v); + return req; + } public async Task DownloadFile(string url, string targetFile, Action progress, IDictionary? headers = null, double timeout = 30, CancellationToken cancelToken = default) @@ -22,7 +39,7 @@ public async Task DownloadFile(string url, string targetFile, Action progre { try { - await _inner.DownloadFile(candidate, targetFile, progress, headers, timeout, cancelToken); + await Download(candidate, targetFile, progress, headers, timeout, cancelToken); return; } catch (OperationCanceledException) when (cancelToken.IsCancellationRequested) { throw; } @@ -31,12 +48,58 @@ public async Task DownloadFile(string url, string targetFile, Action progre throw last ?? new Exception($"Failed to download {url} from GitHub or any mirror."); } + /// + /// Stream one candidate URL to disk, reporting 0-100. Streamed rather than buffered because the + /// .nupkg is ~7 MB today and grows with the app; progress is what the update toast shows. + /// + /// + /// A server that sends no Content-Length gives no denominator, so progress stays at 0 until the + /// file completes rather than reporting a made-up percentage. + /// + private static async Task Download(string url, string targetFile, Action progress, + IDictionary? headers, double timeout, CancellationToken ct) + { + using var req = Request(url, headers); + using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + + long? total = resp.Content.Headers.ContentLength; + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(targetFile); + + var buffer = new byte[81920]; + long read = 0; + int lastPercent = -1; + + while (true) + { + int n = await src.ReadAsync(buffer, ct); + if (n == 0) break; + await dst.WriteAsync(buffer.AsMemory(0, n), ct); + read += n; + + if (total is > 0) + { + int percent = (int)(read * 100 / total.Value); + if (percent != lastPercent) { lastPercent = percent; progress(percent); } + } + } + + progress(100); + } + public async Task DownloadBytes(string url, IDictionary? headers = null, double timeout = 30) { Exception? last = null; foreach (var candidate in GithubProxy.Candidates(url)) { - try { return await _inner.DownloadBytes(candidate, headers, timeout); } + try + { + using var req = Request(candidate, headers); + using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseContentRead); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsByteArrayAsync(); + } catch (Exception ex) { last = ex; } } throw last ?? new Exception($"Failed to download {url} from GitHub or any mirror."); @@ -47,7 +110,13 @@ public async Task DownloadString(string url, IDictionary Exception? last = null; foreach (var candidate in GithubProxy.Candidates(url)) { - try { return await _inner.DownloadString(candidate, headers, timeout); } + try + { + using var req = Request(candidate, headers); + using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseContentRead); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStringAsync(); + } catch (Exception ex) { last = ex; } } throw last ?? new Exception($"Failed to download {url} from GitHub or any mirror."); diff --git a/src/LuaToolsGui/Services/SettingsService.cs b/src/LuaToolsGui/Services/SettingsService.cs index 24ae6b7..9ffe08e 100644 --- a/src/LuaToolsGui/Services/SettingsService.cs +++ b/src/LuaToolsGui/Services/SettingsService.cs @@ -52,6 +52,11 @@ public class AppSettings // When true, FastFetch auto-picks the first available source and downloads immediately. // Nullable so "never set" (→ default OFF) is distinguishable from an explicit choice. public bool? FastFetch { get; set; } + + // How host names are resolved: "Auto" (default), "Always" or "Never". Stored in English because + // it is matched in code; the Settings page localizes the display only. Null = never set → "Auto". + // See AppHttp for what each mode does. + public string? DnsMode { get; set; } } public class SettingsService @@ -153,6 +158,21 @@ public bool FastFetch set { _settings.FastFetch = value; Save(); } } + /// + /// How host names are resolved: "Auto" (default), "Always" or "Never". See . + /// + /// + /// Defaults to Auto rather than off on purpose. A user whose ISP DNS-blocks lua.tools cannot reach + /// anything in the app to discover that a setting would fix it, so an opt-in toggle would be found + /// by everyone except the people who need it. Auto costs unaffected users nothing: the system + /// resolver is still tried first and DoH only engages once it has actually failed. + /// + public string DnsMode + { + get => _settings.DnsMode is "Always" or "Never" ? _settings.DnsMode : "Auto"; + set { _settings.DnsMode = value; Save(); } + } + private static readonly string TmpPath = FilePath + ".tmp"; private static readonly string BakPath = FilePath + ".bak"; diff --git a/src/LuaToolsGui/Services/SteamAppInfoCache.cs b/src/LuaToolsGui/Services/SteamAppInfoCache.cs index 3264a15..53b7222 100644 --- a/src/LuaToolsGui/Services/SteamAppInfoCache.cs +++ b/src/LuaToolsGui/Services/SteamAppInfoCache.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using System.Net; using System.Net.Http; @@ -67,7 +67,7 @@ public class SteamAppInfoCache // (the fast in-RAM index below) are derived from these on demand; there is no separate appinfo.json. private static readonly string DetailsDir = Path.Combine(Dir, "details"); - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(15)); // In-memory fast path for name/header-image. Populated by network resolves, and lazily rehydrated from // the /details blobs on a GetCached miss. A null value means "looked, no usable details" (negative // cache) so we don't re-read a missing/empty blob on every grid render. diff --git a/src/LuaToolsGui/Services/SteamAppListCache.cs b/src/LuaToolsGui/Services/SteamAppListCache.cs index 1cdb9be..6ddf807 100644 --- a/src/LuaToolsGui/Services/SteamAppListCache.cs +++ b/src/LuaToolsGui/Services/SteamAppListCache.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using System.Net.Http; using System.Text.Json; @@ -19,7 +19,7 @@ public class SteamAppListCache Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "steam-applist.json"); private static readonly TimeSpan MaxAge = TimeSpan.FromDays(14); - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) }; + private readonly HttpClient _http = AppHttp.Create(TimeSpan.FromSeconds(60)); private readonly ConcurrentDictionary _names = new(); private Task? _loadTask; diff --git a/src/LuaToolsGui/Services/SteamDepotInfo.cs b/src/LuaToolsGui/Services/SteamDepotInfo.cs index 3b06336..e3bf2fe 100644 --- a/src/LuaToolsGui/Services/SteamDepotInfo.cs +++ b/src/LuaToolsGui/Services/SteamDepotInfo.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net.Http; using System.Text.Json; @@ -54,7 +54,7 @@ public class SteamDepotInfo private readonly ConcurrentDictionary _cache = new(); // memory-only private readonly ConcurrentDictionary> _inFlight = new(); - public SteamDepotInfo() : this(new HttpClientHandler()) { } + public SteamDepotInfo() : this(AppHttp.SharedHandler) { } /// Test seam: swap in a stub handler so caching behaviour can be checked by counting the /// requests that actually go out, rather than by hitting the real API. diff --git a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs index cdc5d30..7914cd5 100644 --- a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs @@ -12,6 +12,10 @@ namespace LuaToolsGui.ViewModels; /// "follow the system display language". public record LanguageOption(string Display, string? Tag); +/// A DNS resolution mode. is the stored value and stays English ("Auto", +/// "Always", "Never") because it is matched in code; only is localized. +public record DnsModeOption(string Display, string Tag); + public partial class SettingsViewModel : ObservableObject { private readonly SettingsService _settings; @@ -148,6 +152,24 @@ partial void OnMinimizeToTrayChanged(bool value) [ObservableProperty] private LanguageOption _selectedLanguage = null!; + // ── DNS ───────────────────────────────────────────────────────── + /// How host names are resolved. See . + public ObservableCollection DnsModeOptions { get; } = + [ + new(Resources.Strings.Settings_Dns_Auto, "Auto"), + new(Resources.Strings.Settings_Dns_Always, "Always"), + new(Resources.Strings.Settings_Dns_Never, "Never"), + ]; + + [ObservableProperty] private DnsModeOption _selectedDnsMode = null!; + + // Takes effect on new connections; pooled ones turn over within AppHttp's connection lifetime, so + // there is no restart prompt here (unlike the language switch, which is baked in at parse time). + partial void OnSelectedDnsModeChanged(DnsModeOption value) + { + if (value is not null) _settings.DnsMode = value.Tag; + } + private bool _suppressLanguagePrompt; // true during ctor init so we don't prompt on first bind partial void OnSelectedLanguageChanged(LanguageOption value) @@ -242,6 +264,10 @@ public SettingsViewModel(SettingsService settings, AuthService auth, SteamServic _suppressLanguagePrompt = true; _selectedLanguage = LanguageOptions.FirstOrDefault(o => o.Tag == settings.Language) ?? LanguageOptions[0]; _suppressLanguagePrompt = false; + + // Assign the backing field, not the property: going through the setter would fire the change + // handler and write the default straight back to disk on first open. + _selectedDnsMode = DnsModeOptions.FirstOrDefault(o => o.Tag == settings.DnsMode) ?? DnsModeOptions[0]; } private void RefreshSteam() diff --git a/src/LuaToolsGui/Views/SettingsView.xaml b/src/LuaToolsGui/Views/SettingsView.xaml index 2449d4d..00c54fb 100644 --- a/src/LuaToolsGui/Views/SettingsView.xaml +++ b/src/LuaToolsGui/Views/SettingsView.xaml @@ -396,24 +396,48 @@ - - - - + + + + + + + + + + + + + + diff --git a/tests/LuaToolsGui.Tests/DohResolverTests.cs b/tests/LuaToolsGui.Tests/DohResolverTests.cs new file mode 100644 index 0000000..5997a9c --- /dev/null +++ b/tests/LuaToolsGui.Tests/DohResolverTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Http; +using LuaToolsGui.Services; +using Xunit; + +namespace LuaToolsGui.Tests; + +/// +/// Cloudflare DNS-over-HTTPS resolution, used when a user's ISP DNS-blocks lua.tools. +/// +/// +/// The behaviour that carries the most weight here is what happens when DoH CANNOT answer. Every such +/// path must yield an empty result rather than throwing or returning a wrong address, because the +/// caller treats "empty" as "fall back to the system resolver". A resolver that threw, or that reported +/// NXDOMAIN as if it were an answer, would turn a recoverable lookup into a dead request. +/// +public class DohResolverTests +{ + // ── Parsing ────────────────────────────────────────────────────── + + [Fact] + public void Parse_ReadsARecordsAndTheLowestTtl() + { + var (addresses, ttl) = DohResolver.Parse(""" + {"Status":0,"Answer":[ + {"name":"lua.tools.","type":1,"TTL":300,"data":"104.21.0.1"}, + {"name":"lua.tools.","type":1,"TTL":120,"data":"172.67.0.2"} + ]} + """); + + Assert.Equal([IPAddress.Parse("104.21.0.1"), IPAddress.Parse("172.67.0.2")], addresses); + Assert.Equal(TimeSpan.FromSeconds(120), ttl); // lowest wins, so nothing is cached past its life + } + + /// + /// A verbatim live response, captured from https://1.1.1.1/dns-query?name=lua.tools&type=A + /// on 2026-09-25. Guards the parser against Cloudflare's actual wire shape rather than a hand-made + /// approximation of it: the extra top-level fields (TC/RD/RA/AD/CD/Question) must all be ignored. + /// + [Fact] + public void Parse_HandlesARealCloudflareResponse() + { + var (addresses, ttl) = DohResolver.Parse( + """{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"lua.tools","type":1}],"Answer":[{"name":"lua.tools","type":1,"TTL":300,"data":"104.21.52.64"},{"name":"lua.tools","type":1,"TTL":300,"data":"172.67.196.53"}]}"""); + + Assert.Equal([IPAddress.Parse("104.21.52.64"), IPAddress.Parse("172.67.196.53")], addresses); + Assert.Equal(TimeSpan.FromSeconds(300), ttl); + } + + /// A CNAME is a step in the chain, not an answer. Only the A records it leads to count. + [Fact] + public void Parse_SkipsCnameRecords() + { + var (addresses, _) = DohResolver.Parse(""" + {"Status":0,"Answer":[ + {"name":"lua.tools.","type":5,"TTL":300,"data":"target.example.com."}, + {"name":"target.example.com.","type":1,"TTL":300,"data":"104.21.0.1"} + ]} + """); + + Assert.Equal([IPAddress.Parse("104.21.0.1")], addresses); + } + + [Fact] + public void Parse_ReadsAaaaRecords() + { + var (addresses, _) = DohResolver.Parse(""" + {"Status":0,"Answer":[{"name":"lua.tools.","type":28,"TTL":300,"data":"2606:4700::1"}]} + """); + + Assert.Equal([IPAddress.Parse("2606:4700::1")], addresses); + } + + /// + /// NXDOMAIN (Status 3) is a real DNS answer meaning "no such host". It must still produce nothing, + /// so the caller falls back instead of treating a negative reply as authoritative. + /// + [Fact] + public void Parse_TreatsNonZeroStatusAsNoAnswer() + { + var (addresses, _) = DohResolver.Parse("""{"Status":3,"Answer":[]}"""); + Assert.Empty(addresses); + } + + [Theory] + [InlineData("""{"Status":0}""")] // no Answer at all + [InlineData("""{"Status":0,"Answer":[]}""")] // empty Answer + [InlineData("""{"Status":0,"Answer":[{"type":1,"data":"not-an-ip"}]}""")] // unparseable address + [InlineData("""{"Status":0,"Answer":[{"type":1}]}""")] // no data field + [InlineData("not json at all")] + [InlineData("[]")] + [InlineData("")] + public void Parse_YieldsNothingForUnusableBodies(string json) => + Assert.Empty(DohResolver.Parse(json).Addresses); + + // ── Bypass rules ───────────────────────────────────────────────── + + /// + /// Names a public resolver cannot or should not answer. Sending these to Cloudflare would add a + /// round-trip to every local call and leak the name, for an answer that could only be wrong. + /// + [Theory] + [InlineData("127.0.0.1")] // the local HTTP server and the OAuth callback + [InlineData("::1")] + [InlineData("167.235.229.108")] // AppConfig.ManifestBackendUrl is already a bare IP + [InlineData("localhost")] + [InlineData("LOCALHOST")] + [InlineData("printer.local")] + [InlineData("myhost")] // single label: intranet, not public DNS + [InlineData("")] + [InlineData(null)] + public void ShouldBypass_IsTrueForLocalAndLiteralHosts(string? host) => + Assert.True(DohResolver.ShouldBypass(host)); + + [Theory] + [InlineData("lua.tools")] + [InlineData("db.lua.tools")] + [InlineData("api.github.com")] + [InlineData("hubcapmanifest.com")] + public void ShouldBypass_IsFalseForRealHosts(string host) => + Assert.False(DohResolver.ShouldBypass(host)); + + [Fact] + public async Task ResolveAsync_ReturnsEmptyForABypassedHostWithoutAskingTheNetwork() + { + var handler = new StubHandler("""{"Status":0,"Answer":[{"type":1,"TTL":60,"data":"1.2.3.4"}]}"""); + var resolver = new DohResolver(handler); + + Assert.Empty(await resolver.ResolveAsync("localhost")); + Assert.Equal(0, handler.Calls); // never went out at all + } + + // ── Caching and failure ────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_CachesUntilTheTtlExpires() + { + var now = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var handler = new StubHandler("""{"Status":0,"Answer":[{"type":1,"TTL":120,"data":"1.2.3.4"}]}"""); + var resolver = new DohResolver(handler) { UtcNow = () => now }; + + Assert.Single(await resolver.ResolveAsync("lua.tools")); + Assert.Single(await resolver.ResolveAsync("lua.tools")); + Assert.Equal(1, handler.Calls); // second answer came from cache + + now = now.AddSeconds(121); + Assert.Single(await resolver.ResolveAsync("lua.tools")); + Assert.Equal(2, handler.Calls); + } + + /// + /// A TTL of 0 would otherwise mean a DoH round-trip on every single connection. The floor keeps a + /// short-lived record usable for long enough to be worth having. + /// + [Fact] + public async Task ResolveAsync_AppliesTheMinimumTtlFloor() + { + var now = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var handler = new StubHandler("""{"Status":0,"Answer":[{"type":1,"TTL":0,"data":"1.2.3.4"}]}"""); + var resolver = new DohResolver(handler) { UtcNow = () => now }; + + await resolver.ResolveAsync("lua.tools"); + now = now.AddSeconds(25); + await resolver.ResolveAsync("lua.tools"); + + Assert.Equal(1, handler.Calls); // still cached despite TTL 0 + } + + [Fact] + public async Task ResolveAsync_ReturnsEmptyWhenTheEndpointIsUnreachable() + { + var resolver = new DohResolver(new ThrowingHandler()); + Assert.Empty(await resolver.ResolveAsync("lua.tools")); // no throw: the caller must get to fall back + } + + [Fact] + public async Task ResolveAsync_ReturnsEmptyOnAnErrorStatusCode() + { + var resolver = new DohResolver(new StubHandler("", HttpStatusCode.ServiceUnavailable)); + Assert.Empty(await resolver.ResolveAsync("lua.tools")); + } + + /// A failed lookup must not be cached, or one blip would blind the resolver for minutes. + [Fact] + public async Task ResolveAsync_DoesNotCacheAFailure() + { + var handler = new StubHandler("""{"Status":2}"""); + var resolver = new DohResolver(handler); + + await resolver.ResolveAsync("lua.tools"); + await resolver.ResolveAsync("lua.tools"); + + Assert.Equal(2, handler.Calls); + } + + // ── Stubs ──────────────────────────────────────────────────────── + + private sealed class StubHandler(string body, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Calls++; + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } + + private sealed class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + throw new HttpRequestException("no route to host"); + } +} From 0a61d5206db38dc20658d706d370ecec69343e01 Mon Sep 17 00:00:00 2001 From: cheapmanga <210733176+cheapmanga@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:22:32 +0200 Subject: [PATCH 29/29] Let manifest sources be declared in JSON The sources the app can fetch from are fixed at build time, so following a repo that moved, or adding a community one, means cutting a release. A `.json` file under %AppData%\LuaToolsGui\sources\ now declares extra sources; they appear as rows on the Add page and install through the existing pipeline. Data only, by design. A file says WHERE manifests come from and nothing else: it names one of the shapes the app already consumes (a zip or a lua per appid) and the app does the fetching. There is no way for one of these files to supply code, a binary, or a routine of its own, so installing one from a stranger cannot execute anything. Pack rows are appended after the app's own sources rather than ranked among them - that order is the app's decision, not a dropped-in file's. They are also exempt from the lua.tools sign-in gate, since they are fetched from the url the file names and never touch lua.tools. The availability probe goes through GithubProxy like the download does; one that skipped it would report "doesn't have the game" whenever GitHub was blocked while the download would have succeeded through a mirror. A host that refuses HEAD is probed with a one-byte ranged GET, because these urls are whatever host the author picked. Refusals are shown in Settings with their reason - a name the app already uses, a non-https url, a missing {appid}, an unknown kind - and one bad entry never takes a file's good ones with it. Files are re-read when the Settings page opens, so nothing needs a restart. All 29 languages. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BPSigFCgHqiUUbL9RRPZWs --- SOURCES.md | 56 +++++++ src/LuaToolsGui/App.xaml.cs | 2 + src/LuaToolsGui/Resources/Strings.Designer.cs | 6 + src/LuaToolsGui/Resources/Strings.ar.resx | 6 + src/LuaToolsGui/Resources/Strings.bg.resx | 6 + src/LuaToolsGui/Resources/Strings.cs.resx | 6 + src/LuaToolsGui/Resources/Strings.da.resx | 6 + src/LuaToolsGui/Resources/Strings.de.resx | 6 + src/LuaToolsGui/Resources/Strings.el.resx | 6 + src/LuaToolsGui/Resources/Strings.es-419.resx | 6 + src/LuaToolsGui/Resources/Strings.es.resx | 6 + src/LuaToolsGui/Resources/Strings.fi.resx | 6 + src/LuaToolsGui/Resources/Strings.fr.resx | 6 + src/LuaToolsGui/Resources/Strings.hu.resx | 6 + src/LuaToolsGui/Resources/Strings.id.resx | 6 + src/LuaToolsGui/Resources/Strings.it.resx | 6 + src/LuaToolsGui/Resources/Strings.ja.resx | 6 + src/LuaToolsGui/Resources/Strings.ko.resx | 6 + src/LuaToolsGui/Resources/Strings.nb.resx | 6 + src/LuaToolsGui/Resources/Strings.nl.resx | 6 + src/LuaToolsGui/Resources/Strings.pl.resx | 6 + src/LuaToolsGui/Resources/Strings.pt-BR.resx | 6 + src/LuaToolsGui/Resources/Strings.pt-PT.resx | 6 + src/LuaToolsGui/Resources/Strings.resx | 6 + src/LuaToolsGui/Resources/Strings.ro.resx | 6 + src/LuaToolsGui/Resources/Strings.ru.resx | 6 + src/LuaToolsGui/Resources/Strings.sv.resx | 6 + src/LuaToolsGui/Resources/Strings.th.resx | 6 + src/LuaToolsGui/Resources/Strings.tr.resx | 6 + src/LuaToolsGui/Resources/Strings.uk.resx | 6 + src/LuaToolsGui/Resources/Strings.vi.resx | 6 + .../Resources/Strings.zh-Hans.resx | 6 + .../Resources/Strings.zh-Hant.resx | 6 + .../Services/Downloads/ManifestJobFactory.cs | 29 +++- .../Services/Sources/PackSourceService.cs | 111 ++++++++++++ .../Services/Sources/SourcePack.cs | 75 +++++++++ .../Services/Sources/SourcePackRegistry.cs | 158 ++++++++++++++++++ .../ViewModels/DownloadViewModel.cs | 82 +++++++-- .../ViewModels/SettingsViewModel.cs | 45 ++++- src/LuaToolsGui/Views/SettingsView.xaml | 39 +++++ 40 files changed, 771 insertions(+), 12 deletions(-) create mode 100644 SOURCES.md create mode 100644 src/LuaToolsGui/Services/Sources/PackSourceService.cs create mode 100644 src/LuaToolsGui/Services/Sources/SourcePack.cs create mode 100644 src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs diff --git a/SOURCES.md b/SOURCES.md new file mode 100644 index 0000000..dcd0fe1 --- /dev/null +++ b/SOURCES.md @@ -0,0 +1,56 @@ +# Manifest sources as JSON + +Extra manifest sources can be declared in `.json` files under +`%AppData%\LuaToolsGui\sources\`. A source declared this way appears as a row on the **Add** page and +installs through the same pipeline as any other. + +Settings → *Manifest sources* lists the files found, what each contributed, and why anything was +refused. Files are re-read whenever that page is opened, so there is nothing to restart. + +## Why + +The sources the app can fetch from are otherwise fixed at build time. Following a repo that moved, or +adding a community one, means cutting a release. This makes it a line of JSON. + +## Data only + +A file can say **where** manifests are fetched from. It cannot supply code, a binary, or a fetch routine +of its own: it names one of the shapes the app already knows how to consume, and the app does the +fetching. Installing one of these files cannot execute anything. + +## Format + +`%AppData%\LuaToolsGui\sources\example.json`: + +```json +{ + "schema": 1, + "name": "Example sources", + "author": "you", + "sources": [ + { + "name": "example-zip", + "displayName": "Example", + "kind": "manifestZip", + "url": "https://raw.githubusercontent.com/someone/some-repo/main/{appid}.zip", + "mirrors": ["https://cdn.jsdelivr.net/gh/someone/some-repo@main/{appid}.zip"], + "badge": "Free" + } + ] +} +``` + +| Field | | +|---|---| +| `kind` | `manifestZip` — one `.zip` holding the lua and its `.manifest` files.
`luaFile` — one `.lua`, entitlements and depot keys only. | +| `url` | Must be `https` and contain `{appid}`. | +| `mirrors` | Tried in order when the primary is unreachable. Optional. | +| `displayName` | Row label. Defaults to `name`. | +| `badge` | Short label on the row. Cosmetic. Optional. | + +A source is refused, with the reason shown in Settings, if it uses a name the app already uses, is not +`https`, has no `{appid}`, or names a `kind` that does not exist. One bad entry never takes the file's +good ones down with it. + +GitHub urls go through the app's existing mirror fallback, the availability check included. A host that +refuses `HEAD` is probed with a one-byte ranged `GET` instead. diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index 792698f..67fb010 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -30,6 +30,8 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index a67f0f8..8a22a60 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -604,4 +604,10 @@ public static class Strings public static string Depot_Err_NoKeyFor => Get(nameof(Depot_Err_NoKeyFor)); public static string Builds_Select_SharedHint => Get(nameof(Builds_Select_SharedHint)); public static string Downloads_ClearHistory_Confirm => Get(nameof(Downloads_ClearHistory_Confirm)); + public static string Sources_Err_Fetch => Get(nameof(Sources_Err_Fetch)); + public static string Settings_Section_Sources => Get(nameof(Settings_Section_Sources)); + public static string Settings_SourcePacks_Hint => Get(nameof(Settings_SourcePacks_Hint)); + public static string Settings_SourcePacks_Open => Get(nameof(Settings_SourcePacks_Open)); + public static string Settings_SourcePacks_None => Get(nameof(Settings_SourcePacks_None)); + public static string Settings_SourcePacks_Count => Get(nameof(Settings_SourcePacks_Count)); } diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 5bafb68..94968f4 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -650,4 +650,10 @@ تغيّر {0} منذ تطبيق هذا الإصلاح، لذا فإن استعادته ستستبدل تلك التغييرات. إذا طبّقت إصلاحًا آخر فوقه، فتراجع عنه أولاً. الملفات المتأثرة: {1}. تم التراجع عن الإصلاح جزئيًا تعذّرت استعادة {0} من الملفات. أغلق اللعبة وحاول مرة أخرى. + هذا المصدر لا يحتوي على هذه اللعبة، أو تعذّر الوصول إليه. + مصادر المانيفست + مصادر إضافية معرَّفة في ملفات ‎.json. بيانات فقط: الملف يحدّد من أين تُجلب المانيفستات، ولا يُنفَّذ منه أي شيء. + فتح المجلد + لا توجد ملفات مصادر بعد. + المصادر: {0} diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index d5577de..20bdf72 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -624,4 +624,10 @@ {0} е променен, откакто беше приложена тази поправка, така че възстановяването ще презапише тези промени. Ако сте приложили друга поправка отгоре, първо върнете нея. Засегнати са {1} файла. Корекцията е върната частично {0} файл(а) не можаха да бъдат възстановени. Затворете играта и опитайте отново. + Този източник няма тази игра или е недостъпен. + Източници на манифести + Допълнителни източници, описани във файлове .json. Само данни: файлът казва откъде да се вземат манифести и нищо в него не се изпълнява. + Отваряне на папката + Все още няма файлове с източници. + източници: {0} diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 4bfe589..6e2d16b 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -624,4 +624,10 @@ Zrušit - pokračovat ve stahování Soubor {0} se od použití této opravy změnil, takže obnovení by tyto změny přepsalo. Pokud jste navrch použili jinou opravu, vraťte nejprve ji. Dotčeno {1} souborů. Oprava vrácena zpět částečně {0} soubor(ů) se nepodařilo obnovit. Zavřete hru a zkuste to znovu. + Tento zdroj tuto hru nemá, nebo je nedostupný. + Zdroje manifestů + Další zdroje deklarované v souborech .json. Pouze data: soubor říká, odkud manifesty stáhnout, a nic z něj se nespouští. + Otevřít složku + Zatím žádné soubory zdrojů. + zdroje: {0} diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 34a6e54..6327903 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -624,4 +624,10 @@ Annuller - fortsæt download {0} er ændret, siden denne rettelse blev anvendt, så en gendannelse ville overskrive de ændringer. Hvis du har anvendt en anden rettelse ovenpå, så fortryd den først. {1} fil(er) berørt. Rettelse delvist fortrudt {0} fil(er) kunne ikke gendannes. Luk spillet og prøv igen. + Denne kilde har ikke dette spil, eller kunne ikke nås. + Manifestkilder + Ekstra kilder angivet i .json-filer. Kun data: en fil siger, hvor manifester hentes fra, og intet i den køres. + Åbn mappen + Ingen kildefiler endnu. + kilder: {0} diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index e5d79d5..8e61146 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -624,4 +624,10 @@ Abbrechen - weiter herunterladen {0} wurde seit dem Anwenden dieses Fixes geändert; eine Wiederherstellung würde diese Änderungen überschreiben. Falls du einen weiteren Fix darüber angewendet hast, setze zuerst diesen zurück. {1} Datei(en) betroffen. Fix teilweise rückgängig gemacht {0} Datei(en) konnten nicht wiederhergestellt werden. Schließe das Spiel und versuche es erneut. + Diese Quelle hat dieses Spiel nicht oder war nicht erreichbar. + Manifest-Quellen + Zusätzliche Quellen, in .json-Dateien deklariert. Nur Daten: eine Datei sagt, woher Manifeste geholt werden, ausgeführt wird daraus nie etwas. + Ordner öffnen + Noch keine Quellendateien. + Quellen: {0} diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index 7b344bd..e3231a2 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -624,4 +624,10 @@ Το {0} έχει αλλάξει από τότε που εφαρμόστηκε αυτή η διόρθωση, οπότε η επαναφορά θα αντικαταστήσει αυτές τις αλλαγές. Αν εφαρμόσατε άλλη διόρθωση από πάνω, επαναφέρετε πρώτα εκείνη. Επηρεάζονται {1} αρχεία. Η διόρθωση αναιρέθηκε μερικώς Δεν ήταν δυνατή η επαναφορά {0} αρχείων. Κλείστε το παιχνίδι και δοκιμάστε ξανά. + Αυτή η πηγή δεν έχει αυτό το παιχνίδι, ή δεν ήταν προσβάσιμη. + Πηγές manifest + Επιπλέον πηγές δηλωμένες σε αρχεία .json. Μόνο δεδομένα: το αρχείο λέει από πού λαμβάνονται τα manifest, και τίποτα μέσα του δεν εκτελείται. + Άνοιγμα φακέλου + Δεν υπάρχουν ακόμη αρχεία πηγών. + πηγές: {0} diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index 8412488..fc130aa 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -624,4 +624,10 @@ Cancelar: seguir descargando {0} cambió desde que se aplicó este fix, así que restaurarlo sobrescribiría esos cambios. Si aplicaste otro fix encima, revierte ese primero. {1} archivo(s) afectado(s). Parche revertido parcialmente No se pudieron restaurar {0} archivo(s). Cerrá el juego y probá de nuevo. + Esta fuente no tiene este juego, o no se pudo contactar. + Fuentes de manifiestos + Fuentes adicionales declaradas en archivos .json. Solo datos: un archivo indica de dónde obtener manifiestos, y nada en él se ejecuta. + Abrir la carpeta + Aún no hay archivos de fuentes. + fuentes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index c0eecff..d78f74a 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -624,4 +624,10 @@ Cancelar: seguir descargando No hay clave de descifrado en el Lua para el depot {0}. Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. + Esta fuente no tiene este juego, o no se pudo contactar. + Fuentes de manifiestos + Fuentes adicionales declaradas en archivos .json. Solo datos: un archivo indica de dónde obtener manifiestos, y nada en él se ejecuta. + Abrir la carpeta + Aún no hay archivos de fuentes. + fuentes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index 5ddea00..2b0c4c0 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -624,4 +624,10 @@ Peruuta - jatka lataamista {0} on muuttunut tämän korjauksen käyttöönoton jälkeen, joten palauttaminen korvaisi nuo muutokset. Jos otit päälle toisen korjauksen, peru ensin se. {1} tiedosto(a) koskee. Korjaus peruttu osittain {0} tiedostoa ei voitu palauttaa. Sulje peli ja yritä uudelleen. + Tässä lähteessä ei ole tätä peliä, tai siihen ei saatu yhteyttä. + Manifestilähteet + Lisälähteitä .json-tiedostoissa. Pelkkää dataa: tiedosto kertoo mistä manifestit haetaan, eikä siitä suoriteta mitään. + Avaa kansio + Ei vielä lähdetiedostoja. + lähteet: {0} diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 8bcece2..8de28e7 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -624,4 +624,10 @@ Annuler - continuer le téléchargement {0} a changé depuis l'application de ce correctif ; le restaurer écraserait ces modifications. Si vous avez appliqué un autre correctif par-dessus, annulez-le d'abord. {1} fichier(s) concerné(s). Correctif partiellement annulé Impossible de restaurer {0} fichier(s). Fermez le jeu et réessayez. + Cette source n'a pas ce jeu, ou n'a pas pu être jointe. + Sources de manifests + Sources supplémentaires déclarées dans des fichiers .json. Uniquement des données : un fichier indique où récupérer des manifests, rien n'y est jamais exécuté. + Ouvrir le dossier + Aucun fichier de source pour l'instant. + sources : {0} diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index e162642..97f14e8 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -624,4 +624,10 @@ Mégse - letöltés folytatása A(z) {0} megváltozott a javítás alkalmazása óta, ezért a visszaállítás felülírná ezeket a módosításokat. Ha másik javítást is alkalmaztál rá, előbb azt vond vissza. {1} fájl érintett. A javítás részben lett visszavonva {0} fájlt nem sikerült visszaállítani. Zárd be a játékot, és próbáld újra. + Ez a forrás nem tartalmazza ezt a játékot, vagy nem érhető el. + Manifest-források + További források .json fájlokban megadva. Csak adat: a fájl megmondja, honnan töltsük le a manifesteket, és semmi nem fut le belőle. + Mappa megnyitása + Még nincs forrásfájl. + források: {0} diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index 5600969..97e6e5a 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -624,4 +624,10 @@ Batal - lanjutkan mengunduh {0} telah berubah sejak fix ini diterapkan, jadi memulihkannya akan menimpa perubahan tersebut. Jika Anda menerapkan fix lain di atasnya, kembalikan yang itu dulu. {1} berkas terpengaruh. Perbaikan dikembalikan sebagian {0} berkas tidak dapat dipulihkan. Tutup game lalu coba lagi. + Sumber ini tidak memiliki gim ini, atau tidak dapat dijangkau. + Sumber manifest + Sumber tambahan yang dideklarasikan dalam berkas .json. Hanya data: berkas menyebutkan dari mana manifest diambil, dan tidak ada isinya yang dijalankan. + Buka folder + Belum ada berkas sumber. + sumber: {0} diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index 0d50239..74d2d8b 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -624,4 +624,10 @@ Annulla - continua a scaricare {0} è cambiato da quando questo fix è stato applicato, quindi ripristinarlo sovrascriverebbe quelle modifiche. Se hai applicato un altro fix sopra, ripristina prima quello. {1} file interessati. Fix annullata parzialmente Impossibile ripristinare {0} file. Chiudi il gioco e riprova. + Questa fonte non ha questo gioco, o non è raggiungibile. + Fonti di manifest + Fonti aggiuntive dichiarate in file .json. Solo dati: un file indica dove prendere i manifest, e nulla al suo interno viene mai eseguito. + Apri la cartella + Nessun file di fonti per ora. + fonti: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index f69e014..786e609 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -624,4 +624,10 @@ Steam\config\stplug-in から .lua ファイルを削除します。この修正の適用後に {0} が変更されているため、復元するとその変更が上書きされます。別の修正を上に適用した場合は、先にそちらを元に戻してください。対象は {1} 個のファイルです。 修正は一部だけ元に戻りました {0} 個のファイルを復元できませんでした。ゲームを閉じてやり直してください。 + このソースにはこのゲームがないか、接続できませんでした。 + マニフェストのソース + ​.json ファイルで宣言する追加ソース。データのみで、ファイルはマニフェストの取得先を示すだけです。中身が実行されることはありません。 + フォルダーを開く + ソースファイルはまだありません。 + ソース: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index 05b0f4f..defab91 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -624,4 +624,10 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 이 픽스를 적용한 뒤 {0} 이(가) 변경되어, 복원하면 해당 변경 사항을 덮어씁니다. 다른 픽스를 위에 적용했다면 그것부터 되돌리세요. {1}개 파일이 영향을 받습니다. 수정이 일부만 되돌려졌습니다 {0}개 파일을 복원하지 못했습니다. 게임을 닫고 다시 시도하세요. + 이 소스에는 이 게임이 없거나 연결할 수 없습니다. + 매니페스트 소스 + ​.json 파일로 선언하는 추가 소스. 데이터일 뿐이며, 파일은 매니페스트를 어디서 가져올지 알려줄 뿐 그 안의 어떤 것도 실행되지 않습니다. + 폴더 열기 + 아직 소스 파일이 없습니다. + 소스: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 65d32b9..63fafce 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -624,4 +624,10 @@ Avbryt - fortsett nedlastingen {0} er endret siden denne fiksen ble brukt, så gjenoppretting ville overskrive de endringene. Hvis du har brukt en annen fiks oppå, tilbakestill den først. {1} fil(er) berørt. Rettelse delvis angret {0} fil(er) kunne ikke gjenopprettes. Lukk spillet og prøv igjen. + Denne kilden har ikke dette spillet, eller kunne ikke nås. + Manifestkilder + Ekstra kilder angitt i .json-filer. Bare data: en fil sier hvor manifester hentes fra, og ingenting i den kjøres. + Åpne mappen + Ingen kildefiler ennå. + kilder: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index 4be97ff..8b7b60f 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -624,4 +624,10 @@ Annuleren - doorgaan met downloaden {0} is gewijzigd sinds deze fix is toegepast, dus herstellen zou die wijzigingen overschrijven. Als je er een andere fix overheen hebt toegepast, draai die dan eerst terug. {1} bestand(en) getroffen. Fix gedeeltelijk teruggedraaid {0} bestand(en) konden niet worden hersteld. Sluit de game en probeer opnieuw. + Deze bron heeft dit spel niet, of was niet bereikbaar. + Manifestbronnen + Extra bronnen, opgegeven in .json-bestanden. Alleen gegevens: een bestand zegt waar manifesten vandaan komen, er wordt nooit iets uit uitgevoerd. + Map openen + Nog geen bronbestanden. + bronnen: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index 4fdffbf..a223ac9 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -624,4 +624,10 @@ Anuluj - kontynuuj pobieranie Plik {0} zmienił się od zastosowania tej poprawki, więc przywrócenie nadpisałoby te zmiany. Jeśli zastosowano na wierzchu inną poprawkę, cofnij najpierw ją. Dotyczy {1} plik(ów). Poprawka cofnięta częściowo Nie udało się przywrócić {0} plików. Zamknij grę i spróbuj ponownie. + To źródło nie ma tej gry lub jest nieosiągalne. + Źródła manifestów + Dodatkowe źródła zadeklarowane w plikach .json. Wyłącznie dane: plik mówi, skąd pobrać manifesty, i nic z niego nie jest uruchamiane. + Otwórz folder + Brak plików źródeł. + źródła: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index 1ea76f4..63d20d9 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -624,4 +624,10 @@ Cancelar - continuar baixando {0} mudou desde que este fix foi aplicado, então restaurá-lo sobrescreveria essas mudanças. Se você aplicou outro fix por cima, reverta aquele primeiro. {1} arquivo(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} arquivo(s). Feche o jogo e tente novamente. + Esta fonte não tem este jogo, ou não pôde ser acessada. + Fontes de manifesto + Fontes extras declaradas em arquivos .json. Apenas dados: um arquivo diz de onde buscar manifestos, e nada nele é executado. + Abrir a pasta + Ainda não há arquivos de fontes. + fontes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index d17fe9b..2f2e1ef 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -624,4 +624,10 @@ Cancelar - continuar a transferir {0} mudou desde que este fix foi aplicado, pelo que restaurá-lo iria sobrescrever essas alterações. Se aplicou outro fix por cima, reverta esse primeiro. {1} ficheiro(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} ficheiro(s). Feche o jogo e tente novamente. + Esta fonte não tem este jogo, ou não pôde ser contactada. + Fontes de manifesto + Fontes adicionais declaradas em ficheiros .json. Apenas dados: um ficheiro diz onde obter manifestos, e nada nele é executado. + Abrir a pasta + Ainda não há ficheiros de fontes. + fontes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 3799bb6..2a38fa0 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -659,4 +659,10 @@ Cancel - keep downloading No decryption key in Lua for depot {0}. Shared runtime — usually already installed. Tick to download anyway. Remove all {0} entries from the download history? Downloaded files are not affected. + This source doesn't have this game, or couldn't be reached. + Manifest sources + Extra sources declared in .json files. Data only: a file says where manifests can be fetched from, and nothing in it is ever executed. + Open the folder + No source files yet. + sources: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 46963fb..222aa53 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -624,4 +624,10 @@ Anulare - continuă descărcarea {0} s-a modificat de la aplicarea acestui fix, așa că restaurarea ar suprascrie acele modificări. Dacă ai aplicat alt fix peste, anulează-l mai întâi pe acela. {1} fișier(e) afectate. Reparație anulată parțial {0} fișier(e) nu au putut fi restaurate. Închide jocul și încearcă din nou. + Această sursă nu are acest joc sau nu a putut fi contactată. + Surse de manifeste + Surse suplimentare declarate în fișiere .json. Doar date: un fișier spune de unde se iau manifestele și nimic din el nu se execută. + Deschide folderul + Încă niciun fișier de surse. + surse: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index 841a407..52c91fd 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -624,4 +624,10 @@ Файл {0} изменился после применения этого фикса, и восстановление перезапишет эти изменения. Если поверх был применён другой фикс, сначала откатите его. Затронуто файлов: {1}. Исправление откачено частично Не удалось восстановить файлов: {0}. Закройте игру и повторите попытку. + В этом источнике нет этой игры, либо он недоступен. + Источники манифестов + Дополнительные источники, описанные в файлах .json. Только данные: файл указывает, откуда брать манифесты, и ничего из него не выполняется. + Открыть папку + Файлов источников пока нет. + источники: {0} diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index d1a4aed..f2890fc 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -624,4 +624,10 @@ Avbryt - fortsätt hämta {0} har ändrats sedan den här fixen tillämpades, så en återställning skulle skriva över de ändringarna. Om du tillämpat en annan fix ovanpå, återställ den först. {1} fil(er) påverkas. Korrigering delvis ångrad {0} fil(er) kunde inte återställas. Stäng spelet och försök igen. + Den här källan har inte det här spelet, eller kunde inte nås. + Manifestkällor + Extra källor som anges i .json-filer. Endast data: en fil säger var manifest hämtas, inget i den körs någonsin. + Öppna mappen + Inga källfiler ännu. + källor: {0} diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index 784486e..569f177 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -624,4 +624,10 @@ {0} มีการเปลี่ยนแปลงหลังจากใช้แพตช์นี้ การกู้คืนจะเขียนทับการเปลี่ยนแปลงนั้น หากคุณใช้แพตช์อื่นทับไว้ ให้ย้อนกลับแพตช์นั้นก่อน มีไฟล์ที่ได้รับผลกระทบ {1} ไฟล์ ย้อนกลับตัวแก้ไขได้บางส่วน กู้คืนไฟล์ {0} รายการไม่สำเร็จ กรุณาปิดเกมแล้วลองใหม่ + แหล่งนี้ไม่มีเกมนี้ หรือเข้าถึงไม่ได้ + แหล่งแมนิเฟสต์ + แหล่งเพิ่มเติมที่ประกาศไว้ในไฟล์ .json เป็นข้อมูลล้วน ๆ ไฟล์บอกเพียงว่าจะดึงแมนิเฟสต์จากที่ใด และไม่มีอะไรในไฟล์ถูกรัน + เปิดโฟลเดอร์ + ยังไม่มีไฟล์แหล่งข้อมูล + แหล่ง: {0} diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 1da70de..6e0ba73 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -624,4 +624,10 @@ Hayır - durdur ama dosyaları koru Bu düzeltme uygulandıktan sonra {0} değişti; geri yüklemek bu değişikliklerin üzerine yazar. Üstüne başka bir düzeltme uyguladıysanız önce onu geri alın. {1} dosya etkilendi. Düzeltme kısmen geri alındı {0} dosya geri yüklenemedi. Oyunu kapatıp tekrar deneyin. + Bu kaynakta bu oyun yok ya da kaynağa ulaşılamadı. + Manifest kaynakları + ​.json dosyalarında tanımlanan ek kaynaklar. Yalnızca veri: dosya manifestlerin nereden alınacağını söyler, içinden hiçbir şey çalıştırılmaz. + Klasörü aç + Henüz kaynak dosyası yok. + kaynaklar: {0} diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 2d5b7f4..ea6423b 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -624,4 +624,10 @@ Файл {0} змінився після застосування цього фікса, тож відновлення перезапише ці зміни. Якщо згори застосовано інший фікс, спершу скасуйте його. Порушено файлів: {1}. Виправлення відкочено частково Не вдалося відновити файлів: {0}. Закрийте гру та спробуйте ще раз. + У цьому джерелі немає цієї гри, або воно недоступне. + Джерела маніфестів + Додаткові джерела, описані у файлах .json. Лише дані: файл вказує, звідки брати маніфести, і нічого з нього не виконується. + Відкрити теку + Файлів джерел поки немає. + джерела: {0} diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index a77b2e8..01ded7d 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -624,4 +624,10 @@ Hủy - tiếp tục tải {0} đã thay đổi kể từ khi bản vá này được áp dụng, nên khôi phục sẽ ghi đè các thay đổi đó. Nếu bạn đã áp dụng bản vá khác lên trên, hãy hoàn tác bản đó trước. {1} tệp bị ảnh hưởng. Bản sửa lỗi được hoàn tác một phần Không thể khôi phục {0} tệp. Hãy đóng trò chơi và thử lại. + Nguồn này không có trò chơi này, hoặc không kết nối được. + Nguồn manifest + Nguồn bổ sung khai báo trong tệp .json. Chỉ là dữ liệu: tệp cho biết lấy manifest ở đâu, và không có gì trong đó được thực thi. + Mở thư mục + Chưa có tệp nguồn nào. + nguồn: {0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index 0b0fde4..a988ab0 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -644,4 +644,10 @@ 自应用此修复以来,{0} 已被更改,还原会覆盖这些更改。如果你在其上应用了另一个修复,请先还原那一个。共影响 {1} 个文件。 修复部分还原 有 {0} 个文件无法恢复。请关闭游戏后重试。 + 此来源没有这个游戏,或无法连接。 + 清单来源 + 在 .json 文件中声明的额外来源。纯数据:文件只说明从哪里获取清单,其中的任何内容都不会被执行。 + 打开文件夹 + 还没有来源文件。 + 来源:{0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 88268a4..ac4f25e 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -624,4 +624,10 @@ 自套用此修復以來,{0} 已被變更,還原會覆寫這些變更。如果你在其上套用了另一個修復,請先還原那一個。共影響 {1} 個檔案。 修復部分還原 有 {0} 個檔案無法復原。請關閉遊戲後重試。 + 此來源沒有這個遊戲,或無法連線。 + 資訊清單來源 + 在 .json 檔案中宣告的額外來源。純資料:檔案只說明從何處取得資訊清單,其中的任何內容都不會被執行。 + 開啟資料夾 + 尚未有來源檔案。 + 來源:{0} diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index 43d055d..6296ce2 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -25,7 +25,8 @@ public class ManifestJobFactory( DepotDownloaderService depotTool, SteamDepotInfo depotInfo, SteamAutoCrackService sac, - AppliedFixIndexService fixIndex) + AppliedFixIndexService fixIndex, + Sources.PackSourceService packSources) { // ── Job builders ───────────────────────────────────────────────── @@ -53,6 +54,32 @@ public DownloadJob CreateManifestJob( onReveal); } + /// + /// A manifest from a source a pack declared. Fetched from the pack's url, then installed by exactly + /// the same code as any other manifest — a pack changes where a file comes from, never what is done + /// with it. + /// + public DownloadJob CreatePackSourceJob( + Sources.PackSource source, long appId, string? gameName, + Func>? confirm = null, + Action? onFinished = null, + Action? onReveal = null) + { + string title = gameName ?? appId.ToString(); + return new DownloadJob( + DownloadKind.Manifest, + $"manifest:{appId}", + appId, + title, + source.DisplayName, + covers.GetLocalPath(appId), + (_, progress, ct) => packSources.FetchAsync(source, appId, progress, ct), + (file, _, _) => Task.FromResult(InstallManifest(file, appId, title)), + confirm, + onFinished, + onReveal); + } + /// DLC unlock lua. Installed silently: it's an unlock, so there's nothing to confirm. public DownloadJob CreateDlcJob( long appId, string baseAppId, string? gameName, diff --git a/src/LuaToolsGui/Services/Sources/PackSourceService.cs b/src/LuaToolsGui/Services/Sources/PackSourceService.cs new file mode 100644 index 0000000..6100660 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/PackSourceService.cs @@ -0,0 +1,111 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using LuaToolsGui.Services.Downloads; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services.Sources; + +/// +/// Fetches from the manifest sources a pack declared. One service for all of them, because a pack names +/// a SHAPE the app already knows how to consume rather than supplying a routine of its own. +/// +/// +/// Everything goes through , the existence probe included: the proxy tries the +/// url directly and only then its mirrors, so a probe that skipped it would answer "this source doesn't +/// have the game" whenever GitHub was blocked, while the download that followed would have succeeded +/// through a mirror. +/// +public class PackSourceService(GithubProxy gh, ILogger log) +{ + // Its own client so a probe never inherits a long download timeout. + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; + + /// Does this source have the game? Any failure answers "no", never an error. + public async Task HasGameAsync(PackSource source, long appId, CancellationToken ct = default) + { + try + { + foreach (string url in Urls(source, appId)) + foreach (string candidate in GithubProxy.Candidates(url)) + { + try { if (await ExistsAsync(candidate, ct)) return true; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { /* this candidate is out; the next one may answer */ } + } + + return false; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + log.LogDebug(ex, "Pack source {Source} probe for {AppId} failed", source.Name, appId); + return false; + } + } + + /// Fetch the game to a temp file, for the existing install pipeline to take. + public async Task FetchAsync( + PackSource source, long appId, IProgress? progress, CancellationToken ct = default) + { + string ext = source.Kind is SourceKind.ManifestZip ? "zip" : "lua"; + string path = Path.Combine(Path.GetTempPath(), $"pack-{Sanitize(source.Name)}-{appId}.{ext}"); + + // GithubProxy reports 0..1 fractions; scaled to the byte-shaped report the queue's UI speaks, so + // the bar moves rather than sitting still. + var sink = progress is null ? null + : new ProgressRelay(f => progress.Report(new DownloadProgress((long)((f ?? 0) * 1000), 1000))); + + Exception? last = null; + foreach (string url in Urls(source, appId)) + { + try + { + await gh.DownloadAsync(url, path, sink, ct); + if (File.Exists(path) && new FileInfo(path).Length > 0) + return new DownloadedFile(path, $"{appId}.{ext}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { last = ex; } + } + + log.LogDebug(last, "Pack source {Source} could not fetch {AppId}", source.Name, appId); + throw new DownloadAbortedException(Resources.Strings.Sources_Err_Fetch); + } + + /// + /// Does this exact url serve something? HEAD first, and on a host that refuses HEAD, a one-byte + /// ranged GET — a pack's url is any host its author chose, and plenty answer 405 or 501 to a HEAD + /// while serving the file perfectly well over GET. + /// + private async Task ExistsAsync(string url, CancellationToken ct) + { + using var head = new HttpRequestMessage(HttpMethod.Head, url); + head.Headers.TryAddWithoutValidation("User-Agent", "LuaTools"); + using var headRes = await _http.SendAsync(head, ct); + if (headRes.StatusCode == HttpStatusCode.OK) return true; + if (headRes.StatusCode is not (HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)) + return false; + + // Range is a request, not a promise: a server may ignore it and start sending the whole file, so + // the response is disposed without reading the body and only the status is used. + using var get = new HttpRequestMessage(HttpMethod.Get, url); + get.Headers.TryAddWithoutValidation("User-Agent", "LuaTools"); + get.Headers.TryAddWithoutValidation("Range", "bytes=0-0"); + using var getRes = await _http.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, ct); + return getRes.StatusCode is HttpStatusCode.OK or HttpStatusCode.PartialContent; + } + + private static IEnumerable Urls(PackSource source, long appId) + { + yield return Fill(source.Url, appId); + foreach (string m in source.Mirrors) yield return Fill(m, appId); + } + + private static string Fill(string template, long appId) => + template.Replace("{appid}", appId.ToString(), StringComparison.OrdinalIgnoreCase); + + /// Keeps a pack-supplied name fit for a temp file name. + private static string Sanitize(string name) => + string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); +} diff --git a/src/LuaToolsGui/Services/Sources/SourcePack.cs b/src/LuaToolsGui/Services/Sources/SourcePack.cs new file mode 100644 index 0000000..b96bae5 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/SourcePack.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace LuaToolsGui.Services.Sources; + +/// +/// One .json file in the source-pack folder. Pure data: a pack declares where manifests can be +/// fetched from and nothing else. There is no code path here by design — a pack cannot execute anything, +/// which is what makes it safe to install one from someone you don't know. +/// +public sealed class SourcePack +{ + /// + /// Format version of the file. Bumped only when a change would make an older build misread a newer + /// pack; a build refuses a schema from the future rather than guess at what it means. + /// + [JsonPropertyName("schema")] + public int Schema { get; set; } = 1; + + /// Shown in the pack list. Falls back to the file name. + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("author")] + public string? Author { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("sources")] + public List Sources { get; set; } = []; +} + +/// The shapes of source LuaTools knows how to fetch. A pack picks one; it cannot supply its own. +public enum SourceKind +{ + /// One <appid>.zip per game, holding the lua and its .manifest files. + ManifestZip, + + /// One <appid>.lua per game: entitlements and depot keys, no manifests. + LuaFile, +} + +/// A single source declared by a pack. +public sealed class SourceEntry +{ + /// + /// Key used for the row, ordering and the download route. Must not be one the app already uses — + /// a pack that claims an existing name is refused rather than silently shadowing it. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// What the Add page's row shows. Falls back to . + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// "manifestZip" or "luaFile", case-insensitive. + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + /// Where to fetch, containing the literal token {appid}. + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + /// + /// Tried in order when the primary url is unreachable. The reason packs are worth having at all: a + /// repo that moves or goes stale becomes a line to edit rather than a release to cut. + /// + [JsonPropertyName("mirrors")] + public List Mirrors { get; set; } = []; + + /// Short label on the row. Cosmetic; the app never reads it back as a capability. + [JsonPropertyName("badge")] + public string? Badge { get; set; } +} diff --git a/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs b/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs new file mode 100644 index 0000000..4323381 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs @@ -0,0 +1,158 @@ +using System.IO; +using System.Text.Json; + +namespace LuaToolsGui.Services.Sources; + +/// A source a pack declared, after the registry has vetted it. +public sealed record PackSource( + string Name, + string DisplayName, + SourceKind Kind, + string Url, + IReadOnlyList Mirrors, + string? Badge); + +/// One pack file, and what became of it. +public sealed class LoadedPack +{ + public required string FileName { get; init; } + public required string DisplayName { get; init; } + public string? Author { get; init; } + public string? Description { get; init; } + + /// Null when the pack loaded; otherwise why it was refused, in words for the user. + public string? Error { get; set; } + + public List Sources { get; } = []; +} + +/// +/// Reads manifest sources from %AppData%\LuaToolsGui\sources\*.json. +/// +/// +/// The sources this app can fetch from are otherwise fixed at build time, so following one to a +/// fresher mirror — or adding a community repo at all — means cutting a release. A pack file moves that +/// to editing a line of JSON. +/// +/// A pack is data only. There is deliberately no way for one to supply code, a binary or a +/// fetch routine of its own: it names one of the shapes the app already knows how to consume, and the +/// app does the fetching. Installing a pack from a stranger cannot execute anything. +/// +/// Nothing here may stop the app from starting. Every stage is wrapped, a bad file is recorded +/// against its own name and the loop moves on. +/// +public sealed class SourcePackRegistry +{ + /// Highest pack schema this build understands. + private const int SupportedSchema = 1; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public static string Root { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "sources"); + + private readonly List _packs = []; + + /// Every pack file found, loaded or refused. + public IReadOnlyList Packs => _packs; + + /// Sources contributed by the packs that loaded, in file then declaration order. + public IReadOnlyList Sources => _packs.SelectMany(p => p.Sources).ToList(); + + /// + /// Re-read the folder. Cheap (a handful of small files) and safe to call whenever the list might + /// have changed, which is what lets a pack be added without restarting the app. + /// + /// + /// Source names the app already uses. A pack claiming one is refused rather than shadowing it: a row + /// whose behaviour depended on which registration won would be impossible to reason about. + /// + public void Reload(IReadOnlyCollection reservedNames) + { + _packs.Clear(); + + List files; + try + { + if (!Directory.Exists(Root)) return; + files = Directory.EnumerateFiles(Root, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch { return; } + + var taken = new HashSet(reservedNames, StringComparer.OrdinalIgnoreCase); + + foreach (string file in files) + { + string shown = Path.GetFileName(file); + SourcePack? pack; + try { pack = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOpts); } + catch (Exception ex) + { + _packs.Add(new LoadedPack { FileName = shown, DisplayName = shown, Error = ex.Message }); + continue; + } + + if (pack is null) + { + _packs.Add(new LoadedPack { FileName = shown, DisplayName = shown, Error = "the file is empty" }); + continue; + } + + var loaded = new LoadedPack + { + FileName = shown, + DisplayName = string.IsNullOrWhiteSpace(pack.Name) ? shown : pack.Name!, + Author = pack.Author, + Description = pack.Description, + }; + _packs.Add(loaded); + + if (pack.Schema > SupportedSchema) + { + loaded.Error = $"needs a newer LuaTools (schema {pack.Schema}, this build reads {SupportedSchema})"; + continue; + } + + foreach (var e in pack.Sources) Vet(loaded, e, taken); + } + } + + /// Adds one declared source, or records why it was skipped. Never throws. + private static void Vet(LoadedPack pack, SourceEntry e, HashSet taken) + { + void Skip(string why) => pack.Error = pack.Error is null ? why : $"{pack.Error}; {why}"; + + if (string.IsNullOrWhiteSpace(e.Name) || string.IsNullOrWhiteSpace(e.Url)) + { Skip("a source is missing its name or url"); return; } + + if (!Enum.TryParse(e.Kind, ignoreCase: true, out var kind)) + { Skip($"\"{e.Name}\" has an unknown kind \"{e.Kind}\""); return; } + + // Everything a pack fetches goes over TLS. A source anyone can add is not the place to accept + // plaintext: the file it returns is installed into Steam. + if (!IsHttps(e.Url) || e.Mirrors.Any(u => !IsHttps(u))) + { Skip($"\"{e.Name}\" must use https"); return; } + + if (!e.Url.Contains("{appid}", StringComparison.OrdinalIgnoreCase)) + { Skip($"\"{e.Name}\" has no {{appid}} in its url"); return; } + + if (!taken.Add(e.Name)) + { Skip($"\"{e.Name}\" is a name the app already uses"); return; } + + pack.Sources.Add(new PackSource( + e.Name, + string.IsNullOrWhiteSpace(e.DisplayName) ? e.Name : e.DisplayName!, + kind, + e.Url, + e.Mirrors.Where(IsHttps).ToArray(), + e.Badge)); + } + + private static bool IsHttps(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var u) && u.Scheme == Uri.UriSchemeHttps; +} diff --git a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs index 1e6e4ec..88199f2 100644 --- a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs @@ -46,13 +46,17 @@ public partial class SourceRowViewModel : ObservableObject // would collapse it anyway; this just keeps the button from looking clickable. public bool CanDownload => IsAvailable && !IsLocked && QueueItem?.IsActive != true; - public SourceRowViewModel(DownloadViewModel parent, string name, string status) + /// + /// Overrides the source-meta table. A source declared by a pack is not in it and never will be — + /// its label travels with the declaration. + /// + public SourceRowViewModel(DownloadViewModel parent, string name, string status, string? displayName = null) { _parent = parent; Name = name; Status = status; var meta = SourceMeta.Get(name); - DisplayName = meta.DisplayName ?? name; + DisplayName = displayName ?? meta.DisplayName ?? name; DiscordUrl = meta.DiscordUrl; NeedsKey = meta.RequiresUserKey; } @@ -89,6 +93,8 @@ public partial class DownloadViewModel : ObservableObject private readonly HardwareAppIdService _hardware; private readonly DownloadQueue _queue; private readonly ManifestJobFactory _jobs; + private readonly Services.Sources.SourcePackRegistry _packs; + private readonly Services.Sources.PackSourceService _packSources; private CancellationTokenSource? _searchCts; private CancellationTokenSource? _detailsCts; @@ -324,8 +330,11 @@ public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsSe AuthService auth, ToastService toast, LuaInstaller installer, SteamAppListCache appList, SteamAppInfoCache appInfo, SteamDepotInfo depotInfo, HardwareAppIdService hardware, DropInstallViewModel drop, - DownloadQueue queue, ManifestJobFactory jobs) + DownloadQueue queue, ManifestJobFactory jobs, + Services.Sources.SourcePackRegistry packs, Services.Sources.PackSourceService packSources) { + _packs = packs; + _packSources = packSources; _api = api; _hubcap = hubcap; _settings = settings; @@ -543,6 +552,8 @@ private async Task FetchAsync() foreach (var (name, status) in statuses.OrderByDescending(kv => SourceMeta.Get(kv.Key).RequiresUserKey ? 1 : 0)) Sources.Add(new SourceRowViewModel(this, name, status)); + await AddPackSourcesAsync(Details.AppId); + await ApplyHubcapStateAsync(); if (FastFetch) @@ -667,8 +678,14 @@ public async Task RefreshStandardUsageAsync() // Hubcap downloads use the user's OWN key and never touch lua.tools, so a guest with a key // configured can download without signing in. Every other source still needs a lua.tools account. + // A pack source is fetched straight from the url its file names and never touches lua.tools, so + // a lua.tools account has no bearing on it. Resolved before the gate for that reason. + var packSource = _packs.Sources.FirstOrDefault(x => + string.Equals(x.Name, source.Name, StringComparison.OrdinalIgnoreCase)); + bool hubcapWithKey = source.NeedsKey && !string.IsNullOrEmpty(_settings.HubcapApiKey); - if (!hubcapWithKey && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; + if (!hubcapWithKey && packSource is null + && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; Error = null; LastDownload = null; @@ -680,12 +697,20 @@ public async Task RefreshStandardUsageAsync() string gameName = Details.Name; bool needsKey = source.NeedsKey; - var job = _jobs.CreateManifestJob( - appId, gameName, source.Name, needsKey, - // Silent/headless installs have no surfaced window to confirm on, so they skip the gate. - confirm: _silentInstall ? null : (file, _, ct) => ConfirmOverwriteAsync(file, appId, gameName, ct), - onFinished: (item, result) => OnManifestFinished(item, result, needsKey), - onReveal: () => NavigateToGame?.Invoke(appId)); + // Silent/headless installs have no surfaced window to confirm on, so they skip the gate. + Func>? confirm = + _silentInstall ? null : (file, _, ct) => ConfirmOverwriteAsync(file, appId, gameName, ct); + + var job = packSource is not null + ? _jobs.CreatePackSourceJob(packSource, appId, gameName, + confirm: confirm, + onFinished: (item, result) => OnManifestFinished(item, result, needsKey: false), + onReveal: () => NavigateToGame?.Invoke(appId)) + : _jobs.CreateManifestJob( + appId, gameName, source.Name, needsKey, + confirm: confirm, + onFinished: (item, result) => OnManifestFinished(item, result, needsKey), + onReveal: () => NavigateToGame?.Invoke(appId)); var queued = _queue.Enqueue(job); source.QueueItem = queued; @@ -693,6 +718,43 @@ public async Task RefreshStandardUsageAsync() return queued; } + /// + /// Append a row for every pack-declared source that has this game. + /// + /// + /// Appended after the app's own sources rather than ranked among them: their order is a decision + /// this app makes, and a file the user dropped in a folder should not be able to overturn it. + /// Probed in parallel, because the count is whatever the user installed and doing them in sequence + /// would put a pack's latency on the critical path of every fetch. A probe that fails means "this + /// source doesn't have it", never an error. + /// + private async Task AddPackSourcesAsync(long appId) + { + _packs.Reload(SourceMeta.All.Keys.ToList()); + + var sources = _packs.Sources; + if (sources.Count == 0) return; + + var probes = sources.Select(src => (Source: src, Has: SafeHasAsync(src, appId))).ToList(); + await Task.WhenAll(probes.Select(p => p.Has)); + + foreach (var (src, has) in probes) + { + if (!has.Result) continue; + Sources.Add(new SourceRowViewModel(this, src.Name, "available", src.DisplayName) + { + StatsText = src.Badge, + }); + } + } + + /// A HasGameAsync that never throws: a failed or offline lookup just means "not covered". + private async Task SafeHasAsync(Services.Sources.PackSource src, long appId) + { + try { return await _packSources.HasGameAsync(src, appId); } + catch { return false; } + } + /// DLC lua: download and install silently (it's just an unlock, no confirm). [RelayCommand] private async Task GenerateDlcAsync() diff --git a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs index 7914cd5..e02b511 100644 --- a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs @@ -22,6 +22,7 @@ public partial class SettingsViewModel : ObservableObject private readonly AuthService _auth; private readonly SteamService _steam; private readonly HubcapService _hubcap; + private readonly Services.Sources.SourcePackRegistry _packs; [ObservableProperty] private string? _displayName; [ObservableProperty] private string? _email; @@ -244,8 +245,9 @@ partial void OnSelectedLanguageChanged(LanguageOption value) public Action? RequestRestartPrompt { get; set; } public SettingsViewModel(SettingsService settings, AuthService auth, SteamService steam, - HubcapService hubcap) + HubcapService hubcap, Services.Sources.SourcePackRegistry packs) { + _packs = packs; _settings = settings; _auth = auth; _steam = steam; @@ -392,6 +394,10 @@ public void OnViewLoaded() // that only read the setting at construction). No-op if unchanged; a real change writes back the // same value, so no feedback loop. FastFetch = _settings.FastFetch; + + // Re-read the source-pack folder every time the page is shown: dropping a file in and coming + // back here to check it was accepted is the natural gesture. Cheap - a handful of small files. + RefreshSourcePacks(); } /// Re-fetch usage stats for the saved key. Silent no-op if no key is saved. @@ -477,4 +483,41 @@ private static string FormatHubcapStats(HubcapStats stats) expiry.ToString("yyyy-MM-dd")); return usage; } + // ── Manifest source packs ──────────────────────────────────────── + + /// One line per .json file found: what it contributed, or why it was refused. + public System.Collections.ObjectModel.ObservableCollection SourcePacks { get; } = []; + + public bool HasSourcePacks => SourcePacks.Count > 0; + + /// + /// Re-read the source-pack folder. Called when the page is shown, so dropping a file in and coming + /// back here is enough to see whether it was accepted - no restart, since a pack is only ever data. + /// + public void RefreshSourcePacks() + { + _packs.Reload(Models.SourceMeta.All.Keys.ToList()); + + SourcePacks.Clear(); + foreach (var pack in _packs.Packs) + { + string detail = pack.Error is not null + ? pack.Error + : string.Format(Resources.Strings.Settings_SourcePacks_Count, pack.Sources.Count); + SourcePacks.Add($"{pack.DisplayName} — {detail}"); + } + OnPropertyChanged(nameof(HasSourcePacks)); + } + + [RelayCommand] + private void OpenSourcesFolder() + { + try + { + System.IO.Directory.CreateDirectory(Services.Sources.SourcePackRegistry.Root); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + Services.Sources.SourcePackRegistry.Root) { UseShellExecute = true }); + } + catch { /* opening a folder is never worth an error dialog */ } + } } diff --git a/src/LuaToolsGui/Views/SettingsView.xaml b/src/LuaToolsGui/Views/SettingsView.xaml index 00c54fb..06144c0 100644 --- a/src/LuaToolsGui/Views/SettingsView.xaml +++ b/src/LuaToolsGui/Views/SettingsView.xaml @@ -575,6 +575,45 @@ IsChecked="{Binding DonateKeys, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + +