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/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. diff --git a/README.md b/README.md index 2744380..6842f24 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,64 @@ -# LuaTools GUI - -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. +

+ lt + +

+ +# 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) +

+ +## Statistics +
+ + +
+ + + + + + Star History Chart + + ## 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 - 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 -``` - -> **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 -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 | + 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 -## Contributing +## 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. -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. - -## Related +## Credits / Adjacent software - [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 from game executables +- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud revival project, can be turned on via the mode page ## Licence MIT. See [LICENSE](LICENSE). + +928c14bad5bbc258894b050af1e17ba8 + 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/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/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index 68d5d95..67fb010 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using System.Windows.Threading; using LuaToolsGui.Models; using LuaToolsGui.Services; @@ -13,8 +13,8 @@ public partial class App : Application { private readonly IHost _host; - // True when the app was cold-started solely to run a silent install AND MinimizeToTray is off — - // means we auto-exit after the balloon so we don't leave a ghost tray icon behind. + // True when the app was cold-started solely to run a silent install AND MinimizeToTray is off, + // which means we auto-exit after the balloon so we don't leave a ghost tray icon behind. private bool _exitAfterSilentInstall; public App() @@ -30,6 +30,8 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -40,7 +42,11 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); // one per page (Home, Add) @@ -48,12 +54,18 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Central download queue. Singleton + hosted service (same pattern as HttpServerService + // below): the hosted lifetime runs the scheduler pump, and view models resolve the same + // instance to enqueue and observe. + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); // Hook loader infrastructure services.AddSingleton(); services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); // Also resolvable as a plain singleton (not just IHostedService) so PluginInstallerService - // can call ReloadPluginFilesAsync() after install/uninstall — same pattern as HttpServerService. + // can call ReloadPluginFilesAsync() after install/uninstall. Same pattern as HttpServerService. services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); services.AddSingleton(); @@ -64,12 +76,14 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // Pages resolved by NavigationView via the DI service provider. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -84,15 +98,15 @@ public App() private UpdateService Updates => _host.Services.GetRequiredService(); // Guards RunUpdateFlowAsync so overlapping triggers (startup + the re-poke a DLL/Steam restart causes) - // never run it concurrently — a second caller drops out immediately. + // never run it concurrently. A second caller drops out immediately. private readonly System.Threading.SemaphoreSlim _updateFlowGate = new(1, 1); /// /// Warn when Steam has overwritten launch options we'd applied, and offer to put them back. /// /// - /// Steam rebuilds appinfo.vdf from PICS on login, app updates and store browsing — it did so twice - /// while this feature was being written — so an applied edit is not permanent. Re-applying is offered + /// Steam rebuilds appinfo.vdf from PICS on login, app updates and store browsing. It did so twice + /// while this feature was being written, so an applied edit is not permanent. Re-applying is offered /// but never automatic: it closes Steam, which is not something to do behind the user's back at /// startup. Costs nothing when no launch options have been edited (the store short-circuits on empty). /// @@ -104,7 +118,7 @@ private async Task CheckLaunchOptionDriftAsync() var launch = _host.Services.GetRequiredService(); if (launch.Store.IsEmpty) return; - // Indexing the ~373 MB cache takes a couple of seconds — never on the UI thread. + // Indexing the ~373 MB cache takes a couple of seconds, never on the UI thread. var drifted = await Task.Run(launch.FindDrifted); if (drifted.Count == 0) return; @@ -117,7 +131,7 @@ private async Task CheckLaunchOptionDriftAsync() } catch { - // Cache locked/unreadable — nothing actionable, and this must never block startup. + // Cache locked/unreadable: nothing actionable, and this must never block startup. } } @@ -125,8 +139,8 @@ private async Task CheckLaunchOptionDriftAsync() /// The drift notice's "Re-apply" button: confirm, then write the staged edits back into appinfo. /// /// - /// The write runs OFF the UI thread. Unlike the launch-options dialog — which is modal, so its own - /// synchronous apply merely blocks a window that's already blocking — this fires with the main window + /// The write runs OFF the UI thread. Unlike the launch-options dialog (which is modal, so its own + /// synchronous apply merely blocks a window that's already blocking), this fires with the main window /// live, and Apply indexes a ~373 MB file, copies a backup and rewrites it. On the UI thread /// that's a multi-second freeze of the whole app. /// @@ -134,7 +148,7 @@ private async Task CheckLaunchOptionDriftAsync() private static async Task ReapplyDriftedAsync( Services.AppInfo.LaunchOptionsService launch, IReadOnlyList drifted, ToastService toast) { - // Same wording as the dialog's own prompt — closing Steam should never read as a different + // Same wording as the dialog's own prompt: closing Steam should never read as a different // decision depending on where it was triggered from. if (MessageBox.Show( LuaToolsGui.Resources.Strings.Launch_ApplyNow_Body, @@ -159,14 +173,14 @@ private static async Task ReapplyDriftedAsync( internal static Func? RunUpdateFlow; /// The Steam-open update flow (fully silent): update the APP first, unconditionally, before - /// ever touching the plugin — then, once the running app is guaranteed current, check/apply a plugin + /// ever touching the plugin. Then, once the running app is guaranteed current, check/apply a plugin /// update against it. Called on a loader (--tray-locked) launch and on the Steam-open re-check poke; /// safe to call repeatedly. /// /// App-before-plugin is load-bearing, not just tidy ordering: the app and plugin are NOT independently /// safe to update out of order whenever a plugin release changes something the app's own compiled code - /// depends on (e.g. 's CDP port is a compile-time constant — - /// an old app build talking to a freshly-updated plugin that moved the port simply can't connect, and + /// depends on (e.g. 's CDP port is a compile-time constant. + /// An old app build talking to a freshly-updated plugin that moved the port simply can't connect, and /// won't self-heal until the app itself happens to update, which is not guaranteed to land in the same /// pass: the app and plugin ship from separate repos on separate cadences, so one can succeed while the /// other fails/lags). Restarting into the latest app FIRST, before it goes anywhere near a plugin @@ -181,7 +195,7 @@ private async Task RunUpdateFlowAsync() // 1) Stage + immediately apply any app update, before touching the plugin at all. // ApplyAndRestart() terminates this process; the relaunched instance (launched with // --tray-locked) re-enters this same flow via OnStartup once it's already current, so this - // run's job ends here — there is nothing safe left for THIS process to do. + // run's job ends here. There is nothing safe left for THIS process to do. try { await Updates.CheckAndStageAsync(); } catch { /* offline / not installed */ } if (Updates.HasStagedUpdate) { @@ -189,7 +203,7 @@ private async Task RunUpdateFlowAsync() return; } - // 2) No app update pending — safe to check/apply a plugin update against this (already-current) app. + // 2) No app update pending: safe to check/apply a plugin update against this (already-current) app. try { var installer = _host.Services.GetRequiredService(); @@ -199,12 +213,12 @@ private async Task RunUpdateFlowAsync() if (!st.DllMatches) { var t = _host.Services.GetRequiredService(); - Dispatcher.Invoke(() => t.Show("LuaTools", "Updating plugin — Steam will restart.")); + Dispatcher.Invoke(() => t.Show("LuaTools", "Updating plugin. Steam will restart.")); } await installer.InstallAsync(progress: null); } } - catch { /* offline / install error — retry next Steam-open */ } + catch { /* offline / install error. Retry next Steam-open */ } } finally { _updateFlowGate.Release(); } } @@ -213,8 +227,16 @@ 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 + // the user never answered, leaves a staged zip nobody will ever delete. _ = System.Threading.Tasks.Task.Run(() => { try @@ -223,11 +245,28 @@ protected override async void OnStartup(StartupEventArgs e) Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "LuaTools"); if (System.IO.Directory.Exists(legacy)) System.IO.Directory.Delete(legacy, recursive: true); } - catch { /* best effort — never block startup on cleanup */ } + catch { /* best effort, never block startup on cleanup */ } + + Services.Downloads.HttpFileDownloader.SweepStale(); }); await _host.StartAsync(); + // Rewrite any pre-3-mode SelectedMode BEFORE anything reads it. UnlockerService.SelectedMode + // would otherwise parse a legacy value to null and quietly present an unconfigured app. Users + // whose mode was retired outright (SteamTools, the CloudRedirect fix) have no mode now, so + // onboarding is forced back open: OnboardingComplete is a permanent flag that every existing + // user already has set, and clearing SelectedMode alone would leave them with no mode AND no + // overlay explaining why. + if (ModeMigration.Apply(_host.Services.GetRequiredService())) + _host.Services.GetRequiredService().OnboardingComplete = false; + + // Point OST/BST at config/stplug-in so lua writes hot-reload. Must run AFTER the migration + // above, which is what makes SelectedMode parse. The app no longer tells anyone to restart + // Steam for a lua change, so this registration is what makes that promise true — and it + // previously only ever ran during a mode install through this app. + _host.Services.GetRequiredService().EnsureLuaPathRegistered(); + var main = _host.Services.GetRequiredService(); var settingsVm = _host.Services.GetRequiredService(); @@ -247,7 +286,7 @@ protected override async void OnStartup(StartupEventArgs e) Program.ShowWindowSignal, (_, _) => Dispatcher.Invoke(() => { - // A silent install relaunch stays headless — don't surface the window for it. + // A silent install relaunch stays headless: don't surface the window for it. string? pending = ProtocolService.TryReadPending(); bool silent = pending is not null && ProtocolService.Parse(pending).Silent; if (!silent) @@ -327,7 +366,7 @@ protected override async void OnStartup(StartupEventArgs e) }); // Steam regenerates appinfo.vdf from PICS, wiping launch edits. Check once at startup and - // OFFER to re-apply — never silently, since applying closes Steam. + // OFFER to re-apply, never silently, since applying closes Steam. _ = CheckLaunchOptionDriftAsync(); // Home "recently added" + Add install banner "Reveal" → go to Manage and open that game's detail. @@ -335,8 +374,20 @@ protected override async void OnStartup(StartupEventArgs e) Dispatcher.Invoke(() => { window.NavigateToManage(); _ = manage.OpenDetailForAppIdAsync(appId); }); var home = _host.Services.GetRequiredService(); home.NavigateToGame = openInManage; + + // Deliberately NO queue-wide completion toast here. Every entry point already reports its own + // outcome: Fixes toasts from ManifestJobFactory, the Add page shows its InstallStatus banner, the + // store plugin has its popup, and a silent install pops a tray balloon. A global toast on top of + // those double-notified every one of them. + + // The Downloads tab's "Review" button on an item waiting for an overwrite confirmation: the + // overlay lives on the Add page, so send the user there. + _host.Services.GetRequiredService().RevealItem = _ => window.NavigateToAdd(); + download.NavigateToGame = openInManage; - builds.NavigateToManage = openInManage; // Builds "Manage" button — the reverse of "Manage Build" + builds.NavigateToManage = openInManage; // Builds "Manage" button: the reverse of "Manage Build" + // Depot download queues one item covering the whole selection; show the user where it went. + builds.RequestShowDownloads = () => Dispatcher.Invoke(window.NavigateToDownloads); // Dragging a SteamDB / Steam store link onto either drop box installs that appid. Routed through // HandleProtocolUrl rather than calling ProtocolInstall directly, so a dropped link and @@ -369,7 +420,7 @@ protected override async void OnStartup(StartupEventArgs e) _ = builds.LoadAsync(); // a newly installed lua is a new variant in the vault await home.RefreshLibraryAsync(); // game appears (its cover may lag for newer titles) - // Newer titles have no guessable header URL — the classic CDN path 404s and the real header is + // Newer titles have no guessable header URL: the classic CDN path 404s and the real header is // a content-hashed store_item_assets URL that only comes from appdetails. Warm that game's // details at interactive priority (retries past throttling), then refresh again so its cover // fills in instead of staying blank until an app restart. @@ -403,16 +454,18 @@ protected override async void OnStartup(StartupEventArgs e) window.Show(); // First-run onboarding: show the welcome overlay on a fresh install. Skip it (and mark done) - // when the user is already set up — BetterSteamTools (or Nightly) selected AND the plugin - // installed — so existing users / dev machines aren't nagged. Marking done here is permanent, - // so switching mode later never re-triggers onboarding. + // when the user is already set up (a managed mode selected AND the plugin installed), so + // existing users / dev machines aren't nagged. Marking done here is permanent, so switching + // mode later never re-triggers onboarding (only ModeMigration ever clears it again). var cache = _host.Services.GetRequiredService(); if (!cache.OnboardingComplete) { var unlocker = _host.Services.GetRequiredService(); var installer = _host.Services.GetRequiredService(); + // Custom deliberately doesn't count: a first-run user can't meaningfully choose "I'll + // manage it myself" before they've been shown what the options are. bool configured = - unlocker.SelectedMode is (UnlockerMode.OpenSteamTools or UnlockerMode.OpenSteamToolsNightly) + unlocker.SelectedMode is (UnlockerMode.Ost or UnlockerMode.Bst) && installer.IsInstalledLocally(); if (configured) cache.OnboardingComplete = true; else main.Onboarding.IsOpen = true; @@ -424,7 +477,7 @@ unlocker.SelectedMode is (UnlockerMode.OpenSteamTools or UnlockerMode.OpenSteamT // Background, non-blocking Steam-open update flow (app + plugin), but ONLY in the loader context // (--tray-locked). A manual / protocol / silent-install launch skips it, so the app never - // auto-updates or restarts mid-manual-use — it only happens when Steam launches us. (Velopack only + // auto-updates or restarts mid-manual-use. It only happens when Steam launches us. (Velopack only // updates to a STRICTLY HIGHER version, so every release must bump --packVersion.) if (Program.SessionTrayLock) _ = RunUpdateFlowAsync(); @@ -437,6 +490,10 @@ unlocker.SelectedMode is (UnlockerMode.OpenSteamTools or UnlockerMode.OpenSteamT // 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) @@ -451,7 +508,7 @@ protected override async void OnExit(ExitEventArgs e) } /// Relaunch the app (used after a language change). The single-instance mutex is released - /// only when THIS process exits, so we start the new instance via a short delayed shell command — by + /// only when THIS process exits, so we start the new instance via a short delayed shell command. By /// the time it launches the exe, our mutex is free and the new instance won't bow out. private void RelaunchApp() { @@ -503,7 +560,10 @@ private void HandleProtocolUrl(string url) { window.ShowInstallNotification(msg, error); // Cold launch + no tray app wanted → exit once the balloon has had time to show. - if (_exitAfterSilentInstall) + // ProtocolInstall already awaited this item's completion, but the user (or the + // store plugin) may have queued more; exiting now would cancel them mid-flight. + var queue = _host.Services.GetRequiredService(); + if (_exitAfterSilentInstall && queue.ActiveCount == 0) _ = Task.Delay(6000).ContinueWith(_ => Dispatcher.Invoke(Shutdown)); })); } diff --git a/src/LuaToolsGui/AppConfig.cs b/src/LuaToolsGui/AppConfig.cs index c5a8f85..0a65eb0 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,47 @@ 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"; + + // 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. + // + // 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 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"; // 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 +92,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 +100,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 +118,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 b/src/LuaToolsGui/MainWindow.xaml index 4cb4b36..81f4165 100644 --- a/src/LuaToolsGui/MainWindow.xaml +++ b/src/LuaToolsGui/MainWindow.xaml @@ -1,4 +1,4 @@ - + + + + + diff --git a/src/LuaToolsGui/MainWindow.xaml.cs b/src/LuaToolsGui/MainWindow.xaml.cs index 0f8f6cd..e234c52 100644 --- a/src/LuaToolsGui/MainWindow.xaml.cs +++ b/src/LuaToolsGui/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using LuaToolsGui.Services; using LuaToolsGui.ViewModels; using LuaToolsGui.Views; @@ -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; @@ -118,6 +118,9 @@ public void ShowInstallNotification(string message, bool error) /// Switch to the Add page (used by the Manage page's "Update" action). public void NavigateToAdd() => RootNavigation.Navigate(typeof(DownloadView)); + /// Switch to Downloads (used when an item needs the user to resolve something). + public void NavigateToDownloads() => RootNavigation.Navigate(typeof(DownloadsView)); + /// Switch to Manage (used by Home's "recently added" cards). Caller opens the detail. public void NavigateToManage() => RootNavigation.Navigate(typeof(ManageView)); @@ -136,7 +139,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..43a14eb 100644 --- a/src/LuaToolsGui/Models/FixModels.cs +++ b/src/LuaToolsGui/Models/FixModels.cs @@ -1,8 +1,8 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; 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,9 +50,81 @@ 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 { [JsonPropertyName("url")] public string Url { get; set; } = ""; } + +// ── 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. + +/// +/// 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; } = []; +} + +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/Models/ModeModels.cs b/src/LuaToolsGui/Models/ModeModels.cs index d2eca61..308f58a 100644 --- a/src/LuaToolsGui/Models/ModeModels.cs +++ b/src/LuaToolsGui/Models/ModeModels.cs @@ -2,25 +2,39 @@ namespace LuaToolsGui.Models; -/// The Steam fixes. Mutually exclusive — only one is active at a time. Each fetches its -/// own files from its own release; switching overwrites as needed but doesn't delete old files. -public enum UnlockerMode { SteamTools, OpenSteamTools, CloudRedirect, OpenSteamToolsNightly } +/// +/// The Steam fixes. Mutually exclusive: only one is active at a time. Each fetches its own files +/// from its own release; switching overwrites as needed but doesn't delete old files. +/// +/// The member names are what land in the settings file, and they are deliberately new: no +/// value written by an older build ("SteamTools", "OpenSteamTools", "OpenSteamToolsNightly", +/// "CloudRedirect") parses into this enum. That is what makes +/// inherently one-shot. An unparseable value is by definition pre-migration, so no marker flag is +/// needed. Reusing "OpenSteamTools" here would have silently reinterpreted every old stable-OST +/// user as being on the nightly channel. +/// +public enum UnlockerMode +{ + Ost, // OpenSteamTools: the nightly channel (madoiscool/OST-Nightly) + Bst, // BetterSteamTools: the fork (madoiscool/BetterSteamTools) + Custom, // the user manages their own unlocker; the app places nothing +} /// How a mode's files are delivered/installed. public enum ModeKind { - Loose, // loose DLL assets copied into the Steam root (each verified by its own digest) - Zip, // a single release zip to download, verify, and extract - Cli, // download a CLI tool and run it; it patches/deploys everything itself + Zip, // a single release zip to download, verify, and extract + Manual, // nothing is downloaded or placed. The user handles their own files } -/// State of a mode's files vs. the latest GitHub release. +/// State of a mode's files vs. the latest published build. public enum ModeStatus { Unknown, // offline / GitHub unreachable / Steam not located NotInstalled, UpToDate, UpdateAvailable, + UserManaged, // Custom mode: nothing to check. Distinct from Unknown, which means "couldn't tell". } /// @@ -36,11 +50,18 @@ public sealed record ModeDefinition( string? FixedTag, // e.g. "ST"; null → use the repo's latest release string[] PlaceFiles, // files that end up in the Steam root (for status/verify) string? ZipAssetPattern, // e.g. "OpenSteamTool-{version}-Release.zip"; null unless Kind == Zip - string? CliAssetName, // Kind == Cli: the tool to download (e.g. "CloudRedirectCLI.exe") - string? CliArgs, // Kind == Cli: args to run it with (e.g. "/stfixer") - string? VerifyFile, // Kind == Cli: the file whose digest confirms success (e.g. "cloud_redirect.dll") + // When set, version + hash come from this raw-hosted TOML instead of the GitHub releases API, and + // the zip URL is built from the version it reports. Costs no api.github.com call, so the mode is + // immune to the 60 req/hr unauthenticated limit. See UnlockerService.FetchUpdateManifestAsync. + string? UpdateManifestUrl = null, string? HiddenUnlessFile = null); // if set, the card is hidden unless this file exists in the Steam root (or the mode is active) +/// One published build, as described by a mode's latest.toml update manifest. +/// Release tag, e.g. "v1.0.0". Also builds the zip download URL. +/// Bare filename the hash belongs to, e.g. "OpenSteamTool.dll". +/// Lowercase hex digest of that file. +public sealed record UpdateManifest(string Version, string File, string Sha256); + // ── GitHub release API DTOs ───────────────────────────────────────── public sealed class GithubRelease { @@ -56,16 +77,20 @@ 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. +/// Queried state for one mode. What a Mode-page card binds to. public sealed record ModeState( UnlockerMode Mode, ModeStatus Status, bool IsActive, // is this the currently-selected (active) mode string? LatestVersion); // resolved release tag (for display) -/// State of the CloudRedirect add-on (a feature of the OpenSteamTool Nightly build), derived +/// State of the CloudRedirect add-on (a feature of both the OST nightly and BST builds), derived /// from disk (cloud_redirect.dll presence + [cloud] enabled in opensteamtool.toml) and the latest /// CloudRedirect release. public sealed record CloudRedirectAddonState( 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/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 910a7fc..8a22a60 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // Strongly-typed resource accessor for Strings.resx. Hand-maintained (not IDE-generated) // so it works under `dotnet watch` / CLI builds without the RESX custom tool. @@ -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)); @@ -124,12 +129,12 @@ public static class Strings public static string Mode_Confirm_Body => Get(nameof(Mode_Confirm_Body)); public static string Mode_Cancel => Get(nameof(Mode_Cancel)); public static string Mode_CloseSteamContinue => Get(nameof(Mode_CloseSteamContinue)); - public static string Mode_Desc_SteamTools => Get(nameof(Mode_Desc_SteamTools)); - public static string Mode_Desc_OpenSteamTools => Get(nameof(Mode_Desc_OpenSteamTools)); - public static string Mode_Desc_CloudRedirect => Get(nameof(Mode_Desc_CloudRedirect)); + public static string Mode_Desc_Ost => Get(nameof(Mode_Desc_Ost)); + public static string Mode_Desc_Bst => Get(nameof(Mode_Desc_Bst)); + public static string Mode_Name_Custom => Get(nameof(Mode_Name_Custom)); + public static string Mode_Desc_Custom => Get(nameof(Mode_Desc_Custom)); public static string Mode_CloudRedirect_Manage => Get(nameof(Mode_CloudRedirect_Manage)); public static string Mode_CloudRedirect_LaunchFailed => Get(nameof(Mode_CloudRedirect_LaunchFailed)); - public static string Mode_Desc_OpenSteamToolsNightly => Get(nameof(Mode_Desc_OpenSteamToolsNightly)); public static string Mode_Experimental => Get(nameof(Mode_Experimental)); public static string Mode_CloudRedirectSupport => Get(nameof(Mode_CloudRedirectSupport)); public static string Mode_CloudRedirect_AddonDesc => Get(nameof(Mode_CloudRedirect_AddonDesc)); @@ -160,6 +165,7 @@ public static class Strings public static string Mode_NotInstalled => Get(nameof(Mode_NotInstalled)); public static string Mode_UpToDate => Get(nameof(Mode_UpToDate)); public static string Mode_UpdateAvailable => Get(nameof(Mode_UpdateAvailable)); + public static string Mode_UserManaged => Get(nameof(Mode_UserManaged)); public static string Mode_Btn_Reinstall => Get(nameof(Mode_Btn_Reinstall)); public static string Mode_Btn_Update => Get(nameof(Mode_Btn_Update)); public static string Mode_Btn_Install => Get(nameof(Mode_Btn_Install)); @@ -186,7 +192,6 @@ public static class Strings public static string Drop_Count_Manifests => Get(nameof(Drop_Count_Manifests)); public static string Drop_Result_Installed => Get(nameof(Drop_Result_Installed)); public static string Drop_Result_Failed => Get(nameof(Drop_Result_Failed)); - public static string Drop_Result_RestartApply => Get(nameof(Drop_Result_RestartApply)); // ── Common ── public static string Common_SearchPlaceholder => Get(nameof(Common_SearchPlaceholder)); @@ -270,7 +275,6 @@ public static class Strings public static string Manage_RemoveFailed_Named => Get(nameof(Manage_RemoveFailed_Named)); public static string Manage_RemoveFailed_Count => Get(nameof(Manage_RemoveFailed_Count)); public static string Manage_RestartSteam_Title => Get(nameof(Manage_RestartSteam_Title)); - public static string Manage_RestartSteam_Ask => Get(nameof(Manage_RestartSteam_Ask)); public static string Manage_RestartSteam_Failed => Get(nameof(Manage_RestartSteam_Failed)); // ── Fixes ── @@ -289,8 +293,7 @@ public static class Strings public static string Fixes_Toast_InstallFailed => Get(nameof(Fixes_Toast_InstallFailed)); public static string Fixes_Toast_InstallFailed_Body => Get(nameof(Fixes_Toast_InstallFailed_Body)); public static string Fixes_Toast_FixInstalled => Get(nameof(Fixes_Toast_FixInstalled)); - public static string Fixes_Toast_FixInstalled_Restarting => Get(nameof(Fixes_Toast_FixInstalled_Restarting)); - public static string Fixes_Toast_FixInstalled_Restart => Get(nameof(Fixes_Toast_FixInstalled_Restart)); + public static string Fixes_Toast_FixInstalled_Body => Get(nameof(Fixes_Toast_FixInstalled_Body)); public static string Fixes_Toast_GameNotFound => Get(nameof(Fixes_Toast_GameNotFound)); public static string Fixes_Toast_GameNotFound_Body => Get(nameof(Fixes_Toast_GameNotFound_Body)); public static string Fixes_Toast_PartiallyApplied => Get(nameof(Fixes_Toast_PartiallyApplied)); @@ -300,6 +303,22 @@ 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_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)); + public static string Fixes_MyGames_NotInstalled => Get(nameof(Fixes_MyGames_NotInstalled)); // ── Add / Download ── public static string Add_Title => Get(nameof(Add_Title)); @@ -332,7 +351,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)); @@ -396,7 +414,7 @@ public static class Strings public static string Plugin_Err_NoLuatoolsJs => Get(nameof(Plugin_Err_NoLuatoolsJs)); // ── Builds page ── - // NOT YET TRANSLATED — English-only for now (Get falls back to the English resx for any culture + // NOT YET TRANSLATED: English-only for now (Get falls back to the English resx for any culture // whose Strings..resx is missing the key). See Resources/README.md → "Pending translation". public static string Builds_Title => Get(nameof(Builds_Title)); public static string Builds_Subtitle => Get(nameof(Builds_Subtitle)); @@ -477,4 +495,119 @@ public static class Strings public static string Launch_Drift_Title => Get(nameof(Launch_Drift_Title)); public static string Launch_Drift_Body => Get(nameof(Launch_Drift_Body)); public static string Launch_Drift_Action => Get(nameof(Launch_Drift_Action)); + + // Service error messages, surfaced to the user via toasts / MessageBox. + public static string Err_SteamNotFound => Get(nameof(Err_SteamNotFound)); + public static string Err_GithubUnreachable => Get(nameof(Err_GithubUnreachable)); + public static string Err_UpdateServerUnreachable => Get(nameof(Err_UpdateServerUnreachable)); + public static string Err_Cancelled => Get(nameof(Err_Cancelled)); + public static string Err_CancelledByUser => Get(nameof(Err_CancelledByUser)); + public static string Err_ReleaseMissingDownload => Get(nameof(Err_ReleaseMissingDownload)); + public static string Err_ReleaseMissingFile => Get(nameof(Err_ReleaseMissingFile)); + public static string Err_VerifyFailed => Get(nameof(Err_VerifyFailed)); + public static string Err_VerifyFailedFile => Get(nameof(Err_VerifyFailedFile)); + public static string Err_DownloadMissingFiles => Get(nameof(Err_DownloadMissingFiles)); + public static string Err_WriteFailedCount => Get(nameof(Err_WriteFailedCount)); + public static string Err_WriteFailedFile => Get(nameof(Err_WriteFailedFile)); + public static string Err_OpenDownloadFailed => Get(nameof(Err_OpenDownloadFailed)); + public static string Err_NoSourceAvailable => Get(nameof(Err_NoSourceAvailable)); + public static string Auth_Err_Timeout => Get(nameof(Auth_Err_Timeout)); + public static string Auth_Err_TokenExchangeFailed => Get(nameof(Auth_Err_TokenExchangeFailed)); + public static string Auth_Err_TokenExchangeEmpty => Get(nameof(Auth_Err_TokenExchangeEmpty)); + public static string Auth_Err_NotSignedIn => Get(nameof(Auth_Err_NotSignedIn)); + public static string Auth_Err_RefreshFailed => Get(nameof(Auth_Err_RefreshFailed)); + public static string Auth_Err_RefreshEmpty => Get(nameof(Auth_Err_RefreshEmpty)); + public static string Api_Err_EmptyDownloadLink => Get(nameof(Api_Err_EmptyDownloadLink)); + public static string Api_Err_DownloadFailed => Get(nameof(Api_Err_DownloadFailed)); + public static string Api_Err_RequestFailed => Get(nameof(Api_Err_RequestFailed)); + public static string Api_Err_SessionExpired => Get(nameof(Api_Err_SessionExpired)); + public static string Hubcap_Err_InvalidKey => Get(nameof(Hubcap_Err_InvalidKey)); + public static string Hubcap_Err_LimitReached => Get(nameof(Hubcap_Err_LimitReached)); + public static string Hubcap_Err_NoManifest => Get(nameof(Hubcap_Err_NoManifest)); + public static string Hubcap_Err_DownloadFailed => Get(nameof(Hubcap_Err_DownloadFailed)); + + // ── Downloads tab ── + 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_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)); + public static string Downloads_Status_Downloading => Get(nameof(Downloads_Status_Downloading)); + public static string Downloads_Status_AwaitingConfirm => Get(nameof(Downloads_Status_AwaitingConfirm)); + public static string Downloads_Status_Installing => Get(nameof(Downloads_Status_Installing)); + public static string Downloads_Status_Completed => Get(nameof(Downloads_Status_Completed)); + public static string Downloads_Status_Failed => Get(nameof(Downloads_Status_Failed)); + public static string Downloads_Status_Cancelled => Get(nameof(Downloads_Status_Cancelled)); + public static string Downloads_Action_Cancel => Get(nameof(Downloads_Action_Cancel)); + public static string Downloads_Action_Retry => Get(nameof(Downloads_Action_Retry)); + public static string Downloads_Action_Remove => Get(nameof(Downloads_Action_Remove)); + public static string Downloads_Action_MoveUp => Get(nameof(Downloads_Action_MoveUp)); + public static string Downloads_Action_MoveDown => Get(nameof(Downloads_Action_MoveDown)); + public static string Downloads_Action_ClearHistory => Get(nameof(Downloads_Action_ClearHistory)); + public static string Downloads_Action_Review => Get(nameof(Downloads_Action_Review)); + public static string Downloads_Of => Get(nameof(Downloads_Of)); + public static string Downloads_Eta => Get(nameof(Downloads_Eta)); + public static string Downloads_ActionRequired => Get(nameof(Downloads_ActionRequired)); + public static string Downloads_Err_Interrupted => Get(nameof(Downloads_Err_Interrupted)); + public static string Downloads_Kind_Dlc => Get(nameof(Downloads_Kind_Dlc)); + public static string Fixes_NotInstalled_Hint => Get(nameof(Fixes_NotInstalled_Hint)); + + // ── Depot downloading ── + public static string Downloads_Status_Paused => Get(nameof(Downloads_Status_Paused)); + public static string Downloads_Status_Verifying => Get(nameof(Downloads_Status_Verifying)); + public static string Downloads_Action_Pause => Get(nameof(Downloads_Action_Pause)); + public static string Downloads_Action_Resume => Get(nameof(Downloads_Action_Resume)); + public static string Downloads_Kind_Depot => Get(nameof(Downloads_Kind_Depot)); + public static string Downloads_Depots_Progress => Get(nameof(Downloads_Depots_Progress)); + public static string Depot_Err_Tool => Get(nameof(Depot_Err_Tool)); + public static string Depot_Err_NoKeys => Get(nameof(Depot_Err_NoKeys)); + public static string Depot_Err_NoSpace => Get(nameof(Depot_Err_NoSpace)); + public static string Depot_Err_Failed => Get(nameof(Depot_Err_Failed)); + public static string Depot_Status_Done => Get(nameof(Depot_Status_Done)); + public static string Builds_Action_Download => Get(nameof(Builds_Action_Download)); + public static string Builds_Select_Title => Get(nameof(Builds_Select_Title)); + public static string Builds_Select_Confirm => Get(nameof(Builds_Select_Confirm)); + public static string Builds_Select_NoManifest => Get(nameof(Builds_Select_NoManifest)); + public static string Builds_Select_SignIn => Get(nameof(Builds_Select_SignIn)); + public static string Depot_Err_SignIn => Get(nameof(Depot_Err_SignIn)); + public static string Depot_Err_NoManifest => Get(nameof(Depot_Err_NoManifest)); + public static string Downloads_Depot_FetchingManifest => Get(nameof(Downloads_Depot_FetchingManifest)); + public static string Builds_Select_All => Get(nameof(Builds_Select_All)); + public static string Builds_Select_None => Get(nameof(Builds_Select_None)); + 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)); + 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)); + 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 8486d81..94968f4 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 تغيير… فتح @@ -157,16 +162,17 @@ إلغاء إغلاق Steam والمتابعة - ‏SteamTools العريق — صنعه الصينيون وأحبّه العالم. الأداة التي بدأت الموجة كلها (GreenLuma غير موجود، صهْ). ⚠️ لا يعمل حاليًا، استخدم BETTERSTEAMTOOLS - نسخة معاد بناؤها من الصفر من SteamTools. أفضل من SteamTools في كل شيء تقريبًا. - ‏SteamTools مع بعض التحسينات الذكية — كمٌّ هائل من الإصلاحات والتحسينات. يعيد دعم Steam Cloud الأصلي عبر Google Drive / OneDrive! + نسخة مفتوحة المصدر من OpenSteamTools يطوّرها فريق LuaTools باستمرار. مع إصلاحات وميزات جديدة! إدارة تعذّر تنزيل CloudRedirect أو تشغيله. - إصدار ليلي من BetterSteamTools (OST) مبني من أحدث المصادر. أحدث الإصلاحات، لكنه قد يكون أقل استقرارًا. + إصدار ليلي من OpenSteamTools مبني من أحدث المصادر. أحدث الإصلاحات، لكنه قد يكون أقل استقرارًا. + الأمر متروك لك الآن + استخدم حل فتح القفل الخاص بك، لن نتدخل فيه + أداة فتح مخصصة تجريبي دعم CloudRedirect - يعيد دعم Steam Cloud الأصلي عبر مزوّدي تخزين خارجيين مثل Google Drive / OneDrive / Cloudflare R2 وغيرها. متوافق حاليًا مع BetterSteamTools Nightly فقط. لا تنسَ إعداده بالضغط على "إدارة" بعد التفعيل، ثم أعد تشغيل Steam! - اختر BetterSteamTools Nightly لاستخدام CloudRedirect. + يعيد دعم Steam Cloud الأصلي عبر مزوّدي تخزين خارجيين مثل Google Drive / OneDrive / Cloudflare R2 وغيرها. متوافق حاليًا مع OpenSteamTools / BetterSteamTools فقط. لا تنسَ إعداده بالضغط على "إدارة" بعد التفعيل، ثم أعد تشغيل Steam! + اختر OpenSteamTools / BetterSteamTools لاستخدام CloudRedirect. تفعيل تعطيل مُفعَّل. @@ -219,7 +225,6 @@ {0} ملف manifest تم تثبيت {0} في Steam. فشل {0} من الملفات — أغلِق Steam وأعِد المحاولة. - أعد تشغيل Steam للتطبيق. ابحث بالاسم أو App ID... @@ -293,13 +298,13 @@ مستودع مشترك DLC {0} إزالة ملف lua - إزالة "{0}" (App ID {1})؟ + إزالة ‏«{0}» ‏(App ID {1})؟ -سيؤدي ذلك إلى حذف ملف ‎.lua الخاص بها من Steam\config\stplug-in. أعد تشغيل Steam بعد ذلك ليسري التغيير. +سيؤدي هذا إلى حذف ملف ‎.lua الخاص به من Steam\config\stplug-in. إزالة ملفات lua إزالة {0} من ملفات lua؟ -سيؤدي ذلك إلى حذف ملفات ‎.lua من Steam\config\stplug-in. أعد تشغيل Steam بعد ذلك لتسري التغييرات. +سيؤدي هذا إلى حذف ملفات ‎.lua من Steam\config\stplug-in. فشلت الإزالة تعذّر حذف الملف: {0} @@ -307,7 +312,6 @@ {1} تعذّر حذف {0} من الملفات. إعادة تشغيل Steam - إعادة تشغيل Steam الآن ليسري مفعول التغييرات؟ تعذّر العثور على Steam أو تشغيله. حدّد موقعه في الإعدادات. @@ -327,8 +331,7 @@ فشل التثبيت تعذّر التثبيت — أغلِق Steam (أو أعد تشغيله) وحاول مجددًا. تم تثبيت الإصلاح - تم تثبيت manifest الخاص بـ {0} — يُعاد تشغيل Steam. - تم تثبيت manifest الخاص بـ {0}. أعد تشغيل Steam للتطبيق. + تم تثبيت ملف بيان {0}. اللعبة غير موجودة ثبّت {0} في Steam أولًا، ثم طبّق الإصلاح. طُبِّق الإصلاح جزئيًا @@ -371,13 +374,12 @@ تعذّر تحديد اللعبة الأساسية لمحتوى DLC هذا حدث خطأ ما — تحقق من اتصالك وحاول مجددًا. فشل التنزيل — تحقق من اتصالك وحاول مجددًا. - فشل الإنشاء — تحقق من اتصالك وحاول مجددًا. استبدال "{0}"؟ لا تغييرات في المستودع/DLC — المحتوى نفسه. أُلغي التثبيت — تُركت الملفات الحالية دون تغيير. تعذّر تثبيت {0} من الملفات — أغلِق Steam (أو استخدم إعادة تشغيل Steam) وحاول مجددًا. - تمت إضافة {0} — ملف lua + {1} ملف manifest. أعد تشغيل Steam للتطبيق. - تمت إضافة {0} — سيجلب Steam ملفات manifest. أعد تشغيل Steam للتطبيق. + تمت إضافة {0}: lua + {1} ملف بيان. + تمت إضافة {0}. سيجلب Steam ملفات البيان. فتح في SteamDB @@ -476,9 +478,9 @@ حذف هذا الإعداد المسبق؟ ستتم إزالة «{0}» من إعداداتك المسبقة المحفوظة. هذا لا يؤثر على اللعبة نفسها. لا يمكن حذف الإعداد المسبق المستخدم حاليًا. - يُستخدم الآن «{0}». أعد تشغيل Steam ليسري المفعول. + يتم الآن استخدام ‏«{0}». تعذّر تبديل الإعداد المسبق — قد يكون ملف lua قيد الاستخدام. - تم الحفظ. أعد تشغيل Steam ليسري المفعول. + تم الحفظ. تعذّر الحفظ — قد يكون ملف lua قيد الاستخدام. تم الحفظ كإعداد مسبق. حفظ في «{0}» @@ -524,4 +526,134 @@ أُعيد ضبط خيارات التشغيل استبدل Steam خيارات التشغيل الخاصة بك لـ {0} لعبة. إعادة التطبيق تُغلق Steam ثم تعيد فتحه. إعادة التطبيق + لم يتم العثور على موقع Steam. حدّده من الإعدادات. + تعذّر الوصول إلى GitHub. تحقّق من اتصالك وحاول مجددًا. + تعذّر الوصول إلى خادم التحديثات. تحقّق من اتصالك وحاول مجددًا. + تم الإلغاء. + ألغاه المستخدم + الإصدار ينقصه ملف التنزيل المطلوب. + الإصدار ينقصه {0}. + فشل التحقق من التنزيل (عدم تطابق sha256). + فشل التحقق من {0} (عدم تطابق sha256). + ناقص من التنزيل: {0}. + تعذّرت كتابة {0} من الملفات. أغلق Steam وحاول مجددًا. + تعذّرت كتابة {0}. أغلق Steam وحاول مجددًا. + تعذّر فتح الملف الذي تم تنزيله: {0} + لا يوجد مصدر متاح لهذه اللعبة. + انتهت مهلة تسجيل الدخول. لا استجابة من المتصفح. + فشل تبادل الرمز ({0}): {1} + أعاد تبادل الرمز جلسة فارغة. + لم تسجّل الدخول. + فشل تحديث الجلسة ({0}). + أعاد تحديث الجلسة جلسة فارغة. + كان رابط التنزيل فارغًا. حاول مجددًا. + فشل التنزيل ({0}). + فشل الطلب ({0}) + انتهت صلاحية الجلسة. يرجى تسجيل الدخول مجددًا. + مفتاح Hubcap الخاص بك غير صالح أو منتهي الصلاحية. + لقد بلغت حدّك اليومي في Hubcap. + لا يوجد بيان Hubcap متاح لهذا التطبيق. + فشل التنزيل من Hubcap ({0}). + التنزيلات + التنزيلات + لا توجد تنزيلات الآن. ابدأ بتنزيل شيء ما! + قائمة الانتظار + السجل + في الانتظار + جارٍ التنزيل + بانتظارك + جارٍ التثبيت + تم + فشل + أُلغي + إلغاء + إعادة المحاولة + إزالة + تحريك لأعلى + تحريك لأسفل + مسح السجل + مراجعة + {0} من {1} + يتبقى {0} + مطلوب إجراء + توقّف عند إغلاق التطبيق. + فتح DLC + ثبّت اللعبة أولاً لتطبيق الإصلاح. + متوقف مؤقتًا + جارٍ التحقق… + إيقاف مؤقت + استئناف + ملفات المستودع + المستودعات · {0} من {1} + تعذّر الحصول على أداة تنزيل المستودعات. + لم يتم العثور على مفاتيح فك تشفير لهذه اللعبة. + فشل المستودع {0}: {1} + تم تنزيل {0} مستودع إلى {1}. + تنزيل + اختر المستودعات المراد تنزيلها + تنزيل {0} مستودع ({1}) + هذا المستودع لا يعلن عن أي إصدار للتنزيل. + تحديد الكل + لم يتم اختيار أي مستودع + اختر مكان حفظ ملفات المستودع + لا توجد مساحة كافية على القرص: يحتاج {0}، والمتاح {1} فقط. + حفظ في + يحتاج {0} · متاح {1} على {2} + سجّل الدخول لتنزيل هذا المستودع. + سجّل الدخول لتنزيل المستودعات. + جارٍ جلب الملف + تعذّر الحصول على ملف 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}) من سجل التنزيلات؟ لن تتأثر الملفات التي تم تنزيلها. + تم تطبيق هذا الإصلاح بالفعل. تراجع عنه إذا أردت تثبيت إصلاح آخر. + ألعابي + {0} من ألعابك لديها إصلاحات. مرّر القائمة لعرض الباقي. + لم تتم إضافة أي ملفات lua بعد. أضف (أو ثبّت) لعبة لتظهر هنا. + تراجع + إلغاء + هل تريد التراجع عن "{0}"؟ ستتم استعادة الملفات المعدّلة من النسخة الاحتياطية وحذف الملفات التي أضافها الإصلاح. + هل تريد التراجع عن إصلاح {0}؟ + تم التراجع عن الإصلاح + تمت استعادة {0} وإزالة {1} من الملفات المضافة. + تعذّر التراجع عن الإصلاح + لا يوجد سجل لتطبيق هذا الإصلاح. ربما تم التراجع عنه بالفعل. + لا يمكن التراجع عن الإصلاح بأمان + تغيّر {0} منذ تطبيق هذا الإصلاح، لذا فإن استعادته ستستبدل تلك التغييرات. إذا طبّقت إصلاحًا آخر فوقه، فتراجع عنه أولاً. الملفات المتأثرة: {1}. + تم التراجع عن الإصلاح جزئيًا + تعذّرت استعادة {0} من الملفات. أغلق اللعبة وحاول مرة أخرى. + هذا المصدر لا يحتوي على هذه اللعبة، أو تعذّر الوصول إليه. + مصادر المانيفست + مصادر إضافية معرَّفة في ملفات ‎.json. بيانات فقط: الملف يحدّد من أين تُجلب المانيفستات، ولا يُنفَّذ منه أي شيء. + فتح المجلد + لا توجد ملفات مصادر بعد. + المصادر: {0} diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 76a8136..20bdf72 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 Промяна… Отваряне @@ -148,16 +153,17 @@ Steam ще бъде затворен, за да могат да се променят файловете му, след което ще се рестартира автоматично. Всички работещи игри или изтегляния ще спрат. Отказ Затваряне на Steam и продължаване - Старият добър SteamTools — създаден от китайците, обичан от света. Отключвачът, който започна цялата тенденция (GreenLuma не съществува, шшт). ⚠️ В МОМЕНТА НЕ РАБОТИ, ИЗПОЛЗВАЙТЕ BETTERSTEAMTOOLS - Изцяло преработена версия на SteamTools, създадена от нулата. По-добра от SteamTools практически във всичко. - SteamTools с няколко хитри корекции — куп поправки и подобрения. Връща нативния Steam Cloud чрез Google Drive / OneDrive! + Форк с отворен код на OpenSteamTools, активно поддържан от екипа на LuaTools. С поправки и нови функции! Управление CloudRedirect не можа да бъде изтеглен или стартиран. - Нощна компилация на BetterSteamTools (OST) от най-новия код. Най-новите поправки, но може да е по-нестабилна. + Нощна компилация на OpenSteamTools от най-новия код. Най-новите поправки, но може да е по-нестабилна. + Оттук нататък сам + Използвай собствено решение за отключване — няма да го пипаме + Собствен отключвател ЕКСПЕРИМЕНТАЛНО ПОДДРЪЖКА НА CLOUDREDIRECT - Връща вградената поддръжка на Steam Cloud чрез външни доставчици на хранилище като Google Drive / OneDrive / Cloudflare R2 и други. Засега е съвместимо само с BetterSteamTools Nightly. Не забравяйте да го конфигурирате, като щракнете върху "Управление" след включване, след което рестартирайте Steam! - Изберете BetterSteamTools Nightly, за да използвате CloudRedirect. + Връща вградената поддръжка на Steam Cloud чрез външни доставчици на хранилище като Google Drive / OneDrive / Cloudflare R2 и други. Засега е съвместимо само с OpenSteamTools / BetterSteamTools. Не забравяйте да го конфигурирате, като щракнете върху "Управление" след включване, след което рестартирайте Steam! + Изберете OpenSteamTools / BetterSteamTools, за да използвате CloudRedirect. Включи Изключи Включено. @@ -207,7 +213,6 @@ {0} манифест(а) {0} е инсталиран(и) в Steam. {0} файл(а) са неуспешни — затворете Steam и опитайте отново. - Рестартирайте Steam, за да приложите. Търсене по име или App ID... Зареждане… App ID: {0} @@ -275,13 +280,13 @@ Споделено депо DLC {0} Премахване на lua файл - Премахване на „{0}“ (App ID {1})? + Да се премахне ли „{0}“ (App ID {1})? -Това изтрива неговия .lua файл от Steam\config\stplug-in. След това рестартирайте Steam, за да влезе в сила промяната. +Това изтрива неговия .lua файл от Steam\config\stplug-in. Премахване на lua файлове - Премахване на {0} lua файла? + Да се премахнат ли {0} lua файла? -Това изтрива .lua файловете от Steam\config\stplug-in. След това рестартирайте Steam, за да влязат в сила промените. +Това изтрива .lua файловете от Steam\config\stplug-in. Премахването е неуспешно Файлът не може да бъде изтрит: {0} @@ -289,7 +294,6 @@ {1} {0} файла не можаха да бъдат изтрити. Рестартиране на Steam - Рестартиране на Steam сега, за да влязат в сила промените? Steam не може да бъде намерен или стартиран. Задайте местоположението му в Настройки. Корекции Зареждане на корекции… @@ -306,8 +310,7 @@ Инсталирането е неуспешно Не може да се инсталира — затворете Steam (или го рестартирайте) и опитайте отново. Корекцията е инсталирана - Манифестът на {0} е инсталиран — Steam се рестартира. - Манифестът на {0} е инсталиран. Рестартирайте Steam, за да приложите. + Манифестът на {0} е инсталиран. Играта не е намерена Първо инсталирайте {0} в Steam, след което приложете корекцията. Корекцията е приложена частично @@ -347,13 +350,12 @@ Не може да се определи основната игра за това DLC Нещо се обърка — проверете връзката си и опитайте отново. Изтеглянето е неуспешно — проверете връзката си и опитайте отново. - Генерирането е неуспешно — проверете връзката си и опитайте отново. Замяна на „{0}“? Няма промени в депо/DLC — същото съдържание. Инсталирането е отказано — съществуващите файлове са непроменени. Не можаха да се инсталират {0} файл(а) — затворете Steam (или използвайте Рестартиране на Steam) и опитайте отново. - {0} е добавен — lua + {1} манифест(а). Рестартирайте Steam, за да приложите. - {0} е добавен — Steam ще извлече манифестите. Рестартирайте Steam, за да приложите. + Добавено {0}: lua + {1} манифест(а). + Добавено {0}. Steam ще изтегли манифестите. Отваряне в SteamDB СПОДЕЛЕНО Това замества инсталирания lua. Прегледайте какво се променя: @@ -450,9 +452,9 @@ Да се изтрие ли тази настройка? „{0}“ ще бъде премахната от запазените ви настройки. Това не засяга самата игра. Настройката, която се използва в момента, не може да бъде изтрита. - Сега се използва „{0}“. Рестартирайте Steam, за да влезе в сила. + Сега се използва „{0}“. Настройката не можа да бъде сменена — lua файлът може да е зает. - Запазено. Рестартирайте Steam, за да влезе в сила. + Запазено. Запазването е неуспешно — lua файлът може да е зает. Запазено като предварителна настройка. Запази в „{0}“ @@ -498,4 +500,134 @@ Опциите за стартиране бяха нулирани Steam е презаписал опциите ви за стартиране за {0} игри. Повторното прилагане затваря и отваря отново Steam. Приложи отново + Местоположението на Steam не е намерено. Задай го в Настройки. + GitHub е недостъпен. Провери връзката си и опитай отново. + Сървърът за обновления е недостъпен. Провери връзката си и опитай отново. + Отменено. + Отменено от потребителя + В изданието липсва очакваният файл за изтегляне. + В изданието липсва {0}. + Изтеглянето не премина проверката (несъответствие на sha256). + {0} не премина проверката (несъответствие на sha256). + В изтеглянето липсва: {0}. + Не бяха записани {0} файл(а). Затвори Steam и опитай отново. + {0} не беше записан. Затвори Steam и опитай отново. + Изтегленият файл не можа да се отвори: {0} + Няма наличен източник за тази игра. + Времето за вход изтече. Няма отговор от браузъра. + Обменът на токен е неуспешен ({0}): {1} + Обменът на токен върна празна сесия. + Не си влязъл в профила си. + Обновяването на сесията е неуспешно ({0}). + Обновяването на сесията върна празна сесия. + Връзката за изтегляне беше празна. Опитай отново. + Изтеглянето е неуспешно ({0}). + Заявката е неуспешна ({0}) + Сесията изтече. Влез отново. + Ключът ти за Hubcap е невалиден или изтекъл. + Достигна дневния си лимит за Hubcap. + Няма наличен Hubcap манифест за това приложение. + Изтеглянето от Hubcap е неуспешно ({0}). + Изтегляния + Изтегляния + В момента няма изтегляния. Хайде, свали нещо! + Опашка + История + На опашка + Изтегляне + Чака теб + Инсталиране + Готово + Неуспешно + Отказано + Отказ + Опитай пак + Премахни + Нагоре + Надолу + Изчисти историята + Прегледай + {0} от {1} + Остават {0} + Изисква действие + Прекъснато при затваряне на приложението. + Отключване на DLC + Първо инсталирайте играта, за да приложите фикс. + На пауза + Проверка… + Пауза + Възобнови + Файлове на депото + Депота · {0} от {1} + Изтеглячът на депота не можа да бъде получен. + Не са намерени ключове за дешифриране за тази игра. + Депо {0} се провали: {1} + Изтеглени са {0} депота в {1}. + Изтегли + Избери депота за изтегляне + Изтегли {0} депота ({1}) + Това депо не обявява версия за изтегляне. + Избери всички + Няма избрани депота + Избери къде да се запишат файловете на депото + Няма достатъчно място на диска: нужни са {0}, свободни са само {1}. + Запази в + Нужни {0} · свободни {1} на {2} + Влез, за да изтеглиш това депо. + Влез, за да изтегляш депота. + зарежда manifest + 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} записа от историята на изтеглянията? Изтеглените файлове не се засягат. + Тази корекция вече е приложена. Върнете я, ако искате да инсталирате друга. + Моите игри + {0} от вашите игри имат корекции. Превъртете списъка за останалите. + Все още няма добавени lua файлове. Добавете (или инсталирайте) игра, за да се появи тук. + Връщане + Отказ + Да се върне ли „{0}“? Променените файлове ще бъдат възстановени от резервното копие, а добавените от корекцията файлове ще бъдат изтрити. + Да се върне ли корекцията за {0}? + Корекцията е върната + Възстановени {0}, премахнати {1} добавени файла. + Корекцията не можа да бъде върната + Няма запис тази корекция да е била прилагана. Може вече да е върната. + Поправката не може да се върне безопасно + {0} е променен, откакто беше приложена тази поправка, така че възстановяването ще презапише тези промени. Ако сте приложили друга поправка отгоре, първо върнете нея. Засегнати са {1} файла. + Корекцията е върната частично + {0} файл(а) не можаха да бъдат възстановени. Затворете играта и опитайте отново. + Този източник няма тази игра или е недостъпен. + Източници на манифести + Допълнителни източници, описани във файлове .json. Само данни: файлът казва откъде да се вземат манифести и нищо в него не се изпълнява. + Отваряне на папката + Все още няма файлове с източници. + източници: {0} diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 957ae88..6e2d16b 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 @@ -148,16 +153,17 @@ Steam bude zavřen, aby bylo možné změnit jeho soubory, a poté se automaticky restartuje. Všechny spuštěné hry nebo stahování se zastaví. Zrušit Zavřít Steam a pokračovat - Starý dobrý SteamTools — vytvořený Číňany, milovaný celým světem. Odemykač, který odstartoval celý ten trend (GreenLuma neexistuje, pšt). ⚠️ AKTUÁLNĚ NEFUNGUJE, POUŽIJTE BETTERSTEAMTOOLS - Od základu předělaná verze SteamTools. Lepší než SteamTools prakticky ve všem. - SteamTools s několika šikovnými záplatami — spousta oprav a vylepšení. Vrací nativní Steam Cloud přes Google Drive / OneDrive! + Open source fork OpenSteamTools, který aktivně udržuje tým LuaTools. Přináší opravy a nové funkce! Spravovat Nepodařilo se stáhnout ani spustit CloudRedirect. - Noční sestavení BetterSteamTools (OST) z nejnovějšího zdroje. Nejnovější opravy, ale může být méně stabilní. + Noční sestavení OpenSteamTools z nejnovějšího zdroje. Nejnovější opravy, ale může být méně stabilní. + Teď je to na tobě + Použij vlastní řešení odemykání, my do něj sahat nebudeme + Vlastní odemykač EXPERIMENTÁLNÍ PODPORA CLOUDREDIRECT - Vrací nativní podporu Steam Cloud přes poskytovatele úložišť třetích stran, jako jsou Google Drive / OneDrive / Cloudflare R2 a další. Zatím kompatibilní pouze s BetterSteamTools Nightly. Nezapomeňte jej po zapnutí nastavit kliknutím na "Spravovat" a poté restartujte Steam! - Vyberte BetterSteamTools Nightly, abyste mohli použít CloudRedirect. + Vrací nativní podporu Steam Cloud přes poskytovatele úložišť třetích stran, jako jsou Google Drive / OneDrive / Cloudflare R2 a další. Zatím kompatibilní pouze s OpenSteamTools / BetterSteamTools. Nezapomeňte jej po zapnutí nastavit kliknutím na "Spravovat" a poté restartujte Steam! + Vyberte OpenSteamTools / BetterSteamTools, abyste mohli použít CloudRedirect. Zapnout Vypnout Zapnuto. @@ -207,7 +213,6 @@ {0} manifest(ů) {0} nainstalováno do Steamu. {0} soubor(ů) selhalo — zavřete Steam a zkuste to znovu. - Restartujte Steam, aby se změny použily. Hledat podle názvu nebo App ID... Načítání… App ID: {0} @@ -277,11 +282,11 @@ Odebrat soubor lua Odebrat „{0}“ (App ID {1})? -Tím se smaže jeho soubor .lua ze Steam\config\stplug-in. Poté restartujte Steam, aby se změna projevila. +Tím se smaže jeho soubor .lua ze Steam\config\stplug-in. Odebrat soubory lua Odebrat {0} souborů lua? -Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, aby se změny projevily. +Tím se smažou soubory .lua ze Steam\config\stplug-in. Odebrání se nezdařilo Soubor se nepodařilo smazat: {0} @@ -289,7 +294,6 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, {1} {0} souborů se nepodařilo smazat. Restartovat Steam - Restartovat Steam nyní, aby se změny projevily? Steam se nepodařilo najít ani spustit. Nastavte jeho umístění v Nastavení. Opravy Načítání oprav… @@ -306,8 +310,7 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, Instalace se nezdařila Nepodařilo se nainstalovat — zavřete Steam (nebo jej restartujte) a zkuste to znovu. Oprava nainstalována - Manifest {0} nainstalován — Steam se restartuje. - Manifest {0} nainstalován. Restartujte Steam, aby se použil. + Manifest {0} nainstalován. Hra nenalezena Nejprve nainstalujte {0} do Steamu a poté použijte opravu. Oprava použita částečně @@ -347,13 +350,12 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, 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. Nepodařilo se nainstalovat {0} soubor(ů) — zavřete Steam (nebo použijte Restartovat Steam) a zkuste to znovu. - {0} přidáno — lua + {1} manifest(ů). Restartujte Steam, aby se použil. - {0} přidáno — Steam načte manifesty. Restartujte Steam, aby se použil. + Přidáno {0}: lua + {1} manifest(ů). + Přidáno {0}. Steam stáhne manifesty. Otevřít na SteamDB SDÍLENÝ Tím se nahradí nainstalovaný lua. Zkontrolujte, co se mění: @@ -450,9 +452,9 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, Smazat tuto předvolbu? „{0}“ bude odebrána z uložených předvoleb. Na samotnou hru to nemá vliv. Předvolbu, která se právě používá, nelze smazat. - Nyní se používá „{0}“. Restartujte Steam, aby se změna projevila. + Nyní se používá „{0}“. Předvolbu se nepodařilo přepnout — soubor lua může být používán. - Uloženo. Restartujte Steam, aby se změna projevila. + Uloženo. Nepodařilo se uložit — soubor lua může být používán. Uloženo jako předvolba. Uložit do „{0}“ @@ -498,4 +500,134 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, Možnosti spuštění byly resetovány Steam přepsal vaše možnosti spuštění u {0} her. Opětovné použití zavře a znovu otevře Steam. Použít znovu + Umístění Steamu nenalezeno. Nastav ho v Nastavení. + Nepodařilo se spojit s GitHubem. Zkontroluj připojení a zkus to znovu. + Nepodařilo se spojit s aktualizačním serverem. Zkontroluj připojení a zkus to znovu. + Zrušeno. + Zrušeno uživatelem + V releasu chybí očekávaný soubor ke stažení. + V releasu chybí {0}. + Stažený soubor neprošel ověřením (nesouhlasí sha256). + {0} neprošel ověřením (nesouhlasí sha256). + Ve stažených souborech chybí: {0}. + Nepodařilo se zapsat {0} soubor(ů). Zavři Steam a zkus to znovu. + Nepodařilo se zapsat {0}. Zavři Steam a zkus to znovu. + Nepodařilo se otevřít stažený soubor: {0} + Pro tuto hru není dostupný žádný zdroj. + Vypršel časový limit přihlášení. Prohlížeč neodpověděl. + Výměna tokenu selhala ({0}): {1} + Výměna tokenu vrátila prázdnou relaci. + Nejsi přihlášen. + Obnovení relace selhalo ({0}). + Obnovení relace vrátilo prázdnou relaci. + Odkaz ke stažení byl prázdný. Zkus to znovu. + Stahování selhalo ({0}). + Požadavek selhal ({0}) + Relace vypršela. Přihlas se znovu. + Tvůj klíč Hubcap je neplatný nebo vypršel. + Dosáhl jsi denního limitu Hubcap. + Pro tuto aplikaci není dostupný žádný manifest Hubcap. + Stahování z Hubcap selhalo ({0}). + Stahování + Stahování + Nic se právě nestahuje. Tak si něco stáhni! + Fronta + Historie + Ve frontě + Stahuje se + Čeká na tebe + Instaluje se + Hotovo + Selhalo + Zrušeno + Zrušit + Zkusit znovu + Odebrat + Nahoru + Dolů + Vymazat historii + Zkontrolovat + {0} z {1} + Zbývá {0} + Vyžaduje akci + Přerušeno při zavření aplikace. + Odemknutí DLC + Nejdřív nainstaluj hru, abys mohl použít fix. + Pozastaveno + Ověřování… + Pozastavit + Pokračovat + Soubory depotu + Depoty · {0} z {1} + Nepodařilo se získat stahovač depotů. + Pro tuto hru nebyly nalezeny žádné dešifrovací klíče. + Depot {0} selhal: {1} + Staženo {0} depotů do {1}. + Stáhnout + Vyber depoty ke stažení + Stáhnout {0} depotů ({1}) + Tento depot neuvádí žádnou verzi ke stažení. + Vybrat vše + Nevybrány žádné depoty + Vyber, kam uložit soubory depotu + Nedostatek místa na disku: potřeba {0}, volných jen {1}. + Uložit do + Potřeba {0} · volných {1} na {2} + Přihlas se pro stažení tohoto depotu. + 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 + 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. + 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. + 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. + 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 1290d82..6327903 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 @@ -148,16 +153,17 @@ Steam lukkes, så dets filer kan ændres, og genstartes derefter automatisk. Alle kørende spil eller downloads stopper. Annuller Luk Steam og fortsæt - Den gode gamle SteamTools — bygget af kineserne, elsket af verden. Oplåseren, der startede hele trenden (GreenLuma findes ikke, sshh). ⚠️ VIRKER IKKE I ØJEBLIKKET, BRUG BETTERSTEAMTOOLS - Genopbygning af SteamTools fra bunden. Bedre end SteamTools på stort set alle måder. - SteamTools med et par smarte patches — masser af rettelser og forbedringer. Bringer native Steam Cloud tilbage via Google Drive / OneDrive! + En open source-fork af OpenSteamTools, som vedligeholdes aktivt af LuaTools-teamet. Med rettelser og nye funktioner! Administrer Kunne ikke downloade eller starte CloudRedirect. - Natlig build af BetterSteamTools (OST) fra den nyeste kildekode. Nyeste rettelser, men kan være mindre stabil. + Natlig build af OpenSteamTools fra den nyeste kildekode. Nyeste rettelser, men kan være mindre stabil. + Nu er du på egen hånd + Brug din egen unlock-løsning, vi rører den ikke + Egen unlocker EKSPERIMENTEL CLOUDREDIRECT-UNDERSTØTTELSE - Bringer indbygget Steam Cloud-understøttelse tilbage via tredjeparts lagringsudbydere som Google Drive / OneDrive / Cloudflare R2 med flere. Kun kompatibel med BetterSteamTools Nightly i øjeblikket. Husk at konfigurere det ved at klikke på "Administrer" efter aktivering, og genstart derefter Steam! - Vælg BetterSteamTools Nightly for at bruge CloudRedirect. + Bringer indbygget Steam Cloud-understøttelse tilbage via tredjeparts lagringsudbydere som Google Drive / OneDrive / Cloudflare R2 med flere. Kun kompatibel med OpenSteamTools / BetterSteamTools i øjeblikket. Husk at konfigurere det ved at klikke på "Administrer" efter aktivering, og genstart derefter Steam! + Vælg OpenSteamTools / BetterSteamTools for at bruge CloudRedirect. Aktivér Deaktivér Aktiveret. @@ -207,7 +213,6 @@ {0} manifest(er) {0} installeret i Steam. {0} fil(er) mislykkedes — luk Steam og prøv igen. - Genstart Steam for at anvende. Søg efter navn eller App ID... Indlæser… App ID: {0} @@ -275,13 +280,13 @@ Delt depot DLC {0} Fjern lua-fil - Fjern "{0}" (App ID {1})? + Fjern “{0}” (App ID {1})? -Dette sletter dens .lua-fil fra Steam\config\stplug-in. Genstart Steam bagefter, for at ændringen træder i kraft. +Dette sletter dens .lua-fil fra Steam\config\stplug-in. Fjern lua-filer Fjern {0} lua-filer? -Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, for at ændringerne træder i kraft. +Dette sletter .lua-filerne fra Steam\config\stplug-in. Fjernelse mislykkedes Filen kunne ikke slettes: {0} @@ -289,7 +294,6 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, {1} {0} filer kunne ikke slettes. Genstart Steam - Genstart Steam nu, så ændringerne træder i kraft? Steam kunne ikke findes eller startes. Angiv dets placering i Indstillinger. Rettelser Indlæser rettelser… @@ -306,8 +310,7 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, Installation mislykkedes Kunne ikke installere — luk Steam (eller genstart Steam) og prøv igen. Rettelse installeret - Manifest for {0} installeret — Steam genstarter. - Manifest for {0} installeret. Genstart Steam for at anvende. + Manifest til {0} installeret. Spil ikke fundet Installer først {0} i Steam, og anvend derefter rettelsen. Rettelse delvist anvendt @@ -347,13 +350,12 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, 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. Kunne ikke installere {0} fil(er) — luk Steam (eller brug Genstart Steam) og prøv igen. - {0} tilføjet — lua + {1} manifest(er). Genstart Steam for at anvende. - {0} tilføjet — Steam henter manifesterne. Genstart Steam for at anvende. + Tilføjede {0}: lua + {1} manifest(er). + Tilføjede {0}. Steam henter manifester. Åbn på SteamDB DELT Dette erstatter den installerede lua. Gennemgå, hvad der ændres: @@ -450,9 +452,9 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, Slet denne forudindstilling? “{0}” fjernes fra dine gemte forudindstillinger. Det påvirker ikke selve spillet. Den forudindstilling, der er i brug, kan ikke slettes. - Bruger nu “{0}”. Genstart Steam, for at det træder i kraft. + Bruger nu “{0}”. Kunne ikke skifte forudindstilling — lua-filen er muligvis i brug. - Gemt. Genstart Steam, for at det træder i kraft. + Gemt. Kunne ikke gemme — lua-filen er muligvis i brug. Gemt som forudindstilling. Gem i “{0}” @@ -498,4 +500,134 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, Startindstillingerne blev nulstillet Steam har overskrevet dine startindstillinger for {0} spil. Når de anvendes igen, lukkes og genåbnes Steam. Anvend igen + Steam-placeringen blev ikke fundet. Angiv den i Indstillinger. + Kunne ikke nå GitHub. Tjek din forbindelse, og prøv igen. + Kunne ikke nå opdateringsserveren. Tjek din forbindelse, og prøv igen. + Annulleret. + Annulleret af brugeren + Den forventede download mangler i udgivelsen. + {0} mangler i udgivelsen. + Downloaden bestod ikke verificeringen (sha256 stemmer ikke). + {0} bestod ikke verificeringen (sha256 stemmer ikke). + Mangler i downloaden: {0}. + Kunne ikke skrive {0} fil(er). Luk Steam, og prøv igen. + Kunne ikke skrive {0}. Luk Steam, og prøv igen. + Kunne ikke åbne downloaden: {0} + Ingen tilgængelig kilde til dette spil. + Login-timeout. Intet svar fra browseren. + Tokenudveksling mislykkedes ({0}): {1} + Tokenudvekslingen returnerede en tom session. + Ikke logget ind. + Fornyelse af sessionen mislykkedes ({0}). + Fornyelsen af sessionen returnerede en tom session. + Downloadlinket var tomt. Prøv igen. + Download mislykkedes ({0}). + Anmodningen mislykkedes ({0}) + Sessionen er udløbet. Log ind igen. + Din Hubcap-nøgle er ugyldig eller udløbet. + Du har nået din daglige Hubcap-grænse. + Der er ingen Hubcap-manifest til denne app. + Hubcap-download mislykkedes ({0}). + Downloads + Downloads + Ingen downloads lige nu. Kom i gang – hent noget! + Kø + Historik + I kø + Downloader + Venter på dig + Installerer + Færdig + Mislykkedes + Annulleret + Annuller + Prøv igen + Fjern + Flyt op + Flyt ned + Ryd historik + Gennemse + {0} af {1} + {0} tilbage + Handling kræves + Afbrudt da appen blev lukket. + DLC-oplåsning + Installér spillet først for at anvende et fix. + Sat på pause + Verificerer… + Pause + Genoptag + Depotfiler + Depoter · {0} af {1} + Kunne ikke hente depot-downloaderen. + Ingen dekrypteringsnøgler fundet til dette spil. + Depot {0} fejlede: {1} + Hentede {0} depoter til {1}. + Hent + Vælg depoter, der skal hentes + Hent {0} depoter ({1}) + Dette depot angiver ingen version at hente. + Vælg alle + Ingen depoter valgt + Vælg, hvor depotfilerne skal gemmes + Ikke nok diskplads: kræver {0}, kun {1} ledig. + Gem i + Kræver {0} · {1} ledig på {2} + Log ind for at hente dette depot. + Log ind for at hente depoter. + henter manifest + 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. + 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. + 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. + 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 0da746d..8e61146 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 @@ -148,16 +153,17 @@ Steam wird geschlossen, damit seine Dateien geändert werden können, und anschließend automatisch neu gestartet. Alle laufenden Spiele oder Downloads werden gestoppt. Abbrechen Steam schließen & fortfahren - Das gute alte SteamTools — von Chinesen gebaut, von der Welt geliebt. Der Unlocker, der den ganzen Trend gestartet hat (GreenLuma existiert nicht, pssst). ⚠️ FUNKTIONIERT DERZEIT NICHT, NUTZE BETTERSTEAMTOOLS - Von Grund auf neu entwickelte Neuauflage von SteamTools. In praktisch jeder Hinsicht besser als SteamTools. - SteamTools mit ein paar raffinierten Patches — jede Menge Fixes und Verbesserungen. Bringt natives Steam Cloud über Google Drive / OneDrive zurück! + Ein Open-Source-Fork von OpenSteamTools, aktiv gepflegt vom LuaTools-Team. Mit Fixes und neuen Funktionen! Verwalten CloudRedirect konnte nicht heruntergeladen oder gestartet werden. - Nightly-Build von BetterSteamTools (OST) aus dem neuesten Quellcode. Neueste Fixes, aber möglicherweise weniger stabil. + Nightly-Build von OpenSteamTools aus dem neuesten Quellcode. Neueste Fixes, aber möglicherweise weniger stabil. + Du bist auf dich gestellt + Nutze deine eigene Unlock-Lösung, wir fassen sie nicht an + Eigener Unlocker EXPERIMENTELL CLOUDREDIRECT-UNTERSTÜTZUNG - Bringt native Steam-Cloud-Unterstützung über Drittanbieter-Speicher wie Google Drive / OneDrive / Cloudflare R2 und mehr zurück. Derzeit nur mit BetterSteamTools Nightly kompatibel. Vergiss nicht, es nach dem Aktivieren über "Verwalten" zu konfigurieren, und starte dann Steam neu! - Wähle BetterSteamTools Nightly, um CloudRedirect zu nutzen. + Bringt native Steam-Cloud-Unterstützung über Drittanbieter-Speicher wie Google Drive / OneDrive / Cloudflare R2 und mehr zurück. Derzeit nur mit OpenSteamTools / BetterSteamTools kompatibel. Vergiss nicht, es nach dem Aktivieren über "Verwalten" zu konfigurieren, und starte dann Steam neu! + Wähle OpenSteamTools / BetterSteamTools, um CloudRedirect zu nutzen. Aktivieren Deaktivieren Aktiviert. @@ -207,7 +213,6 @@ {0} Manifest(e) {0} in Steam installiert. {0} Datei(en) fehlgeschlagen — schließe Steam und versuche es erneut. - Starte Steam neu, um die Änderungen zu übernehmen. Nach Name oder App-ID suchen... Wird geladen… App-ID: {0} @@ -275,13 +280,13 @@ Geteiltes Depot DLC {0} Lua-Datei entfernen - "{0}" (App-ID {1}) entfernen? + „{0}“ (App ID {1}) entfernen? -Dadurch wird die .lua-Datei aus Steam\config\stplug-in gelöscht. Starte Steam anschließend neu, damit die Änderung wirksam wird. +Dies löscht die .lua-Datei aus Steam\config\stplug-in. Lua-Dateien entfernen - {0} Lua-Dateien entfernen? + {0} lua-Dateien entfernen? -Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Steam anschließend neu, damit die Änderungen wirksam werden. +Dies löscht die .lua-Dateien aus Steam\config\stplug-in. Entfernen fehlgeschlagen Die Datei konnte nicht gelöscht werden: {0} @@ -289,7 +294,6 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste {1} {0} Dateien konnten nicht gelöscht werden. Steam neu starten - Steam jetzt neu starten, damit die Änderungen wirksam werden? Steam konnte nicht gefunden oder gestartet werden. Lege den Speicherort in den Einstellungen fest. Fixes Fixes werden geladen… @@ -306,8 +310,7 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste Installation fehlgeschlagen Installation fehlgeschlagen — schließe Steam (oder starte Steam neu) und versuche es erneut. Fix installiert - Manifest für {0} installiert — Steam wird neu gestartet. - Manifest für {0} installiert. Starte Steam neu, um es zu übernehmen. + Manifest für {0} installiert. Spiel nicht gefunden Installiere {0} zuerst in Steam und wende dann den Fix an. Fix teilweise angewendet @@ -347,13 +350,12 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste 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. {0} Datei(en) konnten nicht installiert werden — schließe Steam (oder nutze „Steam neu starten“) und versuche es erneut. - {0} hinzugefügt — Lua + {1} Manifest(e). Starte Steam neu, um es zu übernehmen. - {0} hinzugefügt — Steam ruft die Manifeste ab. Starte Steam neu, um es zu übernehmen. + {0} hinzugefügt: lua + {1} Manifest(e). + {0} hinzugefügt. Steam lädt die Manifeste. Auf SteamDB öffnen GETEILT Dies ersetzt das installierte Lua. Überprüfe, was sich ändert: @@ -450,9 +452,9 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste Diese Vorlage löschen? „{0}“ wird aus deinen gespeicherten Vorlagen entfernt. Das Spiel selbst bleibt davon unberührt. Die gerade verwendete Vorlage kann nicht gelöscht werden. - „{0}“ wird jetzt verwendet. Starte Steam neu, damit es wirksam wird. + „{0}“ wird jetzt verwendet. Vorlage konnte nicht gewechselt werden — die lua-Datei ist möglicherweise in Benutzung. - Gespeichert. Starte Steam neu, damit es wirksam wird. + Gespeichert. Speichern fehlgeschlagen — die lua-Datei ist möglicherweise in Benutzung. Als Vorlage gespeichert. In „{0}“ speichern @@ -498,4 +500,134 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste Startoptionen wurden zurückgesetzt Steam hat deine Startoptionen für {0} Spiel(e) überschrieben. Beim erneuten Anwenden wird Steam geschlossen und wieder geöffnet. Erneut anwenden + Steam-Speicherort nicht gefunden. Lege ihn in den Einstellungen fest. + GitHub war nicht erreichbar. Prüfe deine Verbindung und versuche es erneut. + Der Update-Server war nicht erreichbar. Prüfe deine Verbindung und versuche es erneut. + Abgebrochen. + Vom Benutzer abgebrochen + Im Release fehlt der erwartete Download. + Im Release fehlt {0}. + Der Download hat die Überprüfung nicht bestanden (sha256 stimmt nicht). + {0} hat die Überprüfung nicht bestanden (sha256 stimmt nicht). + Im Download fehlt: {0}. + {0} Datei(en) konnten nicht geschrieben werden. Schließe Steam und versuche es erneut. + {0} konnte nicht geschrieben werden. Schließe Steam und versuche es erneut. + Der Download konnte nicht geöffnet werden: {0} + Für dieses Spiel ist keine Quelle verfügbar. + Zeitüberschreitung bei der Anmeldung. Keine Antwort vom Browser. + Token-Austausch fehlgeschlagen ({0}): {1} + Der Token-Austausch hat eine leere Sitzung zurückgegeben. + Nicht angemeldet. + Sitzungsaktualisierung fehlgeschlagen ({0}). + Die Sitzungsaktualisierung hat eine leere Sitzung zurückgegeben. + Der Download-Link war leer. Versuche es erneut. + Download fehlgeschlagen ({0}). + Anfrage fehlgeschlagen ({0}) + Sitzung abgelaufen. Bitte melde dich erneut an. + Dein Hubcap-Schlüssel ist ungültig oder abgelaufen. + Dein tägliches Hubcap-Limit ist erreicht. + Für diese App ist kein Hubcap-Manifest verfügbar. + Hubcap-Download fehlgeschlagen ({0}). + Downloads + Downloads + Derzeit keine Downloads. Lad dir was runter! + Warteschlange + Verlauf + In Warteschlange + Wird geladen + Wartet auf dich + Wird installiert + Fertig + Fehlgeschlagen + Abgebrochen + Abbrechen + Wiederholen + Entfernen + Nach oben + Nach unten + Verlauf löschen + Ansehen + {0} von {1} + Noch {0} + Aktion erforderlich + Beim Schließen der App unterbrochen. + DLC-Freischaltung + Installiere zuerst das Spiel, um einen Fix anzuwenden. + Pausiert + Wird geprüft… + Pause + Fortsetzen + Depot-Dateien + Depots · {0} von {1} + Der Depot-Downloader konnte nicht geladen werden. + Keine Entschlüsselungsschlüssel für dieses Spiel gefunden. + Depot {0} fehlgeschlagen: {1} + {0} Depots nach {1} heruntergeladen. + Herunterladen + Depots zum Herunterladen auswählen + {0} Depots herunterladen ({1}) + Dieses Depot gibt keine herunterladbare Version an. + Alle auswählen + Keine Depots ausgewählt + Speicherort für die Depot-Dateien wählen + Nicht genug Speicherplatz: benötigt {0}, nur {1} frei. + Speichern in + Benötigt {0} · {1} frei auf {2} + Melde dich an, um dieses Depot zu laden. + Zum Laden von Depots anmelden. + Manifest wird geholt + 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. + 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 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. + 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 691d2ae..e3231a2 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 Αλλαγή… Άνοιγμα @@ -148,16 +153,17 @@ Το Steam θα κλείσει ώστε να μπορούν να αλλάξουν τα αρχεία του, και έπειτα θα επανεκκινηθεί αυτόματα. Όλα τα παιχνίδια ή οι λήψεις που εκτελούνται θα σταματήσουν. Ακύρωση Κλείσιμο Steam και συνέχεια - Το παλιό καλό SteamTools — φτιαγμένο από τους Κινέζους, αγαπημένο σε όλον τον κόσμο. Ο unlocker που ξεκίνησε όλη την τάση (το GreenLuma δεν υπάρχει, σσς). ⚠️ ΔΕΝ ΛΕΙΤΟΥΡΓΕΙ ΑΥΤΗ ΤΗ ΣΤΙΓΜΗ, ΧΡΗΣΙΜΟΠΟΙΗΣΤΕ ΤΟ BETTERSTEAMTOOLS - Πλήρης ανακατασκευή του SteamTools από την αρχή. Καλύτερο από το SteamTools σχεδόν σε κάθε τομέα. - SteamTools με μερικά έξυπνα patch — ένα σωρό διορθώσεις και βελτιώσεις. Επαναφέρει το εγγενές Steam Cloud μέσω Google Drive / OneDrive! + Ένα fork ανοιχτού κώδικα του OpenSteamTools που συντηρείται ενεργά από την ομάδα του LuaTools. Με διορθώσεις και νέες λειτουργίες! Διαχείριση Δεν ήταν δυνατή η λήψη ή η εκκίνηση του CloudRedirect. - Νυχτερινή έκδοση του BetterSteamTools (OST) από τον πιο πρόσφατο κώδικα. Οι νεότερες διορθώσεις, αλλά ίσως λιγότερο σταθερή. + Νυχτερινή έκδοση του OpenSteamTools από τον πιο πρόσφατο κώδικα. Οι νεότερες διορθώσεις, αλλά ίσως λιγότερο σταθερή. + Τα υπόλοιπα δικά σου + Χρησιμοποίησε τη δική σου λύση ξεκλειδώματος, δεν θα την πειράξουμε + Προσαρμοσμένο unlocker ΠΕΙΡΑΜΑΤΙΚΟ ΥΠΟΣΤΗΡΙΞΗ CLOUDREDIRECT - Επαναφέρει την εγγενή υποστήριξη Steam Cloud μέσω παρόχων αποθήκευσης τρίτων, όπως Google Drive / OneDrive / Cloudflare R2 και άλλων. Προς το παρόν συμβατό μόνο με το BetterSteamTools Nightly. Μην ξεχάσετε να το ρυθμίσετε κάνοντας κλικ στο "Διαχείριση" μετά την ενεργοποίηση και έπειτα επανεκκινήστε το Steam! - Επιλέξτε BetterSteamTools Nightly για να χρησιμοποιήσετε το CloudRedirect. + Επαναφέρει την εγγενή υποστήριξη Steam Cloud μέσω παρόχων αποθήκευσης τρίτων, όπως Google Drive / OneDrive / Cloudflare R2 και άλλων. Προς το παρόν συμβατό μόνο με το OpenSteamTools / BetterSteamTools. Μην ξεχάσετε να το ρυθμίσετε κάνοντας κλικ στο "Διαχείριση" μετά την ενεργοποίηση και έπειτα επανεκκινήστε το Steam! + Επιλέξτε OpenSteamTools / BetterSteamTools για να χρησιμοποιήσετε το CloudRedirect. Ενεργοποίηση Απενεργοποίηση Ενεργοποιημένο. @@ -207,7 +213,6 @@ {0} manifest Το {0} εγκαταστάθηκε στο Steam. {0} αρχείο(α) απέτυχαν — κλείστε το Steam και δοκιμάστε ξανά. - Επανεκκινήστε το Steam για εφαρμογή. Αναζήτηση με όνομα ή App ID... Φόρτωση… App ID: {0} @@ -277,11 +282,11 @@ Κατάργηση αρχείου lua Κατάργηση του «{0}» (App ID {1}); -Αυτό διαγράφει το αρχείο .lua του από το Steam\config\stplug-in. Επανεκκινήστε το Steam έπειτα για να τεθεί σε ισχύ η αλλαγή. +Αυτό διαγράφει το αρχείο .lua από το Steam\config\stplug-in. Κατάργηση αρχείων lua Κατάργηση {0} αρχείων lua; -Αυτό διαγράφει τα αρχεία .lua από το Steam\config\stplug-in. Επανεκκινήστε το Steam έπειτα για να τεθούν σε ισχύ οι αλλαγές. +Αυτό διαγράφει τα αρχεία .lua από το Steam\config\stplug-in. Η κατάργηση απέτυχε Δεν ήταν δυνατή η διαγραφή του αρχείου: {0} @@ -289,7 +294,6 @@ {1} Δεν ήταν δυνατή η διαγραφή {0} αρχείων. Επανεκκίνηση Steam - Επανεκκίνηση του Steam τώρα ώστε να τεθούν σε ισχύ οι αλλαγές; Δεν ήταν δυνατή η εύρεση ή εκκίνηση του Steam. Ορίστε την τοποθεσία του στις Ρυθμίσεις. Διορθώσεις Φόρτωση διορθώσεων… @@ -306,8 +310,7 @@ Η εγκατάσταση απέτυχε Δεν ήταν δυνατή η εγκατάσταση — κλείστε το Steam (ή επανεκκινήστε το) και δοκιμάστε ξανά. Η διόρθωση εγκαταστάθηκε - Το manifest του {0} εγκαταστάθηκε — το Steam επανεκκινείται. - Το manifest του {0} εγκαταστάθηκε. Επανεκκινήστε το Steam για εφαρμογή. + Το μανιφέστο του {0} εγκαταστάθηκε. Το παιχνίδι δεν βρέθηκε Εγκαταστήστε πρώτα το {0} στο Steam και έπειτα εφαρμόστε τη διόρθωση. Η διόρθωση εφαρμόστηκε εν μέρει @@ -347,13 +350,12 @@ Δεν ήταν δυνατός ο προσδιορισμός του βασικού παιχνιδιού για αυτό το DLC Κάτι πήγε στραβά — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Η λήψη απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. - Η δημιουργία απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Αντικατάσταση του «{0}»; Καμία αλλαγή depot/DLC — ίδιο περιεχόμενο. Η εγκατάσταση ακυρώθηκε — τα υπάρχοντα αρχεία παρέμειναν αμετάβλητα. Δεν ήταν δυνατή η εγκατάσταση {0} αρχείου(ων) — κλείστε το Steam (ή χρησιμοποιήστε την Επανεκκίνηση Steam) και δοκιμάστε ξανά. - Προστέθηκε το {0} — lua + {1} manifest. Επανεκκινήστε το Steam για εφαρμογή. - Προστέθηκε το {0} — το Steam θα ανακτήσει τα manifest. Επανεκκινήστε το Steam για εφαρμογή. + Προστέθηκε {0}: lua + {1} μανιφέστα. + Προστέθηκε {0}. Το Steam θα λάβει τα μανιφέστα. Άνοιγμα στο SteamDB ΚΟΙΝΟΧΡΗΣΤΟ Αυτό αντικαθιστά το εγκατεστημένο lua. Ελέγξτε τι αλλάζει: @@ -450,9 +452,9 @@ Διαγραφή αυτής της προρύθμισης; Η «{0}» θα αφαιρεθεί από τις αποθηκευμένες προρυθμίσεις σας. Αυτό δεν επηρεάζει το ίδιο το παιχνίδι. Δεν μπορείτε να διαγράψετε την προρύθμιση που χρησιμοποιείται. - Χρησιμοποιείται πλέον η «{0}». Επανεκκινήστε το Steam για να τεθεί σε ισχύ. + Χρησιμοποιείται τώρα «{0}». Δεν ήταν δυνατή η αλλαγή προρύθμισης — το αρχείο lua μπορεί να χρησιμοποιείται. - Αποθηκεύτηκε. Επανεκκινήστε το Steam για να τεθεί σε ισχύ. + Αποθηκεύτηκε. Δεν ήταν δυνατή η αποθήκευση — το αρχείο lua μπορεί να χρησιμοποιείται. Αποθηκεύτηκε ως προρύθμιση. Αποθήκευση στην «{0}» @@ -498,4 +500,134 @@ Οι επιλογές εκκίνησης επαναφέρθηκαν Το Steam αντικατέστησε τις επιλογές εκκίνησης για {0} παιχνίδι(α). Η εκ νέου εφαρμογή κλείνει και ανοίγει ξανά το Steam. Εφαρμογή ξανά + Δεν βρέθηκε η τοποθεσία του Steam. Όρισέ την στις Ρυθμίσεις. + Δεν ήταν δυνατή η σύνδεση με το GitHub. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά. + Δεν ήταν δυνατή η σύνδεση με τον διακομιστή ενημερώσεων. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά. + Ακυρώθηκε. + Ακυρώθηκε από τον χρήστη + Λείπει από την έκδοση το αναμενόμενο αρχείο λήψης. + Λείπει από την έκδοση: {0}. + Η λήψη απέτυχε στην επαλήθευση (ασυμφωνία sha256). + Το {0} απέτυχε στην επαλήθευση (ασυμφωνία sha256). + Λείπει από τη λήψη: {0}. + Δεν ήταν δυνατή η εγγραφή {0} αρχείων. Κλείσε το Steam και δοκίμασε ξανά. + Δεν ήταν δυνατή η εγγραφή του {0}. Κλείσε το Steam και δοκίμασε ξανά. + Δεν ήταν δυνατό το άνοιγμα της λήψης: {0} + Δεν υπάρχει διαθέσιμη πηγή για αυτό το παιχνίδι. + Έληξε ο χρόνος σύνδεσης. Καμία απόκριση από το πρόγραμμα περιήγησης. + Η ανταλλαγή διακριτικού απέτυχε ({0}): {1} + Η ανταλλαγή διακριτικού επέστρεψε κενή συνεδρία. + Δεν έχεις συνδεθεί. + Η ανανέωση της συνεδρίας απέτυχε ({0}). + Η ανανέωση της συνεδρίας επέστρεψε κενή συνεδρία. + Ο σύνδεσμος λήψης ήταν κενός. Δοκίμασε ξανά. + Η λήψη απέτυχε ({0}). + Το αίτημα απέτυχε ({0}) + Η συνεδρία έληξε. Συνδέσου ξανά. + Το κλειδί σου για το Hubcap δεν είναι έγκυρο ή έχει λήξει. + Έφτασες το ημερήσιο όριο του Hubcap. + Δεν υπάρχει διαθέσιμο manifest Hubcap για αυτήν την εφαρμογή. + Η λήψη από το Hubcap απέτυχε ({0}). + Λήψεις + Λήψεις + Καμία λήψη αυτή τη στιγμή. Έλα, κατέβασε κάτι! + Ουρά + Ιστορικό + Σε ουρά + Γίνεται λήψη + Περιμένει εσένα + Γίνεται εγκατάσταση + Ολοκληρώθηκε + Απέτυχε + Ακυρώθηκε + Ακύρωση + Επανάληψη + Αφαίρεση + Μετακίνηση πάνω + Μετακίνηση κάτω + Εκκαθάριση ιστορικού + Έλεγχος + {0} από {1} + Απομένουν {0} + Απαιτείται ενέργεια + Διακόπηκε όταν έκλεισε η εφαρμογή. + Ξεκλείδωμα DLC + Εγκαταστήστε πρώτα το παιχνίδι για να εφαρμόσετε ένα fix. + Σε παύση + Έλεγχος… + Παύση + Συνέχιση + Αρχεία depot + Depot · {0} από {1} + Δεν ήταν δυνατή η λήψη του depot downloader. + Δεν βρέθηκαν κλειδιά αποκρυπτογράφησης για αυτό το παιχνίδι. + Το depot {0} απέτυχε: {1} + Έγινε λήψη {0} depot στο {1}. + Λήψη + Επίλεξε depot για λήψη + Λήψη {0} depot ({1}) + Αυτό το depot δεν δηλώνει έκδοση για λήψη. + Επιλογή όλων + Δεν επιλέχθηκαν depot + Επίλεξε πού θα αποθηκευτούν τα αρχεία depot + Δεν υπάρχει αρκετός χώρος στον δίσκο: χρειάζονται {0}, ελεύθερα μόνο {1}. + Αποθήκευση σε + Χρειάζεται {0} · {1} ελεύθερα σε {2} + Συνδέσου για να κατεβάσεις αυτό το depot. + Συνδέσου για λήψη depot. + λήψη manifest + Δεν ήταν δυνατή η λήψη του 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} καταχωρίσεις από το ιστορικό λήψεων; Τα ληφθέντα αρχεία δεν επηρεάζονται. + Αυτή η διόρθωση έχει ήδη εφαρμοστεί. Αναιρέστε την αν θέλετε να εγκαταστήσετε άλλη. + Τα παιχνίδια μου + {0} από τα παιχνίδια σας έχουν διορθώσεις. Κυλήστε τη λίστα για τα υπόλοιπα. + Δεν έχετε προσθέσει ακόμη lua. Προσθέστε (ή εγκαταστήστε) ένα παιχνίδι για να εμφανιστεί εδώ. + Αναίρεση + Άκυρο + Αναίρεση του «{0}»; Τα τροποποιημένα αρχεία θα επαναφερθούν από το αντίγραφο ασφαλείας και τα αρχεία που πρόσθεσε η διόρθωση θα διαγραφούν. + Αναίρεση της διόρθωσης για {0}; + Η διόρθωση αναιρέθηκε + Επαναφέρθηκαν {0}, αφαιρέθηκαν {1} πρόσθετα αρχεία. + Δεν ήταν δυνατή η αναίρεση της διόρθωσης + Δεν υπάρχει καταγραφή ότι εφαρμόστηκε αυτή η διόρθωση. Ίσως έχει ήδη αναιρεθεί. + Δεν είναι δυνατή η ασφαλής επαναφορά της διόρθωσης + Το {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 f69f2cc..fc130aa 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 @@ -148,16 +153,17 @@ Steam se cerrará para poder modificar sus archivos y luego se reiniciará automáticamente. Cualquier juego o descarga en curso se detendrá. Cancelar Cerrar Steam y continuar - El viejo y querido SteamTools: hecho por los chinos, amado por el mundo. El desbloqueador que inició toda la tendencia (GreenLuma no existe, shh). ⚠️ ACTUALMENTE NO FUNCIONA, USA BETTERSTEAMTOOLS - Remake desde cero de SteamTools. Mejor que SteamTools en prácticamente todo. - SteamTools con algunos parches ingeniosos: un montón de correcciones y mejoras. ¡Recupera el Steam Cloud nativo mediante Google Drive / OneDrive! + Un fork de código abierto de OpenSteamTools mantenido activamente por el equipo de LuaTools. ¡Con correcciones y funciones nuevas! Gestionar No se pudo descargar ni iniciar CloudRedirect. - Compilación nocturna de BetterSteamTools (OST) desde el código más reciente. Las correcciones más recientes, pero puede ser menos estable. + Compilación nocturna de OpenSteamTools desde el código más reciente. Las correcciones más recientes, pero puede ser menos estable. + Corre por tu cuenta + Usa tu propia solución de desbloqueo, no la vamos a tocar + Desbloqueador personalizado EXPERIMENTAL COMPATIBLE CON CLOUDREDIRECT - Recupera la compatibilidad nativa con Steam Cloud mediante proveedores de almacenamiento externos como Google Drive / OneDrive / Cloudflare R2 y más. Por ahora solo es compatible con BetterSteamTools Nightly. No olvides configurarlo haciendo clic en "Gestionar" después de activarlo y luego reinicia Steam. - Selecciona BetterSteamTools Nightly para usar CloudRedirect. + Recupera la compatibilidad nativa con Steam Cloud mediante proveedores de almacenamiento externos como Google Drive / OneDrive / Cloudflare R2 y más. Por ahora solo es compatible con OpenSteamTools / BetterSteamTools. No olvides configurarlo haciendo clic en "Gestionar" después de activarlo y luego reinicia Steam. + Selecciona OpenSteamTools / BetterSteamTools para usar CloudRedirect. Activar Desactivar Activado. @@ -207,7 +213,6 @@ {0} manifiesto(s) {0} instalado(s) en Steam. {0} archivo(s) fallaron: cierra Steam y reinténtalo. - Reinicia Steam para aplicar. Buscar por nombre o App ID... Cargando… App ID: {0} @@ -275,13 +280,13 @@ Depósito compartido DLC {0} Eliminar archivo lua - ¿Eliminar «{0}» (App ID {1})? + ¿Quitar «{0}» (App ID {1})? -Esto borra su archivo .lua de Steam\config\stplug-in. Reinicia Steam después para que el cambio surta efecto. +Esto elimina su archivo .lua de Steam\config\stplug-in. Eliminar archivos lua - ¿Eliminar {0} archivos lua? + ¿Quitar {0} archivos lua? -Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después para que los cambios surtan efecto. +Esto elimina los archivos .lua de Steam\config\stplug-in. Error al eliminar No se pudo eliminar el archivo: {0} @@ -289,7 +294,6 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después {1} No se pudieron eliminar {0} archivos. Reiniciar Steam - ¿Reiniciar Steam ahora para que los cambios surtan efecto? No se pudo encontrar ni iniciar Steam. Define su ubicación en Configuración. Parches Cargando parches… @@ -306,8 +310,7 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después Error de instalación No se pudo instalar: cierra Steam (o reinícialo) e inténtalo de nuevo. Parche instalado - Manifiesto de {0} instalado: Steam se está reiniciando. - Manifiesto de {0} instalado. Reinicia Steam para aplicar. + Manifiesto de {0} instalado. Juego no encontrado Instala primero {0} en Steam y luego aplica el parche. Parche aplicado parcialmente @@ -347,13 +350,12 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después 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. No se pudieron instalar {0} archivo(s): cierra Steam (o usa Reiniciar Steam) e inténtalo de nuevo. - {0} añadido: lua + {1} manifiesto(s). Reinicia Steam para aplicar. - {0} añadido: Steam obtendrá los manifiestos. Reinicia Steam para aplicar. + Agregado {0}: lua + {1} manifiesto(s). + Agregado {0}. Steam descargará los manifiestos. Abrir en SteamDB COMPARTIDO Esto reemplaza el lua instalado. Revisa qué cambia: @@ -450,9 +452,9 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después ¿Eliminar este preajuste? «{0}» se quitará de tus preajustes guardados. Esto no afecta al juego en sí. No se puede eliminar el preajuste que está en uso. - Ahora se usa «{0}». Reinicia Steam para que tenga efecto. + Ahora se usa «{0}». No se pudo cambiar de preajuste: el archivo lua podría estar en uso. - Guardado. Reinicia Steam para que tenga efecto. + Guardado. No se pudo guardar: el archivo lua podría estar en uso. Guardado como preajuste. Guardar en «{0}» @@ -498,4 +500,134 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después Se restablecieron las opciones de inicio Steam sobrescribió tus opciones de inicio de {0} juego(s). Volver a aplicarlas cierra y reabre Steam. Volver a aplicar + No se encontró la ubicación de Steam. Configúrala en Ajustes. + No se pudo conectar con GitHub. Revisa tu conexión y vuelve a intentarlo. + No se pudo conectar con el servidor de actualizaciones. Revisa tu conexión y vuelve a intentarlo. + Cancelado. + Cancelado por el usuario + Falta la descarga esperada en la versión. + Falta {0} en la versión. + La descarga no pasó la verificación (sha256 no coincide). + {0} no pasó la verificación (sha256 no coincide). + Falta en la descarga: {0}. + No se pudieron escribir {0} archivo(s). Cierra Steam y vuelve a intentarlo. + No se pudo escribir {0}. Cierra Steam y vuelve a intentarlo. + No se pudo abrir la descarga: {0} + No hay ninguna fuente disponible para este juego. + Se agotó el tiempo para iniciar sesión. El navegador no respondió. + Falló el intercambio de token ({0}): {1} + El intercambio de token devolvió una sesión vacía. + No has iniciado sesión. + Falló la actualización de la sesión ({0}). + La actualización de sesión devolvió una sesión vacía. + El enlace de descarga estaba vacío. Vuelve a intentarlo. + Falló la descarga ({0}). + Falló la solicitud ({0}) + La sesión expiró. Inicia sesión de nuevo. + Tu clave de Hubcap no es válida o expiró. + Llegaste a tu límite diario de Hubcap. + No hay ningún manifiesto de Hubcap para esta app. + Falló la descarga de Hubcap ({0}). + Descargas + Descargas + No hay descargas en curso. ¡Dale, descarga algo! + Cola + Historial + En cola + Descargando + Esperándote + Instalando + Completada + Fallida + Cancelada + Cancelar + Reintentar + Quitar + Subir + Bajar + Borrar historial + Revisar + {0} de {1} + Queda {0} + Requiere tu atención + Se interrumpió al cerrarse la aplicación. + Desbloqueo de DLC + Instala el juego primero para aplicar un fix. + En pausa + Verificando… + Pausar + Reanudar + Archivos del depósito + Depósitos · {0} de {1} + No se pudo obtener el descargador de depósitos. + No se encontraron claves de descifrado para este juego. + El depósito {0} falló: {1} + Se descargaron {0} depósitos en {1}. + Descargar + Selecciona los depósitos a descargar + Descargar {0} depósitos ({1}) + Este depósito no declara ninguna versión para descargar. + Seleccionar todo + Ningún depósito seleccionado + Elegí dónde guardar los archivos del depósito + No hay espacio suficiente: necesita {0} y solo hay {1} libres. + Guardar en + Necesita {0} · {1} libres en {2} + Iniciá sesión para descargar este depósito. + 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 + 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. + 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. + 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. + 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 1f41750..d78f74a 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 @@ -148,16 +153,17 @@ Steam se cerrará para poder modificar sus archivos y luego se reiniciará automáticamente. Cualquier juego o descarga en curso se detendrá. Cancelar Cerrar Steam y continuar - El viejo y querido SteamTools: hecho por los chinos, amado por el mundo. El desbloqueador que inició toda la tendencia (GreenLuma no existe, shh). ⚠️ ACTUALMENTE NO FUNCIONA, USA BETTERSTEAMTOOLS - Remake desde cero de SteamTools. Mejor que SteamTools en prácticamente todo. - SteamTools con algunos parches ingeniosos: un montón de correcciones y mejoras. ¡Recupera el Steam Cloud nativo mediante Google Drive / OneDrive! + Un fork de código abierto de OpenSteamTools mantenido activamente por el equipo de LuaTools. ¡Con correcciones y funciones nuevas! Gestionar No se pudo descargar ni iniciar CloudRedirect. - Compilación nocturna de BetterSteamTools (OST) desde el código más reciente. Las correcciones más recientes, pero puede ser menos estable. + Compilación nocturna de OpenSteamTools desde el código más reciente. Las correcciones más recientes, pero puede ser menos estable. + Te las apañas tú + Usa tu propia solución de desbloqueo, no la tocaremos + Desbloqueador personalizado EXPERIMENTAL COMPATIBLE CON CLOUDREDIRECT - Recupera la compatibilidad nativa con Steam Cloud mediante proveedores de almacenamiento externos como Google Drive / OneDrive / Cloudflare R2 y más. Por ahora, solo es compatible con BetterSteamTools Nightly. No olvides configurarlo haciendo clic en "Gestionar" después de activarlo y, luego, reinicia Steam. - Selecciona BetterSteamTools Nightly para usar CloudRedirect. + Recupera la compatibilidad nativa con Steam Cloud mediante proveedores de almacenamiento externos como Google Drive / OneDrive / Cloudflare R2 y más. Por ahora, solo es compatible con OpenSteamTools / BetterSteamTools. No olvides configurarlo haciendo clic en "Gestionar" después de activarlo y, luego, reinicia Steam. + Selecciona OpenSteamTools / BetterSteamTools para usar CloudRedirect. Activar Desactivar Activado. @@ -207,7 +213,6 @@ {0} manifiesto(s) {0} instalado(s) en Steam. {0} archivo(s) fallaron: cierra Steam y reinténtalo. - Reinicia Steam para aplicar. Buscar por nombre o App ID... Cargando… App ID: {0} @@ -275,13 +280,13 @@ Depósito compartido DLC {0} Eliminar archivo lua - ¿Eliminar «{0}» (App ID {1})? + ¿Quitar «{0}» (App ID {1})? -Esto borra su archivo .lua de Steam\config\stplug-in. Reinicia Steam después para que el cambio surta efecto. +Esto elimina su archivo .lua de Steam\config\stplug-in. Eliminar archivos lua - ¿Eliminar {0} archivos lua? + ¿Quitar {0} archivos lua? -Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después para que los cambios surtan efecto. +Esto elimina los archivos .lua de Steam\config\stplug-in. Error al eliminar No se pudo eliminar el archivo: {0} @@ -289,7 +294,6 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después {1} No se pudieron eliminar {0} archivos. Reiniciar Steam - ¿Reiniciar Steam ahora para que los cambios surtan efecto? No se pudo encontrar ni iniciar Steam. Define su ubicación en Ajustes. Parches Cargando parches… @@ -306,8 +310,7 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después Error de instalación No se pudo instalar: cierra Steam (o reinícialo) e inténtalo de nuevo. Parche instalado - Manifiesto de {0} instalado: Steam se está reiniciando. - Manifiesto de {0} instalado. Reinicia Steam para aplicar. + Manifiesto de {0} instalado. Juego no encontrado Instala primero {0} en Steam y luego aplica el parche. Parche aplicado parcialmente @@ -317,6 +320,22 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después 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. + 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. + 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... @@ -347,13 +366,12 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después 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. No se pudieron instalar {0} archivo(s): cierra Steam (o usa Reiniciar Steam) e inténtalo de nuevo. - {0} añadido: lua + {1} manifiesto(s). Reinicia Steam para aplicar. - {0} añadido: Steam obtendrá los manifiestos. Reinicia Steam para aplicar. + Añadido {0}: lua + {1} manifiesto(s). + Añadido {0}. Steam descargará los manifiestos. Abrir en SteamDB COMPARTIDO Esto reemplaza el lua instalado. Revisa qué cambia: @@ -450,9 +468,9 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después ¿Eliminar este preajuste? «{0}» se quitará de tus preajustes guardados. Esto no afecta al juego en sí. No se puede eliminar el preajuste que está en uso. - Ahora se usa «{0}». Reinicia Steam para que surta efecto. + Ahora se usa «{0}». No se pudo cambiar de preajuste: el archivo lua podría estar en uso. - Guardado. Reinicia Steam para que surta efecto. + Guardado. No se pudo guardar: el archivo lua podría estar en uso. Guardado como preajuste. Guardar en «{0}» @@ -498,4 +516,118 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después Se restablecieron las opciones de inicio Steam ha sobrescrito tus opciones de inicio de {0} juego(s). Volver a aplicarlas cierra y reabre Steam. Volver a aplicar + No se encontró la ubicación de Steam. Configúrala en Ajustes. + No se pudo conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo. + No se pudo conectar con el servidor de actualizaciones. Comprueba tu conexión e inténtalo de nuevo. + Cancelado. + Cancelado por el usuario + Falta la descarga esperada en la versión. + Falta {0} en la versión. + La descarga no superó la verificación (sha256 no coincide). + {0} no superó la verificación (sha256 no coincide). + Falta en la descarga: {0}. + No se pudieron escribir {0} archivo(s). Cierra Steam e inténtalo de nuevo. + No se pudo escribir {0}. Cierra Steam e inténtalo de nuevo. + No se pudo abrir la descarga: {0} + No hay ninguna fuente disponible para este juego. + Se agotó el tiempo de inicio de sesión. El navegador no respondió. + Error en el intercambio de token ({0}): {1} + El intercambio de token devolvió una sesión vacía. + No has iniciado sesión. + Error al actualizar la sesión ({0}). + La actualización de sesión devolvió una sesión vacía. + El enlace de descarga estaba vacío. Inténtalo de nuevo. + Error en la descarga ({0}). + Error en la solicitud ({0}) + Sesión caducada. Vuelve a iniciar sesión. + Tu clave de Hubcap no es válida o ha caducado. + Has alcanzado tu límite diario de Hubcap. + No hay ningún manifiesto de Hubcap para esta app. + Error en la descarga de Hubcap ({0}). + Descargas + Descargas + No hay descargas en curso. ¡Venga, descarga algo! + Cola + Historial + En cola + Descargando + Esperándote + Instalando + Completada + Fallida + Cancelada + Cancelar + Reintentar + Quitar + Subir + Bajar + Borrar historial + Revisar + {0} de {1} + Queda {0} + Requiere tu atención + Interrumpida al cerrarse la aplicación. + Desbloqueo de DLC + Instala el juego primero para aplicar un fix. + En pausa + Verificando… + Pausar + Reanudar + Archivos del depósito + Depósitos · {0} de {1} + No se pudo obtener el descargador de depósitos. + No se encontraron claves de descifrado para este juego. + El depósito {0} falló: {1} + Se descargaron {0} depósitos en {1}. + Descargar + Selecciona los depósitos que descargar + Descargar {0} depósitos ({1}) + Este depósito no declara ninguna versión para descargar. + Seleccionar todo + Ningún depósito seleccionado + Elige dónde guardar los archivos del depósito + No hay espacio suficiente: necesita {0} y solo hay {1} libres. + Guardar en + Necesita {0} · {1} libres en {2} + Inicia sesión para descargar este depósito. + 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 + 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. + 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 b3930e9..2b0c4c0 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 @@ -148,16 +153,17 @@ Steam suljetaan, jotta sen tiedostoja voidaan muuttaa, ja käynnistetään sitten automaattisesti uudelleen. Kaikki käynnissä olevat pelit tai lataukset pysähtyvät. Peruuta Sulje Steam ja jatka - Hyvä vanha SteamTools — kiinalaisten rakentama, maailman rakastama. Avaaja, joka aloitti koko trendin (GreenLumaa ei ole olemassa, shh). ⚠️ EI TOIMI TÄLLÄ HETKELLÄ, KÄYTÄ ALLA OLEVAA BETTERSTEAMTOOLS:TÄ - Alusta asti uudelleenrakennettu versio SteamToolsista. Parempi kuin SteamTools käytännössä kaikin tavoin. - SteamTools muutamalla näppärällä paikalla — valtavasti korjauksia ja parannuksia. Tuo natiivin Steam Cloudin takaisin Google Driven / OneDriven kautta! + Avoimen lähdekoodin fork OpenSteamToolsista, jota LuaTools-tiimi ylläpitää aktiivisesti. Korjauksia ja uusia ominaisuuksia! Hallinta CloudRedirectin lataaminen tai käynnistäminen epäonnistui. - BetterSteamToolsin (OST) yöllinen koontiversio uusimmasta lähdekoodista. Uusimmat korjaukset, mutta voi olla epävakaampi. + OpenSteamToolsin yöllinen koontiversio uusimmasta lähdekoodista. Uusimmat korjaukset, mutta voi olla epävakaampi. + Nyt olet omillasi + Käytä omaa avausratkaisuasi, me emme koske siihen + Oma unlocker KOKEELLINEN CLOUDREDIRECT-TUKI - Palauttaa natiivin Steam Cloud -tuen kolmannen osapuolen tallennuspalveluiden, kuten Google Drive / OneDrive / Cloudflare R2 ja muiden, kautta. Toistaiseksi yhteensopiva vain BetterSteamTools Nightlyn kanssa. Muista määrittää se napsauttamalla "Hallinta" käyttöönoton jälkeen ja käynnistä sitten Steam uudelleen! - Valitse BetterSteamTools Nightly käyttääksesi CloudRedirectiä. + Palauttaa natiivin Steam Cloud -tuen kolmannen osapuolen tallennuspalveluiden, kuten Google Drive / OneDrive / Cloudflare R2 ja muiden, kautta. Toistaiseksi yhteensopiva vain OpenSteamTools / BetterSteamToolsin kanssa. Muista määrittää se napsauttamalla "Hallinta" käyttöönoton jälkeen ja käynnistä sitten Steam uudelleen! + Valitse OpenSteamTools / BetterSteamTools käyttääksesi CloudRedirectiä. Ota käyttöön Poista käytöstä Käytössä. @@ -207,7 +213,6 @@ {0} manifestia {0} asennettu Steamiin. {0} tiedostoa epäonnistui — sulje Steam ja yritä uudelleen. - Käynnistä Steam uudelleen ottaaksesi käyttöön. Hae nimellä tai App ID:llä... Ladataan… App ID: {0} @@ -275,13 +280,13 @@ Jaettu depo DLC {0} Poista lua-tiedosto - Poistetaanko "{0}" (App ID {1})? + Poistetaanko ”{0}” (App ID {1})? -Tämä poistaa sen .lua-tiedoston sijainnista Steam\config\stplug-in. Käynnistä Steam tämän jälkeen uudelleen, jotta muutos tulee voimaan. +Tämä poistaa sen .lua-tiedoston kansiosta Steam\config\stplug-in. Poista lua-tiedostot Poistetaanko {0} lua-tiedostoa? -Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä Steam tämän jälkeen uudelleen, jotta muutokset tulevat voimaan. +Tämä poistaa .lua-tiedostot kansiosta Steam\config\stplug-in. Poisto epäonnistui Tiedostoa ei voitu poistaa: {0} @@ -289,7 +294,6 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St {1} {0} tiedostoa ei voitu poistaa. Käynnistä Steam uudelleen - Käynnistetäänkö Steam nyt uudelleen, jotta muutokset tulevat voimaan? Steamia ei voitu löytää tai käynnistää. Määritä sen sijainti Asetuksissa. Korjaukset Ladataan korjauksia… @@ -306,8 +310,7 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St Asennus epäonnistui Ei voitu asentaa — sulje Steam (tai käynnistä Steam uudelleen) ja yritä uudelleen. Korjaus asennettu - {0}-manifest asennettu — Steam käynnistyy uudelleen. - {0}-manifest asennettu. Käynnistä Steam uudelleen ottaaksesi käyttöön. + {0}:n manifesti asennettu. Peliä ei löytynyt Asenna ensin {0} Steamiin ja käytä sitten korjausta. Korjaus osittain käytetty @@ -347,13 +350,12 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St 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. {0} tiedostoa ei voitu asentaa — sulje Steam (tai käytä Käynnistä Steam uudelleen) ja yritä uudelleen. - {0} lisätty — lua + {1} manifestia. Käynnistä Steam uudelleen ottaaksesi käyttöön. - {0} lisätty — Steam hakee manifestit. Käynnistä Steam uudelleen ottaaksesi käyttöön. + Lisätty {0}: lua + {1} manifestia. + Lisätty {0}. Steam hakee manifestit. Avaa SteamDB:ssä JAETTU Tämä korvaa asennetun luan. Tarkista, mikä muuttuu: @@ -450,9 +452,9 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St Poistetaanko tämä esiasetus? ”{0}” poistetaan tallennetuista esiasetuksistasi. Tämä ei vaikuta itse peliin. Käytössä olevaa esiasetusta ei voi poistaa. - Käytössä on nyt ”{0}”. Käynnistä Steam uudelleen, jotta muutos tulee voimaan. + Käytössä on nyt ”{0}”. Esiasetusta ei voitu vaihtaa — lua-tiedosto saattaa olla käytössä. - Tallennettu. Käynnistä Steam uudelleen, jotta muutos tulee voimaan. + Tallennettu. Tallennus epäonnistui — lua-tiedosto saattaa olla käytössä. Tallennettu esiasetuksena. Tallenna kohteeseen ”{0}” @@ -498,4 +500,134 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St Käynnistysvalinnat nollattiin Steam on korvannut käynnistysvalintasi {0} pelin osalta. Uudelleen käyttöönotto sulkee Steamin ja avaa sen uudelleen. Ota käyttöön uudelleen + Steamin sijaintia ei löytynyt. Aseta se asetuksissa. + GitHubiin ei saatu yhteyttä. Tarkista yhteytesi ja yritä uudelleen. + Päivityspalvelimeen ei saatu yhteyttä. Tarkista yhteytesi ja yritä uudelleen. + Peruutettu. + Käyttäjä peruutti + Julkaisusta puuttuu odotettu lataus. + Julkaisusta puuttuu {0}. + Lataus ei läpäissyt varmennusta (sha256 ei täsmää). + {0} ei läpäissyt varmennusta (sha256 ei täsmää). + Latauksesta puuttuu: {0}. + {0} tiedoston kirjoitus epäonnistui. Sulje Steam ja yritä uudelleen. + Tiedoston {0} kirjoitus epäonnistui. Sulje Steam ja yritä uudelleen. + Latausta ei voitu avata: {0} + Tälle pelille ei ole saatavilla lähdettä. + Kirjautuminen aikakatkaistiin. Selain ei vastannut. + Tunnuksen vaihto epäonnistui ({0}): {1} + Tunnuksen vaihto palautti tyhjän istunnon. + Et ole kirjautunut sisään. + Istunnon päivitys epäonnistui ({0}). + Istunnon päivitys palautti tyhjän istunnon. + Latauslinkki oli tyhjä. Yritä uudelleen. + Lataus epäonnistui ({0}). + Pyyntö epäonnistui ({0}) + Istunto vanheni. Kirjaudu uudelleen sisään. + Hubcap-avaimesi on virheellinen tai vanhentunut. + Päivittäinen Hubcap-rajasi on täynnä. + Tälle sovellukselle ei ole Hubcap-manifestia. + Hubcap-lataus epäonnistui ({0}). + Lataukset + Lataukset + Ei latauksia juuri nyt. Lataa nyt jotain! + Jono + Historia + Jonossa + Ladataan + Odottaa sinua + Asennetaan + Valmis + Epäonnistui + Peruutettu + Peruuta + Yritä uudelleen + Poista + Siirrä ylös + Siirrä alas + Tyhjennä historia + Tarkista + {0} / {1} + {0} jäljellä + Vaatii toimenpiteen + Keskeytyi, kun sovellus suljettiin. + DLC:n avaus + Asenna peli ensin, jotta voit ottaa korjauksen käyttöön. + Keskeytetty + Tarkistetaan… + Keskeytä + Jatka + Depot-tiedostot + Depotit · {0}/{1} + Depot-latainta ei saatu haettua. + Tälle pelille ei löytynyt salauksenpurkuavaimia. + Depot {0} epäonnistui: {1} + Ladattiin {0} depotia kohteeseen {1}. + Lataa + Valitse ladattavat depotit + Lataa {0} depotia ({1}) + Tämä depot ei ilmoita ladattavaa versiota. + Valitse kaikki + Ei valittuja depoteja + Valitse, minne depot-tiedostot tallennetaan + Levytila ei riitä: tarvitaan {0}, vapaana vain {1}. + Tallenna kohteeseen + Tarvitaan {0} · vapaana {1} asemassa {2} + Kirjaudu sisään ladataksesi tämän depotin. + Kirjaudu sisään ladataksesi depoteja. + haetaan manifestia + 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. + 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. + 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. + 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 615292c..8de28e7 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 @@ -148,16 +153,17 @@ Steam sera fermé afin que ses fichiers puissent être modifiés, puis redémarré automatiquement. Tous les jeux ou téléchargements en cours seront arrêtés. Annuler Fermer Steam et continuer - Le bon vieux SteamTools — créé par les Chinois, adoré dans le monde entier. Le débloqueur qui a lancé toute la tendance (GreenLuma n'existe pas, chut). ⚠️ NE FONCTIONNE PAS ACTUELLEMENT, UTILISEZ BETTERSTEAMTOOLS - Refonte intégrale de SteamTools. Meilleur que SteamTools sur pratiquement tous les points. - SteamTools avec quelques correctifs astucieux — une tonne de corrections et d'améliorations. Rétablit le Steam Cloud natif via Google Drive / OneDrive ! + Un fork open source d'OpenSteamTools activement maintenu par l'équipe LuaTools. Avec des correctifs et de nouvelles fonctionnalités ! Gérer Impossible de télécharger ou de lancer CloudRedirect. - Build nocturne de BetterSteamTools (OST) depuis les dernières sources. Les correctifs les plus récents, mais peut être moins stable. + Build nocturne de OpenSteamTools depuis les dernières sources. Les correctifs les plus récents, mais peut être moins stable. + Tu te débrouilles + Utilise ta propre solution de déblocage, on n'y touchera pas + Débloqueur personnalisé EXPÉRIMENTAL PRISE EN CHARGE DE CLOUDREDIRECT - Rétablit la prise en charge native de Steam Cloud via des fournisseurs de stockage tiers tels que Google Drive / OneDrive / Cloudflare R2 et bien d'autres. Compatible uniquement avec BetterSteamTools Nightly pour le moment. N'oubliez pas de le configurer en cliquant sur "Gérer" après l'avoir activé, puis redémarrez Steam ! - Sélectionnez BetterSteamTools Nightly pour utiliser CloudRedirect. + Rétablit la prise en charge native de Steam Cloud via des fournisseurs de stockage tiers tels que Google Drive / OneDrive / Cloudflare R2 et bien d'autres. Compatible uniquement avec OpenSteamTools / BetterSteamTools pour le moment. N'oubliez pas de le configurer en cliquant sur "Gérer" après l'avoir activé, puis redémarrez Steam ! + Sélectionnez OpenSteamTools / BetterSteamTools pour utiliser CloudRedirect. Activer Désactiver Activé. @@ -207,7 +213,6 @@ {0} manifest(s) {0} installé(s) dans Steam. {0} fichier(s) en échec — fermez Steam et réessayez. - Redémarrez Steam pour appliquer. Rechercher par nom ou App ID... Chargement… App ID : {0} @@ -277,11 +282,11 @@ Supprimer le fichier lua Supprimer « {0} » (App ID {1}) ? -Cela supprime son fichier .lua de Steam\config\stplug-in. Redémarrez Steam ensuite pour que le changement prenne effet. +Cela supprime son fichier .lua de Steam\config\stplug-in. Supprimer les fichiers lua Supprimer {0} fichiers lua ? -Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ensuite pour que les changements prennent effet. +Cela supprime les fichiers .lua de Steam\config\stplug-in. Échec de la suppression Impossible de supprimer le fichier : {0} @@ -289,7 +294,6 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens {1} {0} fichiers n'ont pas pu être supprimés. Redémarrer Steam - Redémarrer Steam maintenant pour que les changements prennent effet ? Impossible de trouver ou de lancer Steam. Définissez son emplacement dans les Paramètres. Correctifs Chargement des correctifs… @@ -306,8 +310,7 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens Échec de l'installation Installation impossible — fermez Steam (ou redémarrez Steam) et réessayez. Correctif installé - Manifest de {0} installé — Steam redémarre. - Manifest de {0} installé. Redémarrez Steam pour appliquer. + Manifeste de {0} installé. Jeu introuvable Installez d'abord {0} dans Steam, puis appliquez le correctif. Correctif partiellement appliqué @@ -347,13 +350,12 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens 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. Impossible d'installer {0} fichier(s) — fermez Steam (ou utilisez Redémarrer Steam) et réessayez. - {0} ajouté — lua + {1} manifest(s). Redémarrez Steam pour appliquer. - {0} ajouté — Steam récupérera les manifests. Redémarrez Steam pour appliquer. + {0} ajouté : lua + {1} manifeste(s). + {0} ajouté. Steam va récupérer les manifestes. Ouvrir sur SteamDB PARTAGÉ Cela remplace le lua installé. Vérifiez ce qui change : @@ -450,9 +452,9 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens Supprimer ce préréglage ? « {0} » sera retiré de vos préréglages enregistrés. Cela n'affecte pas le jeu lui-même. Impossible de supprimer le préréglage actuellement utilisé. - « {0} » est maintenant utilisé. Redémarrez Steam pour que cela prenne effet. + « {0} » est maintenant utilisé. Impossible de changer de préréglage — le fichier lua est peut-être en cours d'utilisation. - Enregistré. Redémarrez Steam pour que cela prenne effet. + Enregistré. Impossible d'enregistrer — le fichier lua est peut-être en cours d'utilisation. Enregistré comme préréglage. Enregistrer dans « {0} » @@ -498,4 +500,134 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens Les options de lancement ont été réinitialisées Steam a écrasé vos options de lancement pour {0} jeu(x). Les réappliquer ferme et rouvre Steam. Réappliquer + Emplacement de Steam introuvable. Définis-le dans les Paramètres. + Impossible de joindre GitHub. Vérifie ta connexion et réessaie. + Impossible de joindre le serveur de mise à jour. Vérifie ta connexion et réessaie. + Annulé. + Annulé par l'utilisateur + Le téléchargement attendu est absent de la version. + {0} est absent de la version. + Échec de la vérification du téléchargement (sha256 incorrect). + Échec de la vérification de {0} (sha256 incorrect). + Élément manquant dans le téléchargement : {0}. + Impossible d'écrire {0} fichier(s). Ferme Steam et réessaie. + Impossible d'écrire {0}. Ferme Steam et réessaie. + Impossible d'ouvrir le téléchargement : {0} + Aucune source disponible pour ce jeu. + Délai de connexion dépassé. Aucune réponse du navigateur. + Échec de l'échange de jeton ({0}) : {1} + L'échange de jeton a renvoyé une session vide. + Non connecté. + Échec de l'actualisation de la session ({0}). + L'actualisation de la session a renvoyé une session vide. + Le lien de téléchargement était vide. Réessaie. + Échec du téléchargement ({0}). + Échec de la requête ({0}) + Session expirée. Reconnecte-toi. + Ta clé Hubcap est invalide ou expirée. + Ta limite quotidienne Hubcap est atteinte. + Aucun manifeste Hubcap n'est disponible pour cette app. + Échec du téléchargement Hubcap ({0}). + Téléchargements + Téléchargements + Aucun téléchargement en cours. Lancez-vous, téléchargez un truc ! + File d'attente + Historique + En attente + Téléchargement + En attente de votre réponse + Installation + Terminé + Échec + Annulé + Annuler + Réessayer + Supprimer + Monter + Descendre + Effacer l'historique + Vérifier + {0} sur {1} + {0} restant + Action requise + Interrompu à la fermeture de l'application. + Déblocage de DLC + Installez d'abord le jeu pour appliquer un fix. + En pause + Vérification… + Pause + Reprendre + Fichiers du dépôt + Dépôts · {0} sur {1} + Impossible de récupérer le téléchargeur de dépôts. + Aucune clé de déchiffrement trouvée pour ce jeu. + Échec du dépôt {0} : {1} + {0} dépôts téléchargés dans {1}. + Télécharger + Sélectionnez les dépôts à télécharger + Télécharger {0} dépôts ({1}) + Ce dépôt ne déclare aucune version à télécharger. + Tout sélectionner + Aucun dépôt sélectionné + Choisissez où enregistrer les fichiers du dépôt + Espace disque insuffisant : nécessite {0}, seulement {1} libre. + Enregistrer dans + Nécessite {0} · {1} libre sur {2} + Connectez-vous pour télécharger ce dépôt. + 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 + 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. + 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é. + 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. + 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 0920b87..97f14e8 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 @@ -148,16 +153,17 @@ A Steam bezárul, hogy a fájljai módosíthatók legyenek, majd automatikusan újraindul. Minden futó játék vagy letöltés leáll. Mégse Steam bezárása és folytatás - A jó öreg SteamTools — kínaiak készítették, a világ szereti. A feloldó, amely elindította az egész trendet (a GreenLuma nem létezik, pszt). ⚠️ JELENLEG NEM MŰKÖDIK, HASZNÁLD AZ ALÁBBI BETTERSTEAMTOOLS-T - A SteamTools alapoktól újraépített változata. Gyakorlatilag minden téren jobb a SteamToolsnál. - SteamTools néhány ügyes javítással — rengeteg javítás és fejlesztés. Visszahozza a natív Steam Cloudot Google Drive / OneDrive segítségével! + Az OpenSteamTools nyílt forráskódú forkja, amelyet a LuaTools csapata aktívan karbantart. Javításokkal és új funkciókkal! Kezelés A CloudRedirect letöltése vagy indítása nem sikerült. - A BetterSteamTools (OST) éjszakai buildje a legújabb forrásból. A legfrissebb javítások, de kevésbé lehet stabil. + A OpenSteamTools éjszakai buildje a legújabb forrásból. A legfrissebb javítások, de kevésbé lehet stabil. + Innentől a tiéd a pálya + Használd a saját feloldó megoldásodat, nem nyúlunk hozzá + Saját feloldó KÍSÉRLETI CLOUDREDIRECT TÁMOGATÁS - Visszahozza a natív Steam Cloud támogatást külső tárhelyszolgáltatókon keresztül, mint a Google Drive / OneDrive / Cloudflare R2 és mások. Jelenleg csak a BetterSteamTools Nightly móddal kompatibilis. Ne feledd bekapcsolás után a "Kezelés" gombra kattintva beállítani, majd indítsd újra a Steamet! - Válaszd a BetterSteamTools Nightly módot a CloudRedirect használatához. + Visszahozza a natív Steam Cloud támogatást külső tárhelyszolgáltatókon keresztül, mint a Google Drive / OneDrive / Cloudflare R2 és mások. Jelenleg csak a OpenSteamTools / BetterSteamTools móddal kompatibilis. Ne feledd bekapcsolás után a "Kezelés" gombra kattintva beállítani, majd indítsd újra a Steamet! + Válaszd a OpenSteamTools / BetterSteamTools módot a CloudRedirect használatához. Bekapcsolás Kikapcsolás Bekapcsolva. @@ -207,7 +213,6 @@ {0} manifest {0} telepítve a Steamre. {0} fájl sikertelen — zárd be a Steamet, és próbáld újra. - Indítsd újra a Steamet az alkalmazáshoz. Keresés név vagy App ID alapján... Betöltés… App ID: {0} @@ -275,13 +280,13 @@ Megosztott depó DLC {0} Lua fájl eltávolítása - Eltávolítod a következőt: „{0}” (App ID {1})? + Eltávolítod a(z) „{0}” (App ID {1}) elemet? -Ez törli a .lua fájlját a Steam\config\stplug-in mappából. Ezután indítsd újra a Steamet, hogy a változás érvénybe lépjen. +Ez törli a .lua fájlját a Steam\config\stplug-in mappából. Lua fájlok eltávolítása Eltávolítasz {0} lua fájlt? -Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd újra a Steamet, hogy a változások érvénybe lépjenek. +Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Az eltávolítás sikertelen A fájlt nem sikerült törölni: {0} @@ -289,7 +294,6 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd {1} {0} fájlt nem sikerült törölni. Steam újraindítása - Újraindítod most a Steamet, hogy a változások érvénybe lépjenek? A Steam nem található vagy nem indítható el. Add meg a helyét a Beállításokban. Javítások Javítások betöltése… @@ -306,8 +310,7 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd Telepítés sikertelen Nem sikerült telepíteni — zárd be a Steamet (vagy indítsd újra), és próbáld újra. Javítás telepítve - A(z) {0} manifest telepítve — a Steam újraindul. - A(z) {0} manifest telepítve. Indítsd újra a Steamet az alkalmazáshoz. + {0} manifest telepítve. Játék nem található Először telepítsd a(z) {0} játékot a Steamre, majd alkalmazd a javítást. Javítás részben alkalmazva @@ -347,13 +350,12 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd 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. Nem sikerült telepíteni {0} fájlt — zárd be a Steamet (vagy használd a Steam újraindítása lehetőséget), és próbáld újra. - {0} hozzáadva — lua + {1} manifest. Indítsd újra a Steamet az alkalmazáshoz. - {0} hozzáadva — a Steam lekéri a manifesteket. Indítsd újra a Steamet az alkalmazáshoz. + {0} hozzáadva: lua + {1} manifest. + {0} hozzáadva. A Steam letölti a manifesteket. Megnyitás a SteamDB-n MEGOSZTOTT Ez lecseréli a telepített luát. Tekintsd át, mi változik: @@ -450,9 +452,9 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd Törlöd ezt az előbeállítást? A(z) „{0}” eltávolításra kerül a mentett előbeállításaid közül. Ez magára a játékra nincs hatással. A jelenleg használt előbeállítás nem törölhető. - Mostantól a(z) „{0}” van használatban. Indítsd újra a Steamet, hogy érvénybe lépjen. + Most a(z) „{0}” van használatban. Nem sikerült előbeállítást váltani — a lua fájl használatban lehet. - Mentve. Indítsd újra a Steamet, hogy érvénybe lépjen. + Mentve. Nem sikerült menteni — a lua fájl használatban lehet. Mentve előbeállításként. Mentés ide: „{0}” @@ -498,4 +500,134 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd Az indítási beállítások visszaálltak A Steam felülírta az indítási beállításaidat {0} játéknál. Az ismételt alkalmazás bezárja és újra megnyitja a Steamet. Alkalmazás újra + A Steam helye nem található. Állítsd be a Beállításokban. + Nem sikerült elérni a GitHubot. Ellenőrizd a kapcsolatot, és próbáld újra. + Nem sikerült elérni a frissítési kiszolgálót. Ellenőrizd a kapcsolatot, és próbáld újra. + Megszakítva. + A felhasználó megszakította + A kiadásból hiányzik a várt letöltés. + A kiadásból hiányzik: {0}. + A letöltés nem ment át az ellenőrzésen (sha256 eltérés). + {0} nem ment át az ellenőrzésen (sha256 eltérés). + A letöltésből hiányzik: {0}. + Nem sikerült {0} fájlt írni. Zárd be a Steamet, és próbáld újra. + Nem sikerült írni: {0}. Zárd be a Steamet, és próbáld újra. + Nem sikerült megnyitni a letöltést: {0} + Ehhez a játékhoz nincs elérhető forrás. + A bejelentkezés időtúllépés miatt megszakadt. A böngésző nem válaszolt. + A tokencsere sikertelen ({0}): {1} + A tokencsere üres munkamenetet adott vissza. + Nem vagy bejelentkezve. + A munkamenet frissítése sikertelen ({0}). + A munkamenet frissítése üres munkamenetet adott vissza. + A letöltési hivatkozás üres volt. Próbáld újra. + A letöltés sikertelen ({0}). + A kérés sikertelen ({0}) + A munkamenet lejárt. Jelentkezz be újra. + A Hubcap-kulcsod érvénytelen vagy lejárt. + Elérted a napi Hubcap-korlátot. + Ehhez az alkalmazáshoz nincs Hubcap-manifeszt. + A Hubcap-letöltés sikertelen ({0}). + Letöltések + Letöltések + Most nincs letöltés. Gyerünk, tölts le valamit! + Várólista + Előzmények + Várólistán + Letöltés + Rád vár + Telepítés + Kész + Sikertelen + Megszakítva + Mégse + Újra + Eltávolítás + Fel + Le + Előzmények törlése + Megnézem + {0} / {1} + {0} van hátra + Beavatkozás szükséges + Megszakadt az alkalmazás bezárásakor. + DLC-feloldás + Előbb telepítsd a játékot a fix alkalmazásához. + Szüneteltetve + Ellenőrzés… + Szünet + Folytatás + Depot-fájlok + Depotok · {0} / {1} + A depot-letöltő nem szerezhető be. + Nem található visszafejtési kulcs ehhez a játékhoz. + A(z) {0} depot hibára futott: {1} + {0} depot letöltve ide: {1}. + Letöltés + Válaszd ki a letöltendő depotokat + {0} depot letöltése ({1}) + Ez a depot nem ad meg letölthető verziót. + Összes kijelölése + Nincs kiválasztott depot + Válaszd ki, hova mentsük a depot-fájlokat + Nincs elég lemezterület: {0} kellene, csak {1} szabad. + Mentés ide + {0} kell · {1} szabad itt: {2} + Jelentkezz be a depot letöltéséhez. + 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 + 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. + 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 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. + 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 9718ddc..97e6e5a 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 @@ -148,16 +153,17 @@ Steam akan ditutup agar file-nya dapat diubah, lalu dimulai ulang secara otomatis. Semua gim atau unduhan yang berjalan akan berhenti. Batal Tutup Steam dan lanjutkan - SteamTools yang melegenda — dibuat oleh orang Tiongkok, dicintai dunia. Pembuka kunci yang memulai seluruh tren ini (GreenLuma tidak ada, sst). ⚠️ SAAT INI TIDAK BERFUNGSI, GUNAKAN BETTERSTEAMTOOLS - Remake SteamTools yang dibuat dari nol. Lebih baik daripada SteamTools dalam hampir segala hal. - SteamTools dengan beberapa patch cerdas — banyak perbaikan dan peningkatan. Mengembalikan Steam Cloud asli melalui Google Drive / OneDrive! + Fork sumber terbuka dari OpenSteamTools yang dikelola secara aktif oleh tim LuaTools. Menghadirkan perbaikan dan fitur baru! Kelola Tidak dapat mengunduh atau menjalankan CloudRedirect. - Build nightly BetterSteamTools (OST) dari sumber terbaru. Perbaikan terbaru, tetapi mungkin kurang stabil. + Build nightly OpenSteamTools dari sumber terbaru. Perbaikan terbaru, tetapi mungkin kurang stabil. + Selanjutnya urusanmu + Pakai solusi unlock milikmu sendiri, kami tidak akan mengutak-atiknya + Unlocker sendiri EKSPERIMENTAL DUKUNGAN CLOUDREDIRECT - Menghadirkan kembali dukungan Steam Cloud bawaan melalui penyedia penyimpanan pihak ketiga seperti Google Drive / OneDrive / Cloudflare R2 dan lainnya. Saat ini hanya kompatibel dengan BetterSteamTools Nightly. Jangan lupa mengonfigurasinya dengan mengeklik "Kelola" setelah mengaktifkan, lalu mulai ulang Steam! - Pilih BetterSteamTools Nightly untuk menggunakan CloudRedirect. + Menghadirkan kembali dukungan Steam Cloud bawaan melalui penyedia penyimpanan pihak ketiga seperti Google Drive / OneDrive / Cloudflare R2 dan lainnya. Saat ini hanya kompatibel dengan OpenSteamTools / BetterSteamTools. Jangan lupa mengonfigurasinya dengan mengeklik "Kelola" setelah mengaktifkan, lalu mulai ulang Steam! + Pilih OpenSteamTools / BetterSteamTools untuk menggunakan CloudRedirect. Aktifkan Nonaktifkan Aktif. @@ -207,7 +213,6 @@ {0} manifest {0} dipasang ke Steam. {0} file gagal — tutup Steam dan coba lagi. - Mulai ulang Steam untuk menerapkan. Cari berdasarkan nama atau App ID... Memuat… App ID: {0} @@ -275,13 +280,13 @@ Depot bersama DLC {0} Hapus file lua - Hapus "{0}" (App ID {1})? + Hapus “{0}” (App ID {1})? -Ini menghapus file .lua-nya dari Steam\config\stplug-in. Mulai ulang Steam setelahnya agar perubahan berlaku. +Ini menghapus berkas .lua-nya dari Steam\config\stplug-in. Hapus file lua - Hapus {0} file lua? + Hapus {0} berkas lua? -Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahnya agar perubahan berlaku. +Ini menghapus berkas .lua dari Steam\config\stplug-in. Penghapusan gagal Tidak dapat menghapus file: {0} @@ -289,7 +294,6 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny {1} {0} file tidak dapat dihapus. Mulai Ulang Steam - Mulai ulang Steam sekarang agar perubahan berlaku? Tidak dapat menemukan atau menjalankan Steam. Atur lokasinya di Pengaturan. Perbaikan Memuat perbaikan… @@ -306,8 +310,7 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny Pemasangan gagal Tidak dapat memasang — tutup Steam (atau mulai ulang Steam) dan coba lagi. Perbaikan terpasang - Manifest {0} terpasang — Steam sedang dimulai ulang. - Manifest {0} terpasang. Mulai ulang Steam untuk menerapkan. + Manifest {0} terpasang. Gim tidak ditemukan Pasang {0} di Steam dahulu, lalu terapkan perbaikan. Perbaikan diterapkan sebagian @@ -347,13 +350,12 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny 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. Tidak dapat memasang {0} file — tutup Steam (atau gunakan Mulai Ulang Steam) dan coba lagi. - {0} ditambahkan — lua + {1} manifest. Mulai ulang Steam untuk menerapkan. - {0} ditambahkan — Steam akan mengambil manifest. Mulai ulang Steam untuk menerapkan. + {0} ditambahkan: lua + {1} manifest. + {0} ditambahkan. Steam akan mengambil manifest. Buka di SteamDB BERSAMA Ini menggantikan lua yang terpasang. Tinjau apa yang berubah: @@ -450,9 +452,9 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny Hapus praatur ini? “{0}” akan dihapus dari praatur tersimpan Anda. Ini tidak memengaruhi game itu sendiri. Praatur yang sedang dipakai tidak bisa dihapus. - Sekarang memakai “{0}”. Mulai ulang Steam agar berlaku. + Sekarang memakai “{0}”. Tidak bisa mengganti praatur — berkas lua mungkin sedang dipakai. - Tersimpan. Mulai ulang Steam agar berlaku. + Tersimpan. Tidak bisa menyimpan — berkas lua mungkin sedang dipakai. Disimpan sebagai praatur. Simpan ke “{0}” @@ -498,4 +500,134 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny Opsi peluncuran telah disetel ulang Steam menimpa opsi peluncuran Anda untuk {0} game. Menerapkannya lagi akan menutup dan membuka Steam kembali. Terapkan lagi + Lokasi Steam tidak ditemukan. Atur di Pengaturan. + Tidak dapat menghubungi GitHub. Periksa koneksimu dan coba lagi. + Tidak dapat menghubungi server pembaruan. Periksa koneksimu dan coba lagi. + Dibatalkan. + Dibatalkan oleh pengguna + Rilis tidak memiliki berkas unduhan yang diharapkan. + Rilis tidak memiliki {0}. + Unduhan gagal verifikasi (sha256 tidak cocok). + {0} gagal verifikasi (sha256 tidak cocok). + Tidak ada dalam unduhan: {0}. + Tidak dapat menulis {0} berkas. Tutup Steam dan coba lagi. + Tidak dapat menulis {0}. Tutup Steam dan coba lagi. + Tidak dapat membuka unduhan: {0} + Tidak ada sumber yang tersedia untuk gim ini. + Waktu masuk habis. Tidak ada respons dari peramban. + Pertukaran token gagal ({0}): {1} + Pertukaran token mengembalikan sesi kosong. + Belum masuk. + Penyegaran sesi gagal ({0}). + Penyegaran sesi mengembalikan sesi kosong. + Tautan unduhan kosong. Coba lagi. + Unduhan gagal ({0}). + Permintaan gagal ({0}) + Sesi berakhir. Silakan masuk lagi. + Kunci Hubcap-mu tidak valid atau kedaluwarsa. + Kamu sudah mencapai batas harian Hubcap. + Tidak ada manifes Hubcap untuk aplikasi ini. + Unduhan Hubcap gagal ({0}). + Unduhan + Unduhan + Tidak ada unduhan saat ini. Ayo, unduh sesuatu! + Antrean + Riwayat + Dalam antrean + Mengunduh + Menunggu kamu + Memasang + Selesai + Gagal + Dibatalkan + Batal + Coba lagi + Hapus + Naikkan + Turunkan + Hapus riwayat + Tinjau + {0} dari {1} + Sisa {0} + Perlu tindakan + Terhenti saat aplikasi ditutup. + Buka kunci DLC + Pasang gamenya dulu untuk menerapkan fix. + Dijeda + Memverifikasi… + Jeda + Lanjutkan + Berkas depot + Depot · {0} dari {1} + Tidak bisa mendapatkan pengunduh depot. + Tidak ada kunci dekripsi untuk gim ini. + Depot {0} gagal: {1} + {0} depot diunduh ke {1}. + Unduh + Pilih depot yang mau diunduh + Unduh {0} depot ({1}) + Depot ini tidak menyatakan versi untuk diunduh. + Pilih semua + Tidak ada depot dipilih + Pilih tempat menyimpan berkas depot + Ruang disk tidak cukup: butuh {0}, hanya {1} tersisa. + Simpan ke + Butuh {0} · {1} tersisa di {2} + Masuk untuk mengunduh depot ini. + Masuk untuk mengunduh depot. + mengambil manifest + 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. + 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. + 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. + 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 2036121..74d2d8b 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -77,7 +77,7 @@ GENERALE STEAM INSTALLAZIONE - COMUNITÀ + COMMUNITY Disconnetti Navigazione come ospite Accedi per scaricare i manifest e gli sblocchi dei DLC @@ -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 @@ -141,23 +146,24 @@ Nessuna modalità selezionata Modalità · {0} Modalità - Cambia il backend usato per sbloccare i giochi aggiunti con LuaTools — uno attivo alla volta. + Cambia il backend utilizzato per sbloccare i giochi aggiunti con LuaTools. È attivo un solo backend alla volta. Cerca aggiornamenti ATTIVA CONSIGLIATO Steam verrà chiuso per poter modificare i suoi file, poi riavviato automaticamente. Tutti i giochi o i download in corso si interromperanno. Annulla Chiudi Steam e continua - Il caro vecchio SteamTools — creato dai cinesi, amato dal mondo. Lo sbloccatore che ha avviato l'intera tendenza (GreenLuma non esiste, shh). ⚠️ ATTUALMENTE NON FUNZIONA, USA BETTERSTEAMTOOLS - Remake da zero di SteamTools. Migliore di SteamTools praticamente in tutto. - SteamTools con alcune patch ingegnose — un sacco di correzioni e miglioramenti. Riporta lo Steam Cloud nativo tramite Google Drive / OneDrive! + Un fork open source di OpenSteamTools mantenuto attivamente dal team di LuaTools. Con correzioni e nuove funzionalità! Gestisci Impossibile scaricare o avviare CloudRedirect. - Build notturna di BetterSteamTools (OST) dai sorgenti più recenti. Le correzioni più recenti, ma potrebbe essere meno stabile. + Build Nightly di OpenSteamTools dai sorgenti più recenti. Le correzioni più recenti, ma potrebbe essere meno stabile. + Te la cavi da solo + Usa la tua soluzione di sblocco, non la toccheremo + Unlocker personalizzato SPERIMENTALE SUPPORTO CLOUDREDIRECT - Riporta il supporto nativo di Steam Cloud tramite provider di archiviazione di terze parti come Google Drive / OneDrive / Cloudflare R2 e altri. Al momento compatibile solo con BetterSteamTools Nightly. Non dimenticare di configurarlo facendo clic su "Gestisci" dopo averlo attivato, poi riavvia Steam! - Seleziona BetterSteamTools Nightly per usare CloudRedirect. + Riporta il supporto nativo di Steam Cloud tramite provider di archiviazione di terze parti come Google Drive / OneDrive / Cloudflare R2 e altri. Al momento compatibile solo con OpenSteamTools / BetterSteamTools. Non dimenticare di configurarlo facendo clic su "Gestisci" dopo averlo attivato, poi riavvia Steam! + Seleziona OpenSteamTools / BetterSteamTools per usare CloudRedirect. Attiva Disattiva Attivato. @@ -195,7 +201,7 @@ {0} è installato. Avvia Steam per usarlo. Installazione non riuscita Qualcosa è andato storto. - Trascina qui file .lua, .manifest o .zip + Trascina qui i file .lua, .manifest o .zip Sfoglia file… Scegli file .lua / .manifest / .zip Lua / manifest / zip|*.lua;*.manifest;*.zip|Tutti i file|*.* @@ -207,7 +213,6 @@ {0} manifest {0} installati in Steam. {0} file non riusciti — chiudi Steam e riprova. - Riavvia Steam per applicare. Cerca per nome o App ID... Caricamento… App ID: {0} @@ -277,11 +282,11 @@ Rimuovi file lua Rimuovere «{0}» (App ID {1})? -Questo elimina il suo file .lua da Steam\config\stplug-in. Riavvia Steam in seguito affinché la modifica abbia effetto. +Questo elimina il suo file .lua da Steam\config\stplug-in. Rimuovi file lua Rimuovere {0} file lua? -Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito affinché le modifiche abbiano effetto. +Questo elimina i file .lua da Steam\config\stplug-in. Rimozione non riuscita Impossibile eliminare il file: {0} @@ -289,7 +294,6 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a {1} Impossibile eliminare {0} file. Riavvia Steam - Riavviare Steam ora affinché le modifiche abbiano effetto? Impossibile trovare o avviare Steam. Imposta il percorso nelle Impostazioni. Fix Caricamento dei fix… @@ -297,7 +301,7 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Manifest Fix {0} fix - Impossibile caricare i fix — controlla la connessione. + Impossibile caricare i fix — controlla la tua connessione. Nessun fix disponibile per ora. Accedi con Discord per scaricare i fix. Accesso richiesto @@ -306,8 +310,7 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Installazione non riuscita Impossibile installare — chiudi Steam (o riavvialo) e riprova. Fix installato - Manifest di {0} installato — Steam si sta riavviando. - Manifest di {0} installato. Riavvia Steam per applicare. + Manifest di {0} installato. Gioco non trovato Installa prima {0} in Steam, poi applica il fix. Fix applicato parzialmente @@ -317,11 +320,11 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Impossibile applicare il fix Aggiornato {0} giochi con fix. - Diamoci da fare con i Lua™! - Inserisci un App ID, incolla un link Steam o cerca per nome del gioco + Mettiamoci a Luare™! + Inserisci un App ID, incolla un link Steam o cerca il nome del gioco Inserisci un App ID o cerca giochi... I più venduti - Novità + Nuove uscite Uscito il {0} Recupera Controllo… @@ -345,15 +348,14 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Accedi con Discord per vedere gli sblocchi dei DLC. Accedi con Discord per scaricare. Impossibile determinare il gioco base di questo DLC - Qualcosa è andato storto — controlla la connessione e riprova. - Download non riuscito — controlla la connessione e riprova. - Generazione non riuscita — controlla la connessione e riprova. + Qualcosa è andato storto — controlla la tua connessione e riprova. + Download non riuscito — controlla la tua connessione e riprova. Sostituire «{0}»? Nessuna modifica a depot/DLC — stesso contenuto. - Installazione annullata — i file esistenti sono invariati. + Installazione annullata — i file esistenti rimangono invariati. Impossibile installare {0} file — chiudi Steam (o usa Riavvia Steam) e riprova. - {0} aggiunto — lua + {1} manifest. Riavvia Steam per applicare. - {0} aggiunto — Steam recupererà i manifest. Riavvia Steam per applicare. + Aggiunto {0}: lua + {1} manifest. + Aggiunto {0}. Steam scaricherà i manifest. Apri su SteamDB CONDIVISO Questo sostituisce il lua installato. Controlla cosa cambia: @@ -374,14 +376,14 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Nessuna fonte disponibile trovata per questo gioco · tramite {0} Plugin LuaTools - Il plugin LuaTools per Steam! — Ora usa LuaLoader invece di Shittenium + Il plugin LuaTools per Steam! — Ora usa LuaLoader invece di Merdennium Stato Aggiornamento disponibile Versione installata Ultima versione Frontend Loader (winmm.dll) - Millennium rilevato — LuaTools funziona insieme a esso. L'installazione disattiva la copia di questo plugin propria di Millennium così che solo LuaLoader esegua l'iniezione. + Millennium rilevato — LuaTools funziona insieme a esso. L'installazione disattiva la copia di questo plugin di Millennium, in modo che solo LuaLoader effettui l'iniezione. LuaLoader si installa nella tua cartella di Steam. Questa app funge da backend: se la chiudi dalla traybar, il plugin smetterà di funzionare! Installa Aggiorna @@ -395,7 +397,7 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Non aggiornato sconosciuta offline - Impossibile raggiungere GitHub per controllare gli aggiornamenti. + Impossibile raggiungere GitHub per controllare nuovi aggiornamenti. Un altro programma sta già usando la porta 8080 — il ponte browser del plugin potrebbe non funzionare. Chiudi ciò che sta usando quella porta e reinstalla o riavvia Steam. Questa operazione chiuderà e riavvierà Steam per applicare il loader. Continuare? Riavvia Steam @@ -450,9 +452,9 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Eliminare questo preset? «{0}» verrà rimosso dai tuoi preset salvati. Questo non influisce sul gioco stesso. Non puoi eliminare il preset attualmente in uso. - Ora si usa «{0}». Riavvia Steam perché abbia effetto. + Ora si usa «{0}». Impossibile cambiare preset — il file lua potrebbe essere in uso. - Salvato. Riavvia Steam perché abbia effetto. + Salvato. Impossibile salvare — il file lua potrebbe essere in uso. Salvato come preset. Salva in «{0}» @@ -463,7 +465,7 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Attivo: la chiave di decrittazione di questo depot viene applicata. Disattivo: il depot non viene sbloccato affatto. (Commenta o decommenta la riga addappid.) Cerca depot per nome, ID o tipo… Nessun depot corrisponde alla ricerca. - Stai modificando un preset non applicato — le modifiche vengono salvate direttamente in esso e Steam continua a usare il preset applicato finché non premi Applica. + Stai modificando un preset non ancora applicato — le modifiche vengono salvate direttamente in esso e Steam continua a usare il preset applicato finché non premi Applica. Opzioni di avvio… Opzioni di avvio Lettura della cache delle app di Steam… @@ -479,8 +481,8 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Etichetta (mostrata nel menu di gioco di Steam) Sistema operativo Tipo - Solo sul ramo - Nome del ramo a cui questa opzione è limitata — ad esempio beta, default, development. Lascia vuoto per mostrarla su tutti i rami. + Solo sul branch + Nome del branch a cui questa opzione è limitata, ad esempio beta, default, development. Lascia vuoto per mostrarlo su ogni branch. Solo se possiedi il DLC (App ID) Percorso di installazione: {0} Applicare le modifiche chiude e riapre Steam. @@ -496,6 +498,136 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Opzioni di avvio applicate. Steam è stato riavviato. Impossibile applicare le opzioni di avvio. {0} Le opzioni di avvio sono state reimpostate - Steam ha sovrascritto le tue opzioni di avvio per {0} gioco/giochi. Riapplicarle chiude e riapre Steam. + Steam ha sovrascritto le tue opzioni di avvio per {0} gioco/i. Riapplicarle chiude e riapre Steam. Riapplica + Percorso di Steam non trovato. Impostalo nelle Impostazioni. + Impossibile raggiungere GitHub. Controlla la tua connessione e riprova. + Impossibile raggiungere il server di aggiornamento. Controlla la tua connessione e riprova. + Annullato. + Annullato dall'utente + Nella release manca il download previsto. + Nella release manca {0}. + Il download non ha superato la verifica (sha256 non corrispondente). + {0} non ha superato la verifica (sha256 non corrispondente). + Nel download manca: {0}. + Impossibile scrivere {0} file. Chiudi Steam e riprova. + Impossibile scrivere {0}. Chiudi Steam e riprova. + Impossibile aprire il download: {0} + Nessuna fonte disponibile per questo gioco. + Accesso scaduto. Nessuna risposta dal browser. + Scambio del token non riuscito ({0}): {1} + Lo scambio del token ha restituito una sessione vuota. + Non hai effettuato l'accesso. + Aggiornamento della sessione non riuscito ({0}). + L'aggiornamento della sessione ha restituito una sessione vuota. + Il link di download era vuoto. Riprova. + Download non riuscito ({0}). + Richiesta non riuscita ({0}) + Sessione scaduta. Accedi di nuovo. + La tua chiave Hubcap non è valida oppure è scaduta. + Hai raggiunto il limite giornaliero di Hubcap. + Nessun manifest Hubcap disponibile per questa app. + Download da Hubcap non riuscito ({0}). + Download + Download + Nessun download in corso. Dai, scarica qualcosa! + Coda + Cronologia + In coda + Download in corso + In attesa di te + Installazione + Completato + Non riuscito + Annullato + Annulla + Riprova + Rimuovi + Sposta su + Sposta giù + Cancella cronologia + Rivedi + {0} di {1} + {0} rimanenti + Azione richiesta + Interrotto alla chiusura dell'app. + Sblocco DLC + Installa prima il gioco per applicare un fix. + In pausa + Verifica… + Pausa + Riprendi + File del depot + Depot · {0} di {1} + Impossibile ottenere il downloader dei depot. + Nessuna chiave di decrittazione trovata per questo gioco. + Depot {0} non riuscito: {1} + Scaricati {0} depot in {1}. + Scarica + Seleziona i depot da scaricare + Scarica {0} depot ({1}) + Questo depot non dichiara alcuna versione da scaricare. + Seleziona tutto + Nessun depot selezionato + Scegli dove salvare i file del depot + Spazio su disco insufficiente: servono {0}, liberi solo {1}. + Salva in + Servono {0} · {1} liberi su {2} + Accedi per scaricare questo depot. + Accedi per scaricare i depot. + recupero manifest + 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. + 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. + 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. + 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 f3fe4cf..786e609 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の場所 変更… 開く @@ -148,16 +153,17 @@ ファイルを変更できるようにSteamを終了し、その後自動的に再起動します。実行中のゲームやダウンロードはすべて停止します。 キャンセル Steamを閉じて続行 - おなじみのSteamTools — 中国製で、世界中に愛されています。このトレンド全体を生み出したアンロッカーです(GreenLumaは存在しません、しー)。⚠️ 現在動作しません。OSTを使用してください - SteamToolsをゼロから作り直したリメイク版。ほぼあらゆる面でSteamToolsより優れています。 - 気の利いたパッチをいくつか加えたSteamTools — 大量の修正と改善が施されています。 Google Drive / OneDrive 経由でネイティブの Steam Cloud を復活させます! + OpenSteamTools のオープンソースフォーク。LuaTools チームが積極的にメンテナンスし、修正や新機能を追加しています! 管理 CloudRedirect をダウンロードまたは起動できませんでした。 - 最新ソースからビルドされた BetterSteamTools(OST)のナイトリー版。最新の修正が入りますが、安定性は低めの場合があります。 + 最新ソースからビルドされた OpenSteamTools のナイトリー版。最新の修正が入りますが、安定性は低めの場合があります。 + あとはお任せします + 自分のアンロック方法を使ってください。こちらは一切手を出しません + カスタムアンロッカー 実験的 CloudRedirect 対応 - Google Drive / OneDrive / Cloudflare R2 など、サードパーティのストレージを使ってネイティブの Steam Cloud サポートを復活させます。現在は BetterSteamTools Nightly のみ対応。有効化後は「管理」をクリックして設定し、Steam を再起動するのを忘れずに! - CloudRedirect を使うには BetterSteamTools Nightly を選択してください。 + Google Drive / OneDrive / Cloudflare R2 など、サードパーティのストレージを使ってネイティブの Steam Cloud サポートを復活させます。現在は OpenSteamTools / BetterSteamTools のみ対応。有効化後は「管理」をクリックして設定し、Steam を再起動するのを忘れずに! + CloudRedirect を使うには OpenSteamTools / BetterSteamTools を選択してください。 有効化 無効化 有効です。 @@ -207,7 +213,6 @@ {0} 個のマニフェスト {0} をSteamにインストールしました。 {0} 個のファイルが失敗しました — Steamを閉じて再試行してください。 - 適用するにはSteamを再起動してください。 名前またはApp IDで検索... 読み込み中… App ID: {0} @@ -277,11 +282,11 @@ luaファイルを削除 「{0}」(App ID {1})を削除しますか? -これにより Steam\config\stplug-in からその .lua ファイルが削除されます。変更を反映するには、その後Steamを再起動してください。 +Steam\config\stplug-in から .lua ファイルを削除します。 luaファイルを削除 - {0} 個のluaファイルを削除しますか? + {0} 件の lua ファイルを削除しますか? -これにより Steam\config\stplug-in から .lua ファイルが削除されます。変更を反映するには、その後Steamを再起動してください。 +Steam\config\stplug-in から .lua ファイルを削除します。 削除に失敗しました ファイルを削除できませんでした: {0} @@ -289,7 +294,6 @@ {1} {0} 個のファイルを削除できませんでした。 Steamを再起動 - 変更を反映するために今すぐSteamを再起動しますか? Steamが見つからないか起動できませんでした。設定で場所を指定してください。 修正 修正を読み込み中… @@ -306,8 +310,7 @@ インストール失敗 インストールできませんでした — Steamを閉じて(またはSteamを再起動して)もう一度お試しください。 修正をインストールしました - {0} のマニフェストをインストールしました — Steamを再起動しています。 - {0} のマニフェストをインストールしました。適用するにはSteamを再起動してください。 + {0} のマニフェストをインストールしました。 ゲームが見つかりません 先に {0} をSteamにインストールしてから、修正を適用してください。 修正が部分的に適用されました @@ -347,13 +350,12 @@ このDLCのベースゲームを特定できませんでした 問題が発生しました — 接続を確認してもう一度お試しください。 ダウンロードに失敗しました — 接続を確認してもう一度お試しください。 - 生成に失敗しました — 接続を確認してもう一度お試しください。 「{0}」を置き換えますか? デポ/DLCの変更なし — 同じ内容です。 インストールをキャンセルしました — 既存のファイルは変更されていません。 {0} 個のファイルをインストールできませんでした — Steamを閉じて(または「Steamを再起動」を使って)もう一度お試しください。 - {0} を追加しました — lua + {1} 個のマニフェスト。適用するにはSteamを再起動してください。 - {0} を追加しました — Steamがマニフェストを取得します。適用するにはSteamを再起動してください。 + {0} を追加しました:lua + マニフェスト {1} 件。 + {0} を追加しました。Steam がマニフェストを取得します。 SteamDBで開く 共有 これは、インストール済みのluaを置き換えます。変更内容を確認してください: @@ -450,9 +452,9 @@ このプリセットを削除しますか? 「{0}」を保存済みのプリセットから削除します。ゲーム自体には影響しません。 使用中のプリセットは削除できません。 - 「{0}」に切り替えました。反映するには Steam を再起動してください。 + 「{0}」を使用中です。 プリセットを切り替えられませんでした — lua ファイルが使用中の可能性があります。 - 保存しました。反映するには Steam を再起動してください。 + 保存しました。 保存できませんでした — lua ファイルが使用中の可能性があります。 プリセットとして保存しました。 「{0}」に保存 @@ -498,4 +500,134 @@ 起動オプションがリセットされました Steam が {0} 本のゲームの起動オプションを上書きしました。再適用すると Steam が終了して再度開きます。 再適用 + Steam の場所が見つかりません。設定で指定してください。 + GitHub に接続できませんでした。接続を確認して再試行してください。 + 更新サーバーに接続できませんでした。接続を確認して再試行してください。 + キャンセルしました。 + ユーザーによりキャンセルされました + リリースに必要なダウンロードファイルがありません。 + リリースに {0} がありません。 + ダウンロードの検証に失敗しました (sha256 不一致)。 + {0} の検証に失敗しました (sha256 不一致)。 + ダウンロードに不足があります: {0}。 + {0} 個のファイルを書き込めませんでした。Steam を終了して再試行してください。 + {0} を書き込めませんでした。Steam を終了して再試行してください。 + ダウンロードを開けませんでした: {0} + このゲームに利用できるソースがありません。 + サインインがタイムアウトしました。ブラウザーから応答がありません。 + トークン交換に失敗しました ({0}): {1} + トークン交換が空のセッションを返しました。 + サインインしていません。 + セッションの更新に失敗しました ({0})。 + セッションの更新が空のセッションを返しました。 + ダウンロードリンクが空でした。再試行してください。 + ダウンロードに失敗しました ({0})。 + リクエストに失敗しました ({0}) + セッションの有効期限が切れました。もう一度サインインしてください。 + Hubcap のキーが無効か期限切れです。 + Hubcap の 1 日の上限に達しました。 + このアプリ用の Hubcap マニフェストはありません。 + Hubcap からのダウンロードに失敗しました ({0})。 + ダウンロード + ダウンロード + 現在ダウンロードはありません。何かダウンロードしてみましょう! + キュー + 履歴 + 待機中 + ダウンロード中 + 確認待ち + インストール中 + 完了 + 失敗 + キャンセル済み + キャンセル + 再試行 + 削除 + 上へ + 下へ + 履歴を消去 + 確認 + {0} / {1} + 残り {0} + 操作が必要です + アプリ終了時に中断されました。 + DLC のアンロック + 修正を適用するには、先にゲームをインストールしてください。 + 一時停止中 + 検証中… + 一時停止 + 再開 + デポファイル + デポ · {1} 件中 {0} 件 + デポダウンローダーを取得できませんでした。 + このゲームの復号キーが見つかりません。 + デポ {0} が失敗しました: {1} + {0} 個のデポを {1} にダウンロードしました。 + ダウンロード + ダウンロードするデポを選択 + {0} 個のデポをダウンロード ({1}) + このデポにはダウンロードできるバージョンがありません。 + すべて選択 + デポが選択されていません + デポファイルの保存先を選択 + ディスク容量が足りません: {0} 必要ですが、空きは {1} だけです。 + 保存先 + {0} 必要 · {2} の空き {1} + サインインするとこのデポをダウンロードできます。 + デポのダウンロードにはサインインが必要です。 + manifest を取得中 + このデポの 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} 件すべてを削除しますか?ダウンロード済みのファイルには影響しません。 + この修正は適用済みです。別のものを入れるには元に戻してください。 + マイゲーム + あなたのゲームのうち {0} 本に修正があります。ほかはグリッドをスクロールしてご覧ください。 + lua がまだありません。ゲームを追加(またはインストール)すると表示されます。 + 元に戻す + キャンセル + 「{0}」を元に戻しますか?変更されたファイルはバックアップから復元され、修正が追加したファイルは削除されます。 + {0} の修正を元に戻しますか? + 修正を元に戻しました + {0} 件を復元し、追加された {1} 件を削除しました。 + 修正を元に戻せませんでした + この修正を適用した記録がありません。すでに元に戻されている可能性があります。 + 修正を安全に元に戻せません + この修正の適用後に {0} が変更されているため、復元するとその変更が上書きされます。別の修正を上に適用した場合は、先にそちらを元に戻してください。対象は {1} 個のファイルです。 + 修正は一部だけ元に戻りました + {0} 個のファイルを復元できませんでした。ゲームを閉じてやり直してください。 + このソースにはこのゲームがないか、接続できませんでした。 + マニフェストのソース + ​.json ファイルで宣言する追加ソース。データのみで、ファイルはマニフェストの取得先を示すだけです。中身が実行されることはありません。 + フォルダーを開く + ソースファイルはまだありません。 + ソース: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index 88fa6b1..defab91 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 위치 변경… 열기 @@ -148,16 +153,17 @@ 파일을 변경할 수 있도록 Steam을 종료한 후 자동으로 재시작합니다. 실행 중인 게임이나 다운로드는 모두 중지됩니다. 취소 Steam 닫고 계속 - 그 옛날의 SteamTools — 중국에서 만들어 전 세계가 사랑하는 도구입니다. 이 모든 유행을 시작한 잠금 해제 도구입니다(GreenLuma는 존재하지 않아요, 쉿). ⚠️ 현재 작동하지 않습니다. OST를 사용하세요 - SteamTools를 처음부터 새로 만든 리메이크. 거의 모든 면에서 SteamTools보다 뛰어납니다. - 몇 가지 기발한 패치를 더한 SteamTools — 수많은 수정과 개선이 포함되어 있습니다. Google Drive / OneDrive를 통해 기본 Steam Cloud를 되살립니다! + LuaTools 팀이 활발히 관리하는 OpenSteamTools의 오픈 소스 포크입니다. 각종 수정과 새로운 기능을 추가하고 있어요! 관리 CloudRedirect를 다운로드하거나 실행할 수 없습니다. - 최신 소스로 빌드한 BetterSteamTools(OST) 나이틀리 버전. 최신 수정 사항이 포함되지만 안정성은 낮을 수 있습니다. + 최신 소스로 빌드한 OpenSteamTools 나이틀리 버전. 최신 수정 사항이 포함되지만 안정성은 낮을 수 있습니다. + 이제부터는 직접 관리하세요 + 직접 준비한 언로커를 쓰세요. 저희는 건드리지 않습니다 + 커스텀 언로커 실험적 CloudRedirect 지원 - Google Drive / OneDrive / Cloudflare R2 등 서드파티 저장소 공급자를 통해 기본 Steam Cloud 지원을 되살립니다. 현재는 BetterSteamTools Nightly에서만 호환됩니다. 사용 설정 후 "관리"를 클릭해 구성한 다음 Steam을 다시 시작하는 것을 잊지 마세요! - CloudRedirect를 사용하려면 BetterSteamTools Nightly를 선택하세요. + Google Drive / OneDrive / Cloudflare R2 등 서드파티 저장소 공급자를 통해 기본 Steam Cloud 지원을 되살립니다. 현재는 OpenSteamTools / BetterSteamTools에서만 호환됩니다. 사용 설정 후 "관리"를 클릭해 구성한 다음 Steam을 다시 시작하는 것을 잊지 마세요! + CloudRedirect를 사용하려면 OpenSteamTools / BetterSteamTools를 선택하세요. 사용 사용 안 함 사용 중. @@ -207,7 +213,6 @@ 매니페스트 {0}개 {0} 을(를) Steam에 설치했습니다. {0}개 파일 실패 — Steam을 닫고 다시 시도하세요. - 적용하려면 Steam을 재시작하세요. 이름 또는 App ID로 검색... 로드 중… App ID: {0} @@ -275,13 +280,13 @@ 공유 디포 DLC {0} lua 파일 제거 - "{0}"(App ID {1})을(를) 제거할까요? + “{0}”(App ID {1})을(를) 제거할까요? -이 작업은 Steam\config\stplug-in에서 해당 .lua 파일을 삭제합니다. 변경 사항을 적용하려면 이후 Steam을 재시작하세요. +Steam\config\stplug-in에서 .lua 파일을 삭제합니다. lua 파일 제거 - {0}개의 lua 파일을 제거할까요? + lua 파일 {0}개를 제거할까요? -이 작업은 Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 변경 사항을 적용하려면 이후 Steam을 재시작하세요. +Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 제거 실패 파일을 삭제할 수 없습니다: {0} @@ -289,7 +294,6 @@ {1} {0}개의 파일을 삭제할 수 없습니다. Steam 재시작 - 변경 사항을 적용하기 위해 지금 Steam을 재시작할까요? Steam을 찾거나 시작할 수 없습니다. 설정에서 위치를 지정하세요. 수정 수정 로드 중… @@ -306,8 +310,7 @@ 설치 실패 설치할 수 없습니다 — Steam을 닫고(또는 Steam을 재시작하고) 다시 시도하세요. 수정 설치됨 - {0} 매니페스트가 설치되었습니다 — Steam을 재시작하는 중입니다. - {0} 매니페스트가 설치되었습니다. 적용하려면 Steam을 재시작하세요. + {0} 매니페스트가 설치되었습니다. 게임을 찾을 수 없음 먼저 Steam에 {0}을(를) 설치한 후 수정을 적용하세요. 수정이 부분적으로 적용됨 @@ -347,13 +350,12 @@ 이 DLC의 본편 게임을 확인할 수 없습니다 문제가 발생했습니다 — 연결을 확인하고 다시 시도하세요. 다운로드 실패 — 연결을 확인하고 다시 시도하세요. - 생성 실패 — 연결을 확인하고 다시 시도하세요. "{0}"을(를) 교체할까요? 디포/DLC 변경 없음 — 동일한 콘텐츠입니다. 설치가 취소되었습니다 — 기존 파일은 변경되지 않았습니다. {0}개의 파일을 설치할 수 없습니다 — Steam을 닫고(또는 'Steam 재시작'을 사용하고) 다시 시도하세요. - {0} 추가됨 — lua + 매니페스트 {1}개. 적용하려면 Steam을 재시작하세요. - {0} 추가됨 — Steam이 매니페스트를 가져옵니다. 적용하려면 Steam을 재시작하세요. + {0} 추가됨: lua + 매니페스트 {1}개. + {0} 추가됨. Steam이 매니페스트를 가져옵니다. SteamDB에서 열기 공유됨 설치된 lua를 교체합니다. 변경 사항을 검토하세요: @@ -450,9 +452,9 @@ 이 프리셋을 삭제할까요? '{0}'이(가) 저장된 프리셋에서 제거됩니다. 게임 자체에는 영향이 없습니다. 현재 사용 중인 프리셋은 삭제할 수 없습니다. - 이제 '{0}'을(를) 사용합니다. 적용하려면 Steam을 다시 시작하세요. + 이제 “{0}”을(를) 사용합니다. 프리셋을 전환하지 못했습니다 — lua 파일이 사용 중일 수 있습니다. - 저장했습니다. 적용하려면 Steam을 다시 시작하세요. + 저장되었습니다. 저장하지 못했습니다 — lua 파일이 사용 중일 수 있습니다. 프리셋으로 저장했습니다. '{0}'에 저장 @@ -498,4 +500,134 @@ 실행 옵션이 초기화되었습니다 Steam이 게임 {0}개의 실행 옵션을 덮어썼습니다. 다시 적용하면 Steam이 종료된 뒤 다시 열립니다. 다시 적용 + Steam 위치를 찾을 수 없습니다. 설정에서 지정하세요. + GitHub에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요. + 업데이트 서버에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요. + 취소되었습니다. + 사용자가 취소함 + 릴리스에 필요한 다운로드 파일이 없습니다. + 릴리스에 {0}이(가) 없습니다. + 다운로드가 검증에 실패했습니다 (sha256 불일치). + {0}이(가) 검증에 실패했습니다 (sha256 불일치). + 다운로드에 누락됨: {0}. + {0}개 파일을 쓰지 못했습니다. Steam을 종료하고 다시 시도하세요. + {0}을(를) 쓰지 못했습니다. Steam을 종료하고 다시 시도하세요. + 다운로드를 열지 못했습니다: {0} + 이 게임에 사용할 수 있는 소스가 없습니다. + 로그인 시간이 초과되었습니다. 브라우저에서 응답이 없습니다. + 토큰 교환 실패 ({0}): {1} + 토큰 교환이 빈 세션을 반환했습니다. + 로그인되어 있지 않습니다. + 세션 갱신 실패 ({0}). + 세션 갱신이 빈 세션을 반환했습니다. + 다운로드 링크가 비어 있습니다. 다시 시도하세요. + 다운로드 실패 ({0}). + 요청 실패 ({0}) + 세션이 만료되었습니다. 다시 로그인하세요. + Hubcap 키가 유효하지 않거나 만료되었습니다. + 일일 Hubcap 한도에 도달했습니다. + 이 앱에 사용할 수 있는 Hubcap 매니페스트가 없습니다. + Hubcap 다운로드 실패 ({0}). + 다운로드 + 다운로드 + 현재 다운로드가 없습니다. 뭐라도 다운로드해 보세요! + 대기열 + 기록 + 대기 중 + 다운로드 중 + 확인 대기 중 + 설치 중 + 완료 + 실패 + 취소됨 + 취소 + 다시 시도 + 제거 + 위로 + 아래로 + 기록 지우기 + 확인 + {0} / {1} + {0} 남음 + 조치 필요 + 앱이 종료되어 중단되었습니다. + DLC 잠금 해제 + 픽스를 적용하려면 먼저 게임을 설치하세요. + 일시중지됨 + 확인 중… + 일시중지 + 재개 + 디포 파일 + 디포 · {1}개 중 {0}개 + 디포 다운로더를 가져오지 못했습니다. + 이 게임의 복호화 키를 찾을 수 없습니다. + 디포 {0} 실패: {1} + 디포 {0}개를 {1}에 다운로드했습니다. + 다운로드 + 다운로드할 디포 선택 + 디포 {0}개 다운로드 ({1}) + 이 디포에는 다운로드할 버전이 없습니다. + 전체 선택 + 선택된 디포 없음 + 디포 파일을 저장할 위치 선택 + 디스크 공간이 부족합니다: {0} 필요, 여유 공간은 {1}뿐입니다. + 저장 위치 + {0} 필요 · {2} 여유 공간 {1} + 로그인하면 이 디포를 다운로드할 수 있습니다. + 디포를 다운로드하려면 로그인하세요. + manifest 가져오는 중 + 이 디포의 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}개 항목을 모두 제거할까요? 내려받은 파일은 영향을 받지 않습니다. + 이 수정은 이미 적용되어 있습니다. 다른 것을 설치하려면 되돌리세요. + 내 게임 + 보유한 게임 중 {0}개에 수정이 있습니다. 나머지는 목록을 스크롤해 확인하세요. + 아직 추가된 lua가 없습니다. 게임을 추가하거나 설치하면 여기에 표시됩니다. + 되돌리기 + 취소 + "{0}"을(를) 되돌릴까요? 변경된 파일은 백업에서 복원되고 수정이 추가한 파일은 삭제됩니다. + {0}의 수정을 되돌릴까요? + 수정을 되돌렸습니다 + {0}개 복원, 추가된 {1}개 삭제됨. + 수정을 되돌리지 못했습니다 + 이 수정이 적용된 기록이 없습니다. 이미 되돌렸을 수 있습니다. + 픽스를 안전하게 되돌릴 수 없음 + 이 픽스를 적용한 뒤 {0} 이(가) 변경되어, 복원하면 해당 변경 사항을 덮어씁니다. 다른 픽스를 위에 적용했다면 그것부터 되돌리세요. {1}개 파일이 영향을 받습니다. + 수정이 일부만 되돌려졌습니다 + {0}개 파일을 복원하지 못했습니다. 게임을 닫고 다시 시도하세요. + 이 소스에는 이 게임이 없거나 연결할 수 없습니다. + 매니페스트 소스 + ​.json 파일로 선언하는 추가 소스. 데이터일 뿐이며, 파일은 매니페스트를 어디서 가져올지 알려줄 뿐 그 안의 어떤 것도 실행되지 않습니다. + 폴더 열기 + 아직 소스 파일이 없습니다. + 소스: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 24e37e3..63fafce 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 @@ -148,16 +153,17 @@ Steam lukkes slik at filene kan endres, og startes deretter automatisk på nytt. Alle spill eller nedlastinger som kjører, stopper. Avbryt Lukk Steam og fortsett - Gode gamle SteamTools — laget av kineserne, elsket av verden. Opplåseren som startet hele trenden (GreenLuma finnes ikke, hysj). ⚠️ FUNGERER IKKE FOR ØYEBLIKKET, BRUK BETTERSTEAMTOOLS - Gjenoppbygd fra bunnen av SteamTools. Bedre enn SteamTools på nesten alle måter. - SteamTools med noen smarte patcher — en haug med fikser og forbedringer. Bringer tilbake innebygd Steam Cloud via Google Drive / OneDrive! + En åpen kildekode-fork av OpenSteamTools som vedlikeholdes aktivt av LuaTools-teamet. Med feilrettinger og nye funksjoner! Administrer Kunne ikke laste ned eller starte CloudRedirect. - Nattlig bygg av BetterSteamTools (OST) fra nyeste kildekode. De nyeste rettelsene, men kan være mindre stabil. + Nattlig bygg av OpenSteamTools fra nyeste kildekode. De nyeste rettelsene, men kan være mindre stabil. + Nå er du på egen hånd + Bruk din egen unlock-løsning, vi rører den ikke + Egen unlocker EKSPERIMENTELL CLOUDREDIRECT-STØTTE - Bringer tilbake innebygd Steam Cloud-støtte via tredjeparts lagringsleverandører som Google Drive / OneDrive / Cloudflare R2 med flere. Foreløpig bare kompatibel med BetterSteamTools Nightly. Ikke glem å konfigurere den ved å klikke "Administrer" etter aktivering, og start deretter Steam på nytt! - Velg BetterSteamTools Nightly for å bruke CloudRedirect. + Bringer tilbake innebygd Steam Cloud-støtte via tredjeparts lagringsleverandører som Google Drive / OneDrive / Cloudflare R2 med flere. Foreløpig bare kompatibel med OpenSteamTools / BetterSteamTools. Ikke glem å konfigurere den ved å klikke "Administrer" etter aktivering, og start deretter Steam på nytt! + Velg OpenSteamTools / BetterSteamTools for å bruke CloudRedirect. Aktiver Deaktiver Aktivert. @@ -207,7 +213,6 @@ {0} manifest(er) {0} installert i Steam. {0} fil(er) mislyktes — lukk Steam og prøv igjen. - Start Steam på nytt for å ta i bruk. Søk etter navn eller App ID... Laster… App ID: {0} @@ -275,13 +280,13 @@ Delt depot DLC {0} Fjern lua-fil - Fjerne "{0}" (App ID {1})? + Fjern «{0}» (App ID {1})? -Dette sletter .lua-filen fra Steam\config\stplug-in. Start Steam på nytt etterpå for at endringen skal tre i kraft. +Dette sletter .lua-filen fra Steam\config\stplug-in. Fjern lua-filer - Fjerne {0} lua-filer? + Fjern {0} lua-filer? -Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etterpå for at endringene skal tre i kraft. +Dette sletter .lua-filene fra Steam\config\stplug-in. Fjerning mislyktes Kunne ikke slette filen: {0} @@ -289,7 +294,6 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter {1} {0} filer kunne ikke slettes. Start Steam på nytt - Starte Steam på nytt nå slik at endringene trer i kraft? Kunne ikke finne eller starte Steam. Angi plasseringen i Innstillinger. Fikser Laster fikser… @@ -306,8 +310,7 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter Installasjonen mislyktes Kunne ikke installere — lukk Steam (eller start Steam på nytt) og prøv igjen. Fiks installert - Manifest for {0} installert — Steam starter på nytt. - Manifest for {0} installert. Start Steam på nytt for å ta i bruk. + Manifest for {0} installert. Spill ikke funnet Installer {0} i Steam først, og bruk deretter fiksen. Fiks delvis brukt @@ -347,13 +350,12 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter 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. Kunne ikke installere {0} fil(er) — lukk Steam (eller bruk Start Steam på nytt) og prøv igjen. - {0} lagt til — lua + {1} manifest(er). Start Steam på nytt for å ta i bruk. - {0} lagt til — Steam henter manifestene. Start Steam på nytt for å ta i bruk. + La til {0}: lua + {1} manifest(er). + La til {0}. Steam henter manifester. Åpne på SteamDB DELT Dette erstatter den installerte lua-en. Se gjennom hva som endres: @@ -450,9 +452,9 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter Slette denne forhåndsinnstillingen? «{0}» fjernes fra de lagrede forhåndsinnstillingene dine. Dette påvirker ikke selve spillet. Forhåndsinnstillingen som er i bruk, kan ikke slettes. - Bruker nå «{0}». Start Steam på nytt for at det skal tre i kraft. + Bruker nå «{0}». Kunne ikke bytte forhåndsinnstilling — lua-filen kan være i bruk. - Lagret. Start Steam på nytt for at det skal tre i kraft. + Lagret. Kunne ikke lagre — lua-filen kan være i bruk. Lagret som forhåndsinnstilling. Lagre i «{0}» @@ -498,4 +500,134 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter Oppstartsvalgene ble tilbakestilt Steam har overskrevet oppstartsvalgene dine for {0} spill. Å ta dem i bruk på nytt lukker og åpner Steam. Ta i bruk på nytt + Fant ikke Steam-plasseringen. Angi den i Innstillinger. + Kunne ikke nå GitHub. Sjekk tilkoblingen og prøv igjen. + Kunne ikke nå oppdateringsserveren. Sjekk tilkoblingen og prøv igjen. + Avbrutt. + Avbrutt av brukeren + Den forventede nedlastingen mangler i utgivelsen. + {0} mangler i utgivelsen. + Nedlastingen besto ikke verifiseringen (sha256 stemmer ikke). + {0} besto ikke verifiseringen (sha256 stemmer ikke). + Mangler i nedlastingen: {0}. + Kunne ikke skrive {0} fil(er). Lukk Steam og prøv igjen. + Kunne ikke skrive {0}. Lukk Steam og prøv igjen. + Kunne ikke åpne nedlastingen: {0} + Ingen tilgjengelig kilde for dette spillet. + Tidsavbrudd ved innlogging. Ingen svar fra nettleseren. + Tokenutveksling mislyktes ({0}): {1} + Tokenutvekslingen returnerte en tom økt. + Ikke logget inn. + Fornyelse av økten mislyktes ({0}). + Fornyelsen av økten returnerte en tom økt. + Nedlastingslenken var tom. Prøv igjen. + Nedlastingen mislyktes ({0}). + Forespørselen mislyktes ({0}) + Økten er utløpt. Logg inn på nytt. + Hubcap-nøkkelen din er ugyldig eller utløpt. + Du har nådd den daglige Hubcap-grensen. + Det finnes ingen Hubcap-manifest for denne appen. + Hubcap-nedlastingen mislyktes ({0}). + Nedlastinger + Nedlastinger + Ingen nedlastinger akkurat nå. Kom i gang – last ned noe! + Kø + Historikk + I kø + Laster ned + Venter på deg + Installerer + Ferdig + Mislyktes + Avbrutt + Avbryt + Prøv igjen + Fjern + Flytt opp + Flytt ned + Tøm historikk + Se over + {0} av {1} + {0} igjen + Krever handling + Avbrutt da appen ble lukket. + DLC-opplåsing + Installer spillet først for å bruke en fiks. + Satt på pause + Verifiserer… + Pause + Fortsett + Depotfiler + Depoter · {0} av {1} + Fikk ikke tak i depot-nedlasteren. + Fant ingen dekrypteringsnøkler for dette spillet. + Depot {0} mislyktes: {1} + Lastet ned {0} depoter til {1}. + Last ned + Velg depoter å laste ned + Last ned {0} depoter ({1}) + Dette depotet oppgir ingen versjon å laste ned. + Velg alle + Ingen depoter valgt + Velg hvor depotfilene skal lagres + Ikke nok diskplass: trenger {0}, bare {1} ledig. + Lagre i + Trenger {0} · {1} ledig på {2} + Logg inn for å laste ned dette depotet. + Logg inn for å laste ned depoter. + henter manifest + 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. + 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. + 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. + 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 c8585b9..8b7b60f 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 @@ -148,16 +153,17 @@ Steam wordt gesloten zodat de bestanden kunnen worden gewijzigd en daarna automatisch opnieuw gestart. Alle actieve games of downloads worden gestopt. Annuleren Steam sluiten en doorgaan - De goede oude SteamTools — gemaakt door de Chinezen, geliefd door de wereld. De ontgrendelaar die de hele trend startte (GreenLuma bestaat niet, sst). ⚠️ WERKT MOMENTEEL NIET, GEBRUIK BETTERSTEAMTOOLS - Volledig nieuw opgebouwde remake van SteamTools. In vrijwel elk opzicht beter dan SteamTools. - SteamTools met een paar handige patches — een hoop fixes en verbeteringen. Brengt native Steam Cloud terug via Google Drive / OneDrive! + Een opensource-fork van OpenSteamTools, actief onderhouden door het LuaTools-team. Met fixes en nieuwe functies! Beheren Kan CloudRedirect niet downloaden of starten. - Nightly-build van BetterSteamTools (OST) uit de nieuwste broncode. De nieuwste fixes, maar mogelijk minder stabiel. + Nightly-build van OpenSteamTools uit de nieuwste broncode. De nieuwste fixes, maar mogelijk minder stabiel. + Je staat er alleen voor + Gebruik je eigen unlock-oplossing, wij blijven ervan af + Eigen unlocker EXPERIMENTEEL CLOUDREDIRECT-ONDERSTEUNING - Brengt native Steam Cloud-ondersteuning terug via externe opslagproviders zoals Google Drive / OneDrive / Cloudflare R2 en meer. Momenteel alleen compatibel met BetterSteamTools Nightly. Vergeet niet om het na het inschakelen te configureren via "Beheren" en start daarna Steam opnieuw op! - Selecteer BetterSteamTools Nightly om CloudRedirect te gebruiken. + Brengt native Steam Cloud-ondersteuning terug via externe opslagproviders zoals Google Drive / OneDrive / Cloudflare R2 en meer. Momenteel alleen compatibel met OpenSteamTools / BetterSteamTools. Vergeet niet om het na het inschakelen te configureren via "Beheren" en start daarna Steam opnieuw op! + Selecteer OpenSteamTools / BetterSteamTools om CloudRedirect te gebruiken. Inschakelen Uitschakelen Ingeschakeld. @@ -207,7 +213,6 @@ {0} manifest(en) {0} geïnstalleerd in Steam. {0} bestand(en) mislukt — sluit Steam en probeer opnieuw. - Herstart Steam om toe te passen. Zoeken op naam of App ID... Laden… App ID: {0} @@ -275,13 +280,13 @@ Gedeeld depot DLC {0} Lua-bestand verwijderen - "{0}" (App ID {1}) verwijderen? + “{0}” (App ID {1}) verwijderen? -Dit verwijdert het .lua-bestand uit Steam\config\stplug-in. Herstart Steam daarna om de wijziging door te voeren. +Dit verwijdert het .lua-bestand uit Steam\config\stplug-in. Lua-bestanden verwijderen {0} lua-bestanden verwijderen? -Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daarna om de wijzigingen door te voeren. +Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Verwijderen mislukt Kon het bestand niet verwijderen: {0} @@ -289,7 +294,6 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar {1} {0} bestanden konden niet worden verwijderd. Steam herstarten - Steam nu herstarten zodat de wijzigingen worden doorgevoerd? Kon Steam niet vinden of starten. Stel de locatie in bij Instellingen. Fixes Fixes laden… @@ -306,8 +310,7 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar Installatie mislukt Kon niet installeren — sluit Steam (of herstart Steam) en probeer opnieuw. Fix geïnstalleerd - Manifest van {0} geïnstalleerd — Steam wordt opnieuw gestart. - Manifest van {0} geïnstalleerd. Herstart Steam om toe te passen. + Manifest van {0} geïnstalleerd. Game niet gevonden Installeer {0} eerst in Steam en pas dan de fix toe. Fix gedeeltelijk toegepast @@ -347,13 +350,12 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar 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. Kon {0} bestand(en) niet installeren — sluit Steam (of gebruik Steam herstarten) en probeer opnieuw. - {0} toegevoegd — lua + {1} manifest(en). Herstart Steam om toe te passen. - {0} toegevoegd — Steam haalt de manifesten op. Herstart Steam om toe te passen. + {0} toegevoegd: lua + {1} manifest(en). + {0} toegevoegd. Steam haalt de manifests op. Openen op SteamDB GEDEELD Dit vervangt de geïnstalleerde lua. Bekijk wat er verandert: @@ -450,9 +452,9 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar Deze voorinstelling verwijderen? ‘{0}’ wordt uit je opgeslagen voorinstellingen verwijderd. Dit heeft geen invloed op de game zelf. De voorinstelling die in gebruik is, kan niet worden verwijderd. - ‘{0}’ wordt nu gebruikt. Start Steam opnieuw op om het door te voeren. + Gebruikt nu “{0}”. Kon niet van voorinstelling wisselen — het lua-bestand is mogelijk in gebruik. - Opgeslagen. Start Steam opnieuw op om het door te voeren. + Opgeslagen. Opslaan mislukt — het lua-bestand is mogelijk in gebruik. Opgeslagen als voorinstelling. Opslaan in ‘{0}’ @@ -498,4 +500,134 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar Opstartopties zijn teruggezet Steam heeft je opstartopties voor {0} game(s) overschreven. Opnieuw toepassen sluit Steam af en opent het weer. Opnieuw toepassen + Steam-locatie niet gevonden. Stel deze in bij Instellingen. + Kon GitHub niet bereiken. Controleer je verbinding en probeer het opnieuw. + Kon de updateserver niet bereiken. Controleer je verbinding en probeer het opnieuw. + Geannuleerd. + Geannuleerd door gebruiker + De verwachte download ontbreekt in de release. + {0} ontbreekt in de release. + De download is niet geverifieerd (sha256 komt niet overeen). + {0} is niet geverifieerd (sha256 komt niet overeen). + Ontbreekt in de download: {0}. + Kon {0} bestand(en) niet schrijven. Sluit Steam en probeer het opnieuw. + Kon {0} niet schrijven. Sluit Steam en probeer het opnieuw. + Kon de download niet openen: {0} + Geen bron beschikbaar voor dit spel. + Time-out bij aanmelden. Geen reactie van de browser. + Tokenuitwisseling mislukt ({0}): {1} + De tokenuitwisseling gaf een lege sessie terug. + Niet aangemeld. + Vernieuwen van de sessie mislukt ({0}). + Het vernieuwen van de sessie gaf een lege sessie terug. + De downloadlink was leeg. Probeer het opnieuw. + Download mislukt ({0}). + Verzoek mislukt ({0}) + Sessie verlopen. Meld je opnieuw aan. + Je Hubcap-sleutel is ongeldig of verlopen. + Je dagelijkse Hubcap-limiet is bereikt. + Er is geen Hubcap-manifest voor deze app. + Hubcap-download mislukt ({0}). + Downloads + Downloads + Geen downloads bezig. Kom op, download iets! + Wachtrij + Geschiedenis + In wachtrij + Bezig met downloaden + Wacht op jou + Bezig met installeren + Klaar + Mislukt + Geannuleerd + Annuleren + Opnieuw proberen + Verwijderen + Omhoog + Omlaag + Geschiedenis wissen + Bekijken + {0} van {1} + Nog {0} + Actie vereist + Onderbroken toen de app werd afgesloten. + DLC-ontgrendeling + Installeer eerst de game om een fix toe te passen. + Gepauzeerd + Verifiëren… + Pauzeren + Hervatten + Depotbestanden + Depots · {0} van {1} + Kon de depot-downloader niet ophalen. + Geen ontsleutelsleutels gevonden voor dit spel. + Depot {0} mislukt: {1} + {0} depots gedownload naar {1}. + Downloaden + Kies depots om te downloaden + {0} depots downloaden ({1}) + Dit depot geeft geen te downloaden versie op. + Alles selecteren + Geen depots geselecteerd + Kies waar de depotbestanden worden opgeslagen + Niet genoeg schijfruimte: vereist {0}, slechts {1} vrij. + Opslaan in + Vereist {0} · {1} vrij op {2} + Meld je aan om dit depot te downloaden. + Meld je aan om depots te downloaden. + manifest ophalen + 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. + 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 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. + 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 764bfdc..a223ac9 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 @@ -148,16 +153,17 @@ Steam zostanie zamknięty, aby można było zmienić jego pliki, a następnie automatycznie uruchomiony ponownie. Wszystkie uruchomione gry i pobierania zostaną zatrzymane. Anuluj Zamknij Steam i kontynuuj - Stary dobry SteamTools — stworzony przez Chińczyków, kochany przez świat. Odblokowywacz, który zapoczątkował cały trend (GreenLuma nie istnieje, ciii). ⚠️ OBECNIE NIE DZIAŁA, UŻYJ BETTERSTEAMTOOLS - Zbudowany od podstaw remake SteamTools. Lepszy od SteamTools praktycznie pod każdym względem. - SteamTools z kilkoma sprytnymi łatkami — mnóstwo poprawek i usprawnień. Przywraca natywny Steam Cloud przez Google Drive / OneDrive! + Otwartoźródłowy fork OpenSteamTools aktywnie rozwijany przez zespół LuaTools. Z poprawkami i nowymi funkcjami! Zarządzaj Nie udało się pobrać ani uruchomić CloudRedirect. - Nocna kompilacja BetterSteamTools (OST) z najnowszego źródła. Najnowsze poprawki, ale może być mniej stabilna. + Nocna kompilacja OpenSteamTools z najnowszego źródła. Najnowsze poprawki, ale może być mniej stabilna. + Radzisz sobie sam + Użyj własnego rozwiązania do odblokowania, nie będziemy w nim grzebać + Własny unlocker EKSPERYMENTALNE OBSŁUGA CLOUDREDIRECT - Przywraca natywną obsługę Steam Cloud za pośrednictwem zewnętrznych dostawców pamięci, takich jak Google Drive / OneDrive / Cloudflare R2 i inne. Obecnie zgodne tylko z BetterSteamTools Nightly. Nie zapomnij skonfigurować go, klikając "Zarządzaj" po włączeniu, a następnie uruchom ponownie Steam! - Wybierz BetterSteamTools Nightly, aby użyć CloudRedirect. + Przywraca natywną obsługę Steam Cloud za pośrednictwem zewnętrznych dostawców pamięci, takich jak Google Drive / OneDrive / Cloudflare R2 i inne. Obecnie zgodne tylko z OpenSteamTools / BetterSteamTools. Nie zapomnij skonfigurować go, klikając "Zarządzaj" po włączeniu, a następnie uruchom ponownie Steam! + Wybierz OpenSteamTools / BetterSteamTools, aby użyć CloudRedirect. Włącz Wyłącz Włączone. @@ -207,7 +213,6 @@ {0} manifest(ów) Zainstalowano {0} w Steam. {0} plik(ów) nie powiodło się — zamknij Steam i spróbuj ponownie. - Uruchom ponownie Steam, aby zastosować. Szukaj według nazwy lub App ID... Ładowanie… App ID: {0} @@ -277,11 +282,11 @@ Usuń plik lua Usunąć „{0}” (App ID {1})? -Spowoduje to usunięcie jego pliku .lua z Steam\config\stplug-in. Następnie uruchom ponownie Steam, aby zmiana zaczęła obowiązywać. +Spowoduje to usunięcie pliku .lua z Steam\config\stplug-in. Usuń pliki lua Usunąć {0} plików lua? -Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie uruchom ponownie Steam, aby zmiany zaczęły obowiązywać. +Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Usuwanie nie powiodło się Nie udało się usunąć pliku: {0} @@ -289,7 +294,6 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho {1} Nie udało się usunąć {0} plików. Uruchom ponownie Steam - Uruchomić ponownie Steam teraz, aby zmiany zaczęły obowiązywać? Nie udało się znaleźć ani uruchomić Steam. Ustaw jego lokalizację w Ustawieniach. Poprawki Ładowanie poprawek… @@ -306,8 +310,7 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho Instalacja nie powiodła się Nie udało się zainstalować — zamknij Steam (lub uruchom go ponownie) i spróbuj ponownie. Zainstalowano poprawkę - Manifest {0} zainstalowany — Steam uruchamia się ponownie. - Manifest {0} zainstalowany. Uruchom ponownie Steam, aby zastosować. + Zainstalowano manifest {0}. Nie znaleziono gry Najpierw zainstaluj {0} w Steam, a następnie zastosuj poprawkę. Poprawka zastosowana częściowo @@ -347,13 +350,12 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho 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. Nie udało się zainstalować {0} plik(ów) — zamknij Steam (lub użyj Uruchom ponownie Steam) i spróbuj ponownie. - Dodano {0} — lua + {1} manifest(ów). Uruchom ponownie Steam, aby zastosować. - Dodano {0} — Steam pobierze manifesty. Uruchom ponownie Steam, aby zastosować. + Dodano {0}: lua + {1} manifest(ów). + Dodano {0}. Steam pobierze manifesty. Otwórz w SteamDB WSPÓŁDZIELONY To zastąpi zainstalowany lua. Sprawdź, co się zmienia: @@ -450,9 +452,9 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho Usunąć to ustawienie? „{0}” zostanie usunięte z zapisanych ustawień. Nie wpływa to na samą grę. Nie można usunąć ustawienia, które jest w użyciu. - Używana jest teraz „{0}”. Uruchom Steam ponownie, aby zmiana zadziałała. + Używana jest teraz „{0}”. Nie udało się przełączyć ustawienia — plik lua może być w użyciu. - Zapisano. Uruchom Steam ponownie, aby zmiana zadziałała. + Zapisano. Nie udało się zapisać — plik lua może być w użyciu. Zapisano jako ustawienie. Zapisz w „{0}” @@ -498,4 +500,134 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho Opcje uruchamiania zostały zresetowane Steam nadpisał twoje opcje uruchamiania dla {0} gry/gier. Ponowne zastosowanie zamyka i otwiera Steam. Zastosuj ponownie + Nie znaleziono lokalizacji Steam. Ustaw ją w Ustawieniach. + Nie udało się połączyć z GitHubem. Sprawdź połączenie i spróbuj ponownie. + Nie udało się połączyć z serwerem aktualizacji. Sprawdź połączenie i spróbuj ponownie. + Anulowano. + Anulowane przez użytkownika + W wydaniu brakuje oczekiwanego pliku do pobrania. + W wydaniu brakuje {0}. + Pobieranie nie przeszło weryfikacji (niezgodna suma sha256). + {0} nie przeszedł weryfikacji (niezgodna suma sha256). + Brakuje w pobranych plikach: {0}. + Nie udało się zapisać {0} plik(ów). Zamknij Steam i spróbuj ponownie. + Nie udało się zapisać {0}. Zamknij Steam i spróbuj ponownie. + Nie udało się otworzyć pobranego pliku: {0} + Brak dostępnego źródła dla tej gry. + Upłynął limit czasu logowania. Brak odpowiedzi z przeglądarki. + Wymiana tokenu nie powiodła się ({0}): {1} + Wymiana tokenu zwróciła pustą sesję. + Nie zalogowano. + Odświeżenie sesji nie powiodło się ({0}). + Odświeżenie sesji zwróciło pustą sesję. + Link do pobrania był pusty. Spróbuj ponownie. + Pobieranie nie powiodło się ({0}). + Żądanie nie powiodło się ({0}) + Sesja wygasła. Zaloguj się ponownie. + Twój klucz Hubcap jest nieprawidłowy lub wygasł. + Osiągnięto dzienny limit Hubcap. + Brak manifestu Hubcap dla tej aplikacji. + Pobieranie z Hubcap nie powiodło się ({0}). + Pobieranie + Pobieranie + Nic się teraz nie pobiera. No dawaj, pobierz coś! + Kolejka + Historia + W kolejce + Pobieranie + Czeka na Ciebie + Instalowanie + Gotowe + Niepowodzenie + Anulowano + Anuluj + Ponów + Usuń + W górę + W dół + Wyczyść historię + Sprawdź + {0} z {1} + Pozostało {0} + Wymaga działania + Przerwano przy zamykaniu aplikacji. + Odblokowanie DLC + Najpierw zainstaluj grę, aby zastosować poprawkę. + Wstrzymano + Weryfikowanie… + Wstrzymaj + Wznów + Pliki depotu + Depoty · {0} z {1} + Nie udało się pobrać pobieracza depotów. + Nie znaleziono kluczy deszyfrujących dla tej gry. + Depot {0} nie powiódł się: {1} + Pobrano {0} depotów do {1}. + Pobierz + Wybierz depoty do pobrania + Pobierz {0} depotów ({1}) + Ten depot nie podaje wersji do pobrania. + Zaznacz wszystko + Nie wybrano depotów + Wybierz, gdzie zapisać pliki depotu + Za mało miejsca na dysku: potrzeba {0}, wolne tylko {1}. + Zapisz w + Potrzeba {0} · wolne {1} na {2} + Zaloguj się, aby pobrać ten depot. + Zaloguj się, aby pobierać depoty. + pobieranie manifestu + 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. + 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. + 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. + 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 38ed6a0..63d20d9 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 @@ -148,16 +153,17 @@ O Steam será fechado para que seus arquivos possam ser alterados e, em seguida, reiniciado automaticamente. Todos os jogos ou downloads em andamento serão interrompidos. Cancelar Fechar Steam e continuar - O bom e velho SteamTools — feito pelos chineses, amado pelo mundo. O desbloqueador que iniciou toda a tendência (GreenLuma não existe, shh). ⚠️ ATUALMENTE NÃO FUNCIONA, USE O BETTERSTEAMTOOLS - Remake do zero do SteamTools. Melhor que o SteamTools em praticamente tudo. - SteamTools com alguns patches engenhosos — uma tonelada de correções e melhorias. Traz de volta o Steam Cloud nativo via Google Drive / OneDrive! + Um fork de código aberto do OpenSteamTools mantido ativamente pela equipe do LuaTools. Trazendo correções e novos recursos! Gerenciar Não foi possível baixar ou iniciar o CloudRedirect. - Build noturno do BetterSteamTools (OST) a partir do código mais recente. As correções mais recentes, mas pode ser menos estável. + Build noturno do OpenSteamTools a partir do código mais recente. As correções mais recentes, mas pode ser menos estável. + Agora é com você + Use sua própria solução de desbloqueio, não vamos mexer nela + Desbloqueador personalizado EXPERIMENTAL SUPORTE A CLOUDREDIRECT - Traz de volta o suporte nativo ao Steam Cloud por meio de provedores de armazenamento de terceiros como Google Drive / OneDrive / Cloudflare R2 e mais. No momento, compatível apenas com o BetterSteamTools Nightly. Não esqueça de configurá-lo clicando em "Gerenciar" após ativar e, em seguida, reinicie o Steam! - Selecione o BetterSteamTools Nightly para usar o CloudRedirect. + Traz de volta o suporte nativo ao Steam Cloud por meio de provedores de armazenamento de terceiros como Google Drive / OneDrive / Cloudflare R2 e mais. No momento, compatível apenas com o OpenSteamTools / BetterSteamTools. Não esqueça de configurá-lo clicando em "Gerenciar" após ativar e, em seguida, reinicie o Steam! + Selecione o OpenSteamTools / BetterSteamTools para usar o CloudRedirect. Ativar Desativar Ativado. @@ -207,7 +213,6 @@ {0} manifesto(s) {0} instalado(s) no Steam. {0} arquivo(s) falharam — feche o Steam e tente novamente. - Reinicie o Steam para aplicar. Pesquisar por nome ou App ID... Carregando… App ID: {0} @@ -275,13 +280,13 @@ Depósito compartilhado DLC {0} Remover arquivo lua - Remover "{0}" (App ID {1})? + Remover “{0}” (App ID {1})? -Isso exclui o arquivo .lua de Steam\config\stplug-in. Reinicie o Steam depois para que a alteração tenha efeito. +Isso exclui o arquivo .lua de Steam\config\stplug-in. Remover arquivos lua Remover {0} arquivos lua? -Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois para que as alterações tenham efeito. +Isso exclui os arquivos .lua de Steam\config\stplug-in. Falha ao remover Não foi possível excluir o arquivo: {0} @@ -289,7 +294,6 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois {1} {0} arquivos não puderam ser excluídos. Reiniciar Steam - Reiniciar o Steam agora para que as alterações tenham efeito? Não foi possível encontrar ou iniciar o Steam. Defina o local nas Configurações. Correções Carregando correções… @@ -306,8 +310,7 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois Falha na instalação Não foi possível instalar — feche o Steam (ou reinicie o Steam) e tente novamente. Correção instalada - Manifesto de {0} instalado — o Steam está reiniciando. - Manifesto de {0} instalado. Reinicie o Steam para aplicar. + Manifesto de {0} instalado. Jogo não encontrado Instale {0} no Steam primeiro e depois aplique a correção. Correção aplicada parcialmente @@ -347,13 +350,12 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois 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. Não foi possível instalar {0} arquivo(s) — feche o Steam (ou use Reiniciar Steam) e tente novamente. - {0} adicionado — lua + {1} manifesto(s). Reinicie o Steam para aplicar. - {0} adicionado — o Steam buscará os manifestos. Reinicie o Steam para aplicar. + Adicionado {0}: lua + {1} manifesto(s). + Adicionado {0}. O Steam vai buscar os manifestos. Abrir no SteamDB COMPARTILHADO Isso substitui o lua instalado. Revise o que muda: @@ -450,9 +452,9 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois Excluir esta predefinição? “{0}” será removida das suas predefinições salvas. Isso não afeta o jogo em si. Não é possível excluir a predefinição que está em uso. - Usando “{0}” agora. Reinicie o Steam para valer. + Agora usando “{0}”. Não foi possível trocar de predefinição — o arquivo lua pode estar em uso. - Salvo. Reinicie o Steam para valer. + Salvo. Não foi possível salvar — o arquivo lua pode estar em uso. Salvo como predefinição. Salvar em “{0}” @@ -498,4 +500,134 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois As opções de inicialização foram redefinidas O Steam sobrescreveu suas opções de inicialização de {0} jogo(s). Aplicar de novo fecha e reabre o Steam. Aplicar de novo + Local do Steam não encontrado. Defina-o nas Configurações. + Não foi possível acessar o GitHub. Verifique sua conexão e tente novamente. + Não foi possível acessar o servidor de atualizações. Verifique sua conexão e tente novamente. + Cancelado. + Cancelado pelo usuário + O download esperado está faltando na versão. + {0} está faltando na versão. + O download falhou na verificação (sha256 não confere). + {0} falhou na verificação (sha256 não confere). + Faltando no download: {0}. + Não foi possível gravar {0} arquivo(s). Feche o Steam e tente novamente. + Não foi possível gravar {0}. Feche o Steam e tente novamente. + Não foi possível abrir o download: {0} + Nenhuma fonte disponível para este jogo. + Tempo de login esgotado. Sem resposta do navegador. + Falha na troca de token ({0}): {1} + A troca de token retornou uma sessão vazia. + Você não está conectado. + Falha ao atualizar a sessão ({0}). + A atualização da sessão retornou uma sessão vazia. + O link de download estava vazio. Tente novamente. + Falha no download ({0}). + Falha na requisição ({0}) + Sessão expirada. Entre novamente. + Sua chave do Hubcap é inválida ou expirou. + Você atingiu seu limite diário do Hubcap. + Nenhum manifesto do Hubcap disponível para este app. + Falha no download do Hubcap ({0}). + Downloads + Downloads + Nenhum download em andamento. Bora, baixa alguma coisa! + Fila + Histórico + Na fila + Baixando + Aguardando você + Instalando + Concluído + Falhou + Cancelado + Cancelar + Tentar de novo + Remover + Mover para cima + Mover para baixo + Limpar histórico + Revisar + {0} de {1} + Faltam {0} + Ação necessária + Interrompido quando o app foi fechado. + Desbloqueio de DLC + Instale o jogo primeiro para aplicar um fix. + Pausado + Verificando… + Pausar + Retomar + Arquivos do depot + Depots · {0} de {1} + Não deu para obter o baixador de depots. + Nenhuma chave de descriptografia encontrada para este jogo. + Depot {0} falhou: {1} + {0} depots baixados em {1}. + Baixar + Selecione os depots para baixar + Baixar {0} depots ({1}) + Este depot não declara nenhuma versão para baixar. + Selecionar tudo + Nenhum depot selecionado + Escolha onde salvar os arquivos do depot + Espaço em disco insuficiente: precisa de {0}, só há {1} livres. + Salvar em + Precisa de {0} · {1} livres em {2} + Entre para baixar este depot. + Entre para baixar depots. + obtendo manifest + 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. + 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. + 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. + 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 04d1863..2f2e1ef 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 @@ -148,16 +153,17 @@ O Steam será fechado para que os seus ficheiros possam ser alterados e, em seguida, reiniciado automaticamente. Todos os jogos ou transferências em curso serão interrompidos. Cancelar Fechar Steam e continuar - O bom e velho SteamTools — feito pelos chineses, amado pelo mundo. O desbloqueador que iniciou toda a tendência (o GreenLuma não existe, shh). ⚠️ ATUALMENTE NÃO FUNCIONA, USE O BETTERSTEAMTOOLS - Remake de raiz do SteamTools. Melhor que o SteamTools em praticamente tudo. - SteamTools com alguns patches engenhosos — uma tonelada de correções e melhorias. Traz de volta o Steam Cloud nativo através do Google Drive / OneDrive! + Um fork de código aberto do OpenSteamTools mantido ativamente pela equipa do LuaTools. A trazer correções e novas funcionalidades! Gerir Não foi possível transferir ou iniciar o CloudRedirect. - Compilação noturna do BetterSteamTools (OST) a partir do código mais recente. As correções mais recentes, mas pode ser menos estável. + Compilação noturna do OpenSteamTools a partir do código mais recente. As correções mais recentes, mas pode ser menos estável. + Agora é contigo + Usa a tua própria solução de desbloqueio, não lhe vamos mexer + Desbloqueador personalizado EXPERIMENTAL SUPORTE A CLOUDREDIRECT - Traz de volta o suporte nativo ao Steam Cloud através de fornecedores de armazenamento de terceiros como Google Drive / OneDrive / Cloudflare R2 e mais. De momento, apenas compatível com o BetterSteamTools Nightly. Não te esqueças de o configurar clicando em "Gerir" após ativar e, de seguida, reinicia o Steam! - Selecione o BetterSteamTools Nightly para usar o CloudRedirect. + Traz de volta o suporte nativo ao Steam Cloud através de fornecedores de armazenamento de terceiros como Google Drive / OneDrive / Cloudflare R2 e mais. De momento, apenas compatível com o OpenSteamTools / BetterSteamTools. Não te esqueças de o configurar clicando em "Gerir" após ativar e, de seguida, reinicia o Steam! + Selecione o OpenSteamTools / BetterSteamTools para usar o CloudRedirect. Ativar Desativar Ativado. @@ -207,7 +213,6 @@ {0} manifesto(s) {0} instalado(s) no Steam. {0} ficheiro(s) falharam — feche o Steam e tente novamente. - Reinicie o Steam para aplicar. Pesquisar por nome ou App ID... A carregar… App ID: {0} @@ -275,13 +280,13 @@ Depósito partilhado DLC {0} Remover ficheiro lua - Remover "{0}" (App ID {1})? + Remover “{0}” (App ID {1})? -Isto elimina o ficheiro .lua de Steam\config\stplug-in. Reinicie o Steam depois para que a alteração tenha efeito. +Isto elimina o ficheiro .lua de Steam\config\stplug-in. Remover ficheiros lua Remover {0} ficheiros lua? -Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depois para que as alterações tenham efeito. +Isto elimina os ficheiros .lua de Steam\config\stplug-in. Falha ao remover Não foi possível eliminar o ficheiro: {0} @@ -289,7 +294,6 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi {1} Não foi possível eliminar {0} ficheiros. Reiniciar Steam - Reiniciar o Steam agora para que as alterações tenham efeito? Não foi possível encontrar ou iniciar o Steam. Defina a localização nas Definições. Correções A carregar correções… @@ -306,8 +310,7 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi Falha na instalação Não foi possível instalar — feche o Steam (ou reinicie o Steam) e tente novamente. Correção instalada - Manifesto de {0} instalado — o Steam está a reiniciar. - Manifesto de {0} instalado. Reinicie o Steam para aplicar. + Manifesto de {0} instalado. Jogo não encontrado Instale primeiro {0} no Steam e depois aplique a correção. Correção aplicada parcialmente @@ -347,13 +350,12 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi 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. Não foi possível instalar {0} ficheiro(s) — feche o Steam (ou use Reiniciar Steam) e tente novamente. - {0} adicionado — lua + {1} manifesto(s). Reinicie o Steam para aplicar. - {0} adicionado — o Steam irá obter os manifestos. Reinicie o Steam para aplicar. + Adicionado {0}: lua + {1} manifesto(s). + Adicionado {0}. O Steam vai obter os manifestos. Abrir no SteamDB PARTILHADO Isto substitui o lua instalado. Reveja o que muda: @@ -450,9 +452,9 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi Eliminar esta predefinição? “{0}” será removida das suas predefinições guardadas. Isto não afeta o jogo em si. Não é possível eliminar a predefinição que está a ser utilizada. - A utilizar “{0}” agora. Reinicie o Steam para ter efeito. + A usar “{0}”. Não foi possível mudar de predefinição — o ficheiro lua pode estar a ser utilizado. - Guardado. Reinicie o Steam para ter efeito. + Guardado. Não foi possível guardar — o ficheiro lua pode estar a ser utilizado. Guardado como predefinição. Guardar em “{0}” @@ -498,4 +500,134 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi As opções de arranque foram repostas O Steam substituiu as suas opções de arranque de {0} jogo(s). Voltar a aplicar fecha e reabre o Steam. Voltar a aplicar + Localização do Steam não encontrada. Define-a nas Definições. + Não foi possível contactar o GitHub. Verifica a tua ligação e tenta novamente. + Não foi possível contactar o servidor de atualizações. Verifica a tua ligação e tenta novamente. + Cancelado. + Cancelado pelo utilizador + Falta o download esperado na versão. + Falta {0} na versão. + O download falhou a verificação (sha256 não corresponde). + {0} falhou a verificação (sha256 não corresponde). + Em falta no download: {0}. + Não foi possível escrever {0} ficheiro(s). Fecha o Steam e tenta novamente. + Não foi possível escrever {0}. Fecha o Steam e tenta novamente. + Não foi possível abrir o download: {0} + Nenhuma fonte disponível para este jogo. + Tempo de sessão esgotado. Sem resposta do navegador. + Falha na troca de token ({0}): {1} + A troca de token devolveu uma sessão vazia. + Sessão não iniciada. + Falha ao atualizar a sessão ({0}). + A atualização da sessão devolveu uma sessão vazia. + O link de download estava vazio. Tenta novamente. + Falha no download ({0}). + Falha no pedido ({0}) + Sessão expirada. Inicia sessão novamente. + A tua chave Hubcap é inválida ou expirou. + Atingiste o teu limite diário do Hubcap. + Nenhum manifesto Hubcap disponível para esta app. + Falha no download do Hubcap ({0}). + Transferências + Transferências + Nenhuma transferência em curso. Vá lá, descarregue alguma coisa! + Fila + Histórico + Em fila + A transferir + À sua espera + A instalar + Concluída + Falhou + Cancelada + Cancelar + Tentar novamente + Remover + Mover para cima + Mover para baixo + Limpar histórico + Rever + {0} de {1} + Faltam {0} + Ação necessária + Interrompida quando a aplicação foi fechada. + Desbloqueio de DLC + Instale o jogo primeiro para aplicar um fix. + Em pausa + A verificar… + Pausar + Retomar + Ficheiros do depot + Depots · {0} de {1} + Não foi possível obter o transferidor de depots. + Não foram encontradas chaves de desencriptação para este jogo. + O depot {0} falhou: {1} + {0} depots transferidos para {1}. + Transferir + Selecione os depots a transferir + Transferir {0} depots ({1}) + Este depot não declara nenhuma versão para transferir. + Selecionar tudo + Nenhum depot selecionado + Escolha onde guardar os ficheiros do depot + Espaço em disco insuficiente: precisa de {0}, só há {1} livres. + Guardar em + Precisa de {0} · {1} livres em {2} + Inicie sessão para transferir este depot. + 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 + 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. + 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. + 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. + 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 db0c8fd..2a38fa0 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -87,9 +87,9 @@ Have a code? Run /login in Discord and enter it here. A3X9K2 Redeem - Code expired — run /login again. + Code expired. Run /login again. Invalid or unknown code. - Server error — try again. + Server error. Try again. Finish linking your account You're signed in with a temporary Discord account. Sign in at lua.tools, then run /login again and enter the code to link your full account. Dismiss @@ -105,12 +105,17 @@ {0}/{1} requests today {0}/{1} today · expires {2} That doesn't look like a valid key (smm_…). - Couldn't verify the key — it may be invalid, expired, or Hubcap is unreachable. + Couldn't verify the key. It may be invalid, expired, or Hubcap is unreachable. Active Refresh 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 @@ -118,7 +123,7 @@ Auto Update Apps (Don't Lock Manifests) Installs without pinning a version, so Steam keeps games updated. Turn off to lock games to the downloaded version. Donate Decryption Keys - Share spare Steam depot decryption keys to grow the community pool. Only validated keys are sent — no account info or personal data. + Share spare Steam depot decryption keys to grow the community pool. Only validated keys are sent, never account info or personal data. STARTUP Start with Windows Launch LuaTools automatically when Windows starts. @@ -147,7 +152,7 @@ View details Checking Steam… Steam detected · {0} - Steam not found — set its location in Settings. + Steam not found. Set its location in Settings. Browsing as guest Signed in Signed in as {0} @@ -156,7 +161,7 @@ Mode - Switch the backend used to unlock games added with LuaTools — one active at a time. + Switch the backend used to unlock games added with LuaTools. Only one is active at a time. Check for updates ACTIVE RECOMMENDED @@ -164,25 +169,26 @@ Cancel Close Steam & continue - Good ol' SteamTools — built by the Chinese, loved by the world. The unlocker that started the whole trend (GreenLuma doesn't exist, shh). ⚠️ CURRENTLY NOT WORKING, USE BETTERSTEAMTOOLS - Ground up remake of SteamTools. Better than SteamTools in basically every way. - SteamTools with a few nifty patches — a ton of fixes and improvements. Brings back native Steam Cloud via Google Drive / OneDrive! + An open source fork of OpenSteamTools actively maintained by the LuaTools team. Introducing fixes and new features! Manage Couldn't download or launch CloudRedirect. - Nightly build of BetterSteamTools (OST) from the latest source. Newest fixes, but may be less stable. + Nightly build of OpenSteamTools from the latest source. Newest fixes, but may be less stable. + Custom Unlocker + Use your own unlocking solution, we will not mess with it + You're on your own EXPERIMENTAL CLOUDREDIRECT SUPPORT - Brings back native Steam Cloud support via third-party storage providers such as Google Drive / OneDrive / Cloudflare R2 + more. Only compatible with BetterSteamTools Nightly at the moment. Don't forget to configure it by clicking "Manage" after enabling, then restart Steam! - Select BetterSteamTools Nightly to use CloudRedirect. + Brings back native Steam Cloud support via third-party storage providers such as Google Drive / OneDrive / Cloudflare R2 + more. Only compatible with OpenSteamTools / BetterSteamTools at the moment. Don't forget to configure it by clicking "Manage" after enabling, then restart Steam! + Select OpenSteamTools / BetterSteamTools to use CloudRedirect. Enable Disable Enabled. Installed, but disabled. Not installed yet. Update available. - Enabled — restart Steam to apply. - Disabled — restart Steam to apply. - Updated — restart Steam to apply. + Enabled. Restart Steam to apply. + Disabled. Restart Steam to apply. + Updated. Restart Steam to apply. Welcome to the LuaTools App! Sign in to unlock adding games and fixes. @@ -208,7 +214,7 @@ Reinstall {0}? Switch to {0}? Mode updated - {0} is installed — Steam is restarting. + {0} is installed. Steam is restarting. {0} is installed. Start Steam to use it. Install failed Something went wrong. @@ -220,15 +226,14 @@ Choose .lua / .manifest / .zip files Lua / manifest / zip|*.lua;*.manifest;*.zip|All files|*.* - Nothing to install — drop .lua, .manifest, or .zip files. + Nothing to install. Drop .lua, .manifest, or .zip files. Replace "{0}"? - No depot/DLC changes — same content. + No depot/DLC changes. Same content. Nothing installed (cancelled or unrecognised file names). {0} lua(s) {0} manifest(s) Installed {0} to Steam. - {0} file(s) failed — close Steam and retry. - Restart Steam to apply. + {0} file(s) failed. Close Steam and retry. Search by name or App ID... @@ -268,12 +273,12 @@ This runs Steamless on the game's executable to strip SteamStub DRM. The original .exe is backed up to .bak first. Some games may need a Steam file verification afterwards. Continue? Removing Steam DRM… {0} patched, {1} unchanged. - Game folder not found — is it installed? + Game folder not found. Is it installed? Couldn't remove DRM. {0} Click to copy App ID Loading depot info… On Steam but not in your lua (often censored or blacklisted depots) - Broken, unreleased, or shared redistributable depots — usually nothing to worry about + Broken, unreleased, or shared redistributable depots. Usually nothing to worry about Clear Delete selected Loading your games… @@ -293,7 +298,7 @@ Fetching details for {0} apps No games match your filters. No lua files found yet. Add a game from the Add tab. - Steam location not found — set it in Settings. + Steam location not found. Set it in Settings. Not found That game is no longer in your library. Library refreshed @@ -308,11 +313,11 @@ Remove lua file Remove "{0}" (App ID {1})? -This deletes its .lua file from Steam\config\stplug-in. Restart Steam afterwards for the change to take effect. +This deletes its .lua file from Steam\config\stplug-in. Remove lua files Remove {0} lua files? -This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterwards for the changes to take effect. +This deletes the .lua files from Steam\config\stplug-in. Remove failed Couldn't delete the file: {0} @@ -320,7 +325,6 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward {1} {0} files couldn't be deleted. Restart Steam - Restart Steam now so the changes take effect? Couldn't find or launch Steam. Set its location in Settings. @@ -331,26 +335,41 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Fix {0} fixes - Couldn't load fixes — check your connection. + Couldn't load fixes. Check your connection. No fixes available yet. Sign in with Discord to download fixes. Sign in required Download failed - Something went wrong — try again. + Something went wrong. Try again. Install failed - Couldn't install — close Steam (or Restart Steam) and try again. + Couldn't install. Close Steam (or Restart Steam) and try again. Fix installed - {0} manifest installed — Steam is restarting. - {0} manifest installed. Restart Steam to apply. + {0} manifest installed. Game not found Install {0} in Steam first, then apply the fix. Fix partially applied - {0} file(s) couldn't be written — close the game and try again. + {0} file(s) couldn't be written. Close the game and try again. Fix applied {0} fix extracted into the game folder. 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. + 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. + No luas added yet — add a game (or install one) to see it here. Let's get Luing™! @@ -376,21 +395,20 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward NO KEY Download .lua Generating… - Put the .lua into steam\config\stplug-in — make sure the base game is added too. Counts toward your daily limit (25/day). + Put the .lua into steam\config\stplug-in, and make sure the base game is added too. Counts toward your daily limit (25/day). Already have the files? Sign in with Discord to view DLC unlocks. Sign in with Discord to download. 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. + Something went wrong. Check your connection and try again. + Download failed. Check your connection and try again. Replace "{0}"? - No depot/DLC changes — same content. - Install cancelled — existing files left unchanged. - Couldn't install {0} file(s) — close Steam (or use Restart Steam) and try again. - Added {0} — lua + {1} manifest(s). Restart Steam to apply. - Added {0} — Steam will fetch manifests. Restart Steam to apply. + No depot/DLC changes. Same content. + Install cancelled. Existing files left unchanged. + Couldn't install {0} file(s). Close Steam (or use Restart Steam) and try again. + Added {0}: lua + {1} manifest(s). + Added {0}. Steam will fetch manifests. FastFetch™️ Auto-download from the first available source No available sources found for this game @@ -405,15 +423,15 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Skip Replace LuaTools Plugin - The LuaTools Steam Plugin! — Now using LuaLoader instead of Shittenium + The LuaTools Steam Plugin! Now using LuaLoader instead of Shittenium Status Update available Installed version Latest version Frontend Loader (winmm.dll) - Millennium detected — LuaTools runs alongside it. Installing disables Millennium's own copy of this plugin so only LuaLoader injects. - LuaLoader installs into your Steam folder. This app works as the backend — if you close it from the tray, the plugin will no longer function! + Millennium detected. LuaTools runs alongside it. Installing disables Millennium's own copy of this plugin so only LuaLoader injects. + LuaLoader installs into your Steam folder. This app works as the backend, so if you close it from the tray, the plugin will no longer function! Install Update Reinstall @@ -427,7 +445,7 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward unknown offline Couldn't reach GitHub to check for updates. - Another program is already using port 8080 — the plugin's browser bridge may not work. Close whatever's using that port and reinstall or restart Steam. + Another program is already using port 8080, so the plugin's browser bridge may not work. Close whatever's using that port and reinstall or restart Steam. This will close and restart Steam to apply the loader. Continue? Restart Steam Plugin @@ -481,10 +499,10 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Delete this preset? “{0}” will be removed from your saved presets. This doesn't affect the game itself. Can't delete the preset that's currently in use. - Now using “{0}”. Restart Steam for it to take effect. - Couldn't switch presets — the lua file may be in use. - Saved. Restart Steam for it to take effect. - Couldn't save — the lua file may be in use. + Now using “{0}”. + Couldn't switch presets. The lua file may be in use. + Saved. + Couldn't save. The lua file may be in use. Saved as a preset. Save to “{0}” Save as new @@ -494,7 +512,7 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward On: this depot's decryption key applies. Off: the depot isn't unlocked at all. (Comments the addappid line in or out.) Search depots by name, ID or type… No depots match your search. - You’re editing a preset that isn’t applied — changes save straight into it, and Steam keeps using the applied preset until you hit Apply. + You’re editing a preset that isn’t applied. Changes save straight into it, and Steam keeps using the applied preset until you hit Apply. Launch options… @@ -513,7 +531,7 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Operating system Type Only on branch - Branch name this option is limited to — e.g. beta, default, development. Leave blank to show it on every branch. + Branch name this option is limited to, e.g. beta, default, development. Leave blank to show it on every branch. Only if DLC owned (App ID) Install path: {0} Applying changes closes and reopens Steam. @@ -523,7 +541,7 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward This puts back the launch options as Steam had them and forgets your changes. Steam will be closed and reopened. Continue? This game hasn't been changed, so there's nothing to restore. Apply to Steam now? - Your changes are saved. Writing them into Steam requires closing and reopening Steam — do that now? Choose Cancel to apply later. + Your changes are saved. Writing them into Steam requires closing and reopening Steam. Do that now? Choose Cancel to apply later. Saved. Apply them from this window when you're ready to restart Steam. Launch options applied. Launch options applied. Steam was restarted. @@ -531,4 +549,120 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Launch options were reset Steam has overwritten your launch options for {0} game(s). Re-applying closes and reopens Steam. Re-apply + + + Steam location not found. Set it in Settings. + Couldn't reach GitHub. Check your connection and try again. + Couldn't reach the update server. Check your connection and try again. + Cancelled. + Cancelled by user + Release is missing the expected download. + Release is missing {0}. + Download failed verification (sha256 mismatch). + {0} failed verification (sha256 mismatch). + Download is missing: {0}. + Couldn't write {0} file(s). Close Steam and try again. + Couldn't write {0}. Close Steam and try again. + Couldn't open the download: {0} + No available source for this game. + Sign-in timed out. No response from the browser. + Token exchange failed ({0}): {1} + Token exchange returned an empty session. + Not signed in. + Session refresh failed ({0}). + Session refresh returned an empty session. + The download link was empty. Try again. + Download failed ({0}). + Request failed ({0}) + Session expired. Please sign in again. + Your Hubcap key is invalid or expired. + Your Hubcap daily limit has been reached. + No Hubcap manifest is available for this app. + Hubcap download failed ({0}). + Downloads + Downloads + Nothing downloading right now. Start downloading some shit! + Queue + History + Queued + Downloading + Waiting for you + Installing + Done + Failed + Cancelled + Cancel + Retry + Remove + Move up + Move down + Clear history + Review + {0} of {1} + {0} left + Action required + Interrupted when the app closed. + DLC unlock + Install the game first to apply a fix. + Paused + Verifying… + Pause + Resume + Depot files + Depots · {0} of {1} + Couldn't get the depot downloader. + No decryption keys found for this game. + Depot {0} failed: {1} + Downloaded {0} depot(s) to {1}. + Download + Select depots to download + Download {0} depots ({1}) + This depot declares no version to download. + Select all + No depots selected + Choose where to save the depot files + Not enough disk space: needs {0}, only {1} free. + Save to + Needs {0} · {1} free on {2} + Sign in to download this depot. + Sign in to download depots. + fetching manifest + 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. + 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 0c4a9d0..222aa53 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 @@ -148,16 +153,17 @@ Steam va fi închis pentru a putea fi modificate fișierele sale, apoi repornit automat. Toate jocurile sau descărcările în curs se vor opri. Anulează Închide Steam și continuă - Bunul și vechiul SteamTools — făcut de chinezi, iubit de întreaga lume. Deblocatorul care a pornit întregul trend (GreenLuma nu există, șșș). ⚠️ NU FUNCȚIONEAZĂ ÎN PREZENT, FOLOSEȘTE BETTERSTEAMTOOLS - Remake de la zero al SteamTools. Mai bun decât SteamTools în aproape toate privințele. - SteamTools cu câteva patch-uri ingenioase — o grămadă de remedieri și îmbunătățiri. Readuce Steam Cloud nativ prin Google Drive / OneDrive! + Un fork open source al OpenSteamTools, întreținut activ de echipa LuaTools. Cu corecții și funcții noi! Gestionează Nu s-a putut descărca sau lansa CloudRedirect. - Versiune nightly a BetterSteamTools (OST) din cel mai recent cod sursă. Cele mai noi remedieri, dar poate fi mai puțin stabilă. + Versiune nightly a OpenSteamTools din cel mai recent cod sursă. Cele mai noi remedieri, dar poate fi mai puțin stabilă. + De acum te descurci singur + Folosește-ți propria soluție de deblocare, nu ne atingem de ea + Deblocator personalizat EXPERIMENTAL SUPORT CLOUDREDIRECT - Readuce suportul nativ Steam Cloud prin furnizori de stocare terți precum Google Drive / OneDrive / Cloudflare R2 și altele. Momentan compatibil doar cu BetterSteamTools Nightly. Nu uita să îl configurezi apăsând pe "Gestionează" după activare, apoi repornește Steam! - Selectează BetterSteamTools Nightly pentru a folosi CloudRedirect. + Readuce suportul nativ Steam Cloud prin furnizori de stocare terți precum Google Drive / OneDrive / Cloudflare R2 și altele. Momentan compatibil doar cu OpenSteamTools / BetterSteamTools. Nu uita să îl configurezi apăsând pe "Gestionează" după activare, apoi repornește Steam! + Selectează OpenSteamTools / BetterSteamTools pentru a folosi CloudRedirect. Activează Dezactivează Activat. @@ -207,7 +213,6 @@ {0} manifest(e) {0} instalat(e) în Steam. {0} fișier(e) au eșuat — închide Steam și încearcă din nou. - Repornește Steam pentru a aplica. Caută după nume sau App ID... Se încarcă… App ID: {0} @@ -277,11 +282,11 @@ Elimină fișierul lua Elimini „{0}” (App ID {1})? -Aceasta șterge fișierul .lua din Steam\config\stplug-in. Repornește Steam după aceea pentru ca modificarea să aibă efect. +Aceasta șterge fișierul .lua din Steam\config\stplug-in. Elimină fișierele lua Elimini {0} fișiere lua? -Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam după aceea pentru ca modificările să aibă efect. +Aceasta șterge fișierele .lua din Steam\config\stplug-in. Eliminare eșuată Fișierul nu a putut fi șters: {0} @@ -289,7 +294,6 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du {1} {0} fișiere nu au putut fi șterse. Repornește Steam - Repornești Steam acum pentru ca modificările să aibă efect? Steam nu a putut fi găsit sau pornit. Setează-i locația în Setări. Remedieri Se încarcă remedierile… @@ -306,8 +310,7 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du Instalare eșuată Nu s-a putut instala — închide Steam (sau repornește-l) și încearcă din nou. Remediere instalată - Manifestul {0} instalat — Steam repornește. - Manifestul {0} instalat. Repornește Steam pentru a aplica. + Manifestul {0} a fost instalat. Joc negăsit Instalează mai întâi {0} în Steam, apoi aplică remedierea. Remediere aplicată parțial @@ -347,13 +350,12 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du 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. Nu s-au putut instala {0} fișier(e) — închide Steam (sau folosește Repornește Steam) și încearcă din nou. - {0} adăugat — lua + {1} manifest(e). Repornește Steam pentru a aplica. - {0} adăugat — Steam va prelua manifestele. Repornește Steam pentru a aplica. + Adăugat {0}: lua + {1} manifest(e). + Adăugat {0}. Steam va descărca manifestele. Deschide pe SteamDB PARTAJAT Aceasta înlocuiește lua-ul instalat. Verifică ce se modifică: @@ -450,9 +452,9 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du Ștergi această presetare? „{0}” va fi eliminată din presetările tale salvate. Acest lucru nu afectează jocul în sine. Presetarea aflată în uz nu poate fi ștearsă. - Se folosește acum „{0}”. Repornește Steam pentru a avea efect. + Se folosește acum „{0}”. Nu s-a putut schimba presetarea — fișierul lua ar putea fi în uz. - Salvat. Repornește Steam pentru a avea efect. + Salvat. Nu s-a putut salva — fișierul lua ar putea fi în uz. Salvat ca presetare. Salvează în „{0}” @@ -498,4 +500,134 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du Opțiunile de lansare au fost resetate Steam a suprascris opțiunile tale de lansare pentru {0} joc(uri). Reaplicarea închide și redeschide Steam. Reaplică + Locația Steam nu a fost găsită. Setează-o în Setări. + Nu s-a putut contacta GitHub. Verifică conexiunea și încearcă din nou. + Nu s-a putut contacta serverul de actualizări. Verifică conexiunea și încearcă din nou. + Anulat. + Anulat de utilizator + Din versiune lipsește descărcarea așteptată. + Din versiune lipsește {0}. + Descărcarea nu a trecut verificarea (sha256 nu corespunde). + {0} nu a trecut verificarea (sha256 nu corespunde). + Lipsește din descărcare: {0}. + Nu s-au putut scrie {0} fișier(e). Închide Steam și încearcă din nou. + Nu s-a putut scrie {0}. Închide Steam și încearcă din nou. + Nu s-a putut deschide descărcarea: {0} + Nicio sursă disponibilă pentru acest joc. + Autentificarea a expirat. Niciun răspuns de la browser. + Schimbul de token a eșuat ({0}): {1} + Schimbul de token a returnat o sesiune goală. + Nu ești autentificat. + Reîmprospătarea sesiunii a eșuat ({0}). + Reîmprospătarea sesiunii a returnat o sesiune goală. + Linkul de descărcare era gol. Încearcă din nou. + Descărcarea a eșuat ({0}). + Cererea a eșuat ({0}) + Sesiune expirată. Autentifică-te din nou. + Cheia ta Hubcap este invalidă sau a expirat. + Ai atins limita zilnică Hubcap. + Nu există manifest Hubcap pentru această aplicație. + Descărcarea Hubcap a eșuat ({0}). + Descărcări + Descărcări + Nicio descărcare în curs. Hai, descarcă ceva! + Coadă + Istoric + În coadă + Se descarcă + Te așteaptă + Se instalează + Gata + Eșuat + Anulat + Anulează + Reîncearcă + Elimină + Mută mai sus + Mută mai jos + Șterge istoricul + Verifică + {0} din {1} + Au rămas {0} + Necesită acțiune + Întreruptă la închiderea aplicației. + Deblocare DLC + Instalează întâi jocul pentru a aplica un fix. + În pauză + Se verifică… + Pauză + Reia + Fișiere depot + Depouri · {0} din {1} + Nu s-a putut obține descărcătorul de depouri. + Nu s-au găsit chei de decriptare pentru acest joc. + Depoul {0} a eșuat: {1} + S-au descărcat {0} depouri în {1}. + Descarcă + Alege depourile de descărcat + Descarcă {0} depouri ({1}) + Acest depou nu declară nicio versiune de descărcat. + Selectează tot + Niciun depou selectat + Alege unde să salvezi fișierele depoului + Spațiu insuficient pe disc: necesită {0}, doar {1} liberi. + Salvează în + Necesită {0} · {1} liberi pe {2} + Autentifică-te ca să descarci acest depou. + 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 + 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. + 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ă. + 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. + 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 0a24c43..52c91fd 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 Изменить… Открыть @@ -148,16 +153,17 @@ Steam будет закрыт, чтобы можно было изменить его файлы, а затем автоматически перезапущен. Все запущенные игры или загрузки будут остановлены. Отмена Закрыть Steam и продолжить - Старый добрый SteamTools — создан китайцами, любим всем миром. Разблокировщик, с которого началась вся эта мода (GreenLuma не существует, тсс). ⚠️ В ДАННЫЙ МОМЕНТ НЕ РАБОТАЕТ, ИСПОЛЬЗУЙТЕ BETTERSTEAMTOOLS - Полностью переработанная версия SteamTools, созданная с нуля. Лучше SteamTools практически во всём. - SteamTools с парой ловких патчей — куча исправлений и улучшений. Возвращает родной Steam Cloud через Google Drive / OneDrive! + Форк OpenSteamTools с открытым исходным кодом, который активно поддерживает команда LuaTools. С исправлениями и новыми функциями! Управление Не удалось загрузить или запустить CloudRedirect. - Ночная сборка BetterSteamTools (OST) из свежего исходного кода. Самые свежие исправления, но может быть менее стабильной. + Ночная сборка OpenSteamTools из свежего исходного кода. Самые свежие исправления, но может быть менее стабильной. + Дальше сам + Используй своё решение для разблокировки — мы в него не лезем + Свой анлокер ЭКСПЕРИМЕНТАЛЬНАЯ ПОДДЕРЖКА CLOUDREDIRECT - Возвращает встроенную поддержку Steam Cloud через сторонние хранилища, такие как Google Drive / OneDrive / Cloudflare R2 и другие. Пока совместимо только с BetterSteamTools Nightly. Не забудьте настроить его, нажав "Управление" после включения, а затем перезапустите Steam! - Выберите BetterSteamTools Nightly, чтобы использовать CloudRedirect. + Возвращает встроенную поддержку Steam Cloud через сторонние хранилища, такие как Google Drive / OneDrive / Cloudflare R2 и другие. Пока совместимо только с OpenSteamTools / BetterSteamTools. Не забудьте настроить его, нажав "Управление" после включения, а затем перезапустите Steam! + Выберите OpenSteamTools / BetterSteamTools, чтобы использовать CloudRedirect. Включить Выключить Включено. @@ -207,7 +213,6 @@ {0} манифест(ов) {0} установлено в Steam. Не удалось обработать {0} файл(ов) — закройте Steam и повторите. - Перезапустите Steam, чтобы применить. Поиск по имени или App ID... Загрузка… App ID: {0} @@ -277,11 +282,11 @@ Удалить файл lua Удалить «{0}» (App ID {1})? -Это удалит его файл .lua из Steam\config\stplug-in. Затем перезапустите Steam, чтобы изменение вступило в силу. +Это удалит его файл .lua из Steam\config\stplug-in. Удалить файлы lua - Удалить {0} файлов lua? + Удалить файлов lua: {0}? -Это удалит файлы .lua из Steam\config\stplug-in. Затем перезапустите Steam, чтобы изменения вступили в силу. +Это удалит файлы .lua из Steam\config\stplug-in. Не удалось удалить Не удалось удалить файл: {0} @@ -289,7 +294,6 @@ {1} Не удалось удалить {0} файлов. Перезапустить Steam - Перезапустить Steam сейчас, чтобы изменения вступили в силу? Не удалось найти или запустить Steam. Укажите его расположение в настройках. Фиксы Загрузка фиксов… @@ -306,8 +310,7 @@ Ошибка установки Не удалось установить — закройте Steam (или перезапустите Steam) и попробуйте снова. Фикс установлен - Манифест {0} установлен — Steam перезапускается. - Манифест {0} установлен. Перезапустите Steam, чтобы применить. + Манифест {0} установлен. Игра не найдена Сначала установите {0} в Steam, затем примените фикс. Фикс применён частично @@ -347,13 +350,12 @@ Не удалось определить базовую игру для этого DLC Что-то пошло не так — проверьте подключение и попробуйте снова. Ошибка загрузки — проверьте подключение и попробуйте снова. - Ошибка генерации — проверьте подключение и попробуйте снова. Заменить «{0}»? Нет изменений депо/DLC — то же содержимое. Установка отменена — существующие файлы не изменены. Не удалось установить {0} файл(ов) — закройте Steam (или используйте «Перезапустить Steam») и попробуйте снова. - {0} добавлено — lua + {1} манифест(ов). Перезапустите Steam, чтобы применить. - {0} добавлено — Steam загрузит манифесты. Перезапустите Steam, чтобы применить. + Добавлено {0}: lua + манифестов: {1}. + Добавлено {0}. Steam загрузит манифесты. Открыть в SteamDB ОБЩИЙ Это заменит установленный lua. Просмотрите, что изменится: @@ -450,9 +452,9 @@ Удалить этот пресет? «{0}» будет удалён из сохранённых пресетов. На саму игру это не влияет. Нельзя удалить пресет, который используется сейчас. - Теперь используется «{0}». Перезапустите Steam, чтобы изменения вступили в силу. + Теперь используется «{0}». Не удалось переключить пресет — файл lua может быть занят. - Сохранено. Перезапустите Steam, чтобы изменения вступили в силу. + Сохранено. Не удалось сохранить — файл lua может быть занят. Сохранено как пресет. Сохранить в «{0}» @@ -498,4 +500,134 @@ Параметры запуска были сброшены Steam перезаписал ваши параметры запуска для {0} игр(ы). Повторное применение закроет и снова откроет Steam. Применить снова + Папка Steam не найдена. Укажите её в настройках. + Не удалось связаться с GitHub. Проверьте подключение и повторите попытку. + Не удалось связаться с сервером обновлений. Проверьте подключение и повторите попытку. + Отменено. + Отменено пользователем + В релизе отсутствует нужный файл. + В релизе отсутствует {0}. + Загрузка не прошла проверку (несовпадение sha256). + {0} не прошёл проверку (несовпадение sha256). + В загрузке отсутствует: {0}. + Не удалось записать файлов: {0}. Закройте Steam и повторите попытку. + Не удалось записать {0}. Закройте Steam и повторите попытку. + Не удалось открыть загрузку: {0} + Для этой игры нет доступных источников. + Время входа истекло. Браузер не ответил. + Не удалось обменять токен ({0}): {1} + Обмен токена вернул пустую сессию. + Вход не выполнен. + Не удалось обновить сессию ({0}). + Обновление сессии вернуло пустую сессию. + Ссылка на загрузку оказалась пустой. Повторите попытку. + Не удалось загрузить ({0}). + Запрос не выполнен ({0}) + Сессия истекла. Войдите снова. + Ваш ключ Hubcap недействителен или истёк. + Достигнут дневной лимит Hubcap. + Для этого приложения нет манифеста Hubcap. + Не удалось загрузить с Hubcap ({0}). + Загрузки + Загрузки + Сейчас ничего не загружается. Скачайте уже что-нибудь! + Очередь + История + В очереди + Загрузка + Ожидает вашего решения + Установка + Готово + Ошибка + Отменено + Отменить + Повторить + Удалить + Вверх + Вниз + Очистить историю + Просмотреть + {0} из {1} + Осталось {0} + Требуется действие + Прервано при закрытии приложения. + Разблокировка DLC + Сначала установите игру, чтобы применить фикс. + Приостановлено + Проверка… + Пауза + Продолжить + Файлы депо + Депо · {0} из {1} + Не удалось получить загрузчик депо. + Ключи расшифровки для этой игры не найдены. + Депо {0} не удалось: {1} + Загружено депо: {0} в {1}. + Скачать + Выберите депо для загрузки + Скачать {0} депо ({1}) + Это депо не объявляет версию для загрузки. + Выбрать все + Депо не выбраны + Выберите, куда сохранить файлы депо + Недостаточно места на диске: нужно {0}, свободно только {1}. + Сохранить в + Нужно {0} · свободно {1} на {2} + Войдите, чтобы скачать это депо. + Войдите, чтобы скачивать депо. + получение manifest + Не удалось получить 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}) из истории загрузок? Загруженные файлы не затрагиваются. + Это исправление уже применено. Откатите его, чтобы установить другое. + Мои игры + Исправления есть для {0} ваших игр. Прокрутите список, чтобы увидеть остальные. + Файлы lua ещё не добавлены. Добавьте (или установите) игру, чтобы увидеть её здесь. + Откатить + Отмена + Откатить «{0}»? Изменённые файлы будут восстановлены из резервной копии, а добавленные исправлением файлы удалены. + Откатить исправление для {0}? + Исправление откачено + Восстановлено {0}, удалено добавленных файлов: {1}. + Не удалось откатить исправление + Нет записи о применении этого исправления. Возможно, его уже откатили. + Не удаётся безопасно откатить фикс + Файл {0} изменился после применения этого фикса, и восстановление перезапишет эти изменения. Если поверх был применён другой фикс, сначала откатите его. Затронуто файлов: {1}. + Исправление откачено частично + Не удалось восстановить файлов: {0}. Закройте игру и повторите попытку. + В этом источнике нет этой игры, либо он недоступен. + Источники манифестов + Дополнительные источники, описанные в файлах .json. Только данные: файл указывает, откуда брать манифесты, и ничего из него не выполняется. + Открыть папку + Файлов источников пока нет. + источники: {0} diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index dd3d9dd..f2890fc 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 @@ -148,16 +153,17 @@ Steam stängs så att dess filer kan ändras och startas sedan om automatiskt. Alla spel eller nedladdningar som körs stoppas. Avbryt Stäng Steam och fortsätt - Gamla goda SteamTools — byggt av kineserna, älskat av världen. Upplåsaren som startade hela trenden (GreenLuma finns inte, schh). ⚠️ FUNGERAR INTE FÖR TILLFÄLLET, ANVÄND BETTERSTEAMTOOLS - Helt nybyggd remake av SteamTools. Bättre än SteamTools på i stort sett alla sätt. - SteamTools med några smarta patchar — massor av fixar och förbättringar. Återställer inbyggd Steam Cloud via Google Drive / OneDrive! + En fork med öppen källkod av OpenSteamTools som underhålls aktivt av LuaTools-teamet. Med fixar och nya funktioner! Hantera Det gick inte att ladda ner eller starta CloudRedirect. - Nightly-bygge av BetterSteamTools (OST) från den senaste källkoden. De senaste fixarna, men kan vara mindre stabil. + Nightly-bygge av OpenSteamTools från den senaste källkoden. De senaste fixarna, men kan vara mindre stabil. + Nu är du på egen hand + Använd din egen upplåsningslösning, vi rör den inte + Egen unlocker EXPERIMENTELL CLOUDREDIRECT-STÖD - Återinför inbyggt Steam Cloud-stöd via tredjeparts lagringsleverantörer som Google Drive / OneDrive / Cloudflare R2 med flera. För närvarande endast kompatibelt med BetterSteamTools Nightly. Glöm inte att konfigurera det genom att klicka på "Hantera" efter aktivering och starta sedan om Steam! - Välj BetterSteamTools Nightly för att använda CloudRedirect. + Återinför inbyggt Steam Cloud-stöd via tredjeparts lagringsleverantörer som Google Drive / OneDrive / Cloudflare R2 med flera. För närvarande endast kompatibelt med OpenSteamTools / BetterSteamTools. Glöm inte att konfigurera det genom att klicka på "Hantera" efter aktivering och starta sedan om Steam! + Välj OpenSteamTools / BetterSteamTools för att använda CloudRedirect. Aktivera Inaktivera Aktiverat. @@ -207,7 +213,6 @@ {0} manifest {0} installerade i Steam. {0} fil(er) misslyckades — stäng Steam och försök igen. - Starta om Steam för att tillämpa. Sök efter namn eller App ID... Läser in… App ID: {0} @@ -275,13 +280,13 @@ Delad depå DLC {0} Ta bort lua-fil - Ta bort "{0}" (App ID {1})? + Ta bort ”{0}” (App ID {1})? -Detta tar bort dess .lua-fil från Steam\config\stplug-in. Starta om Steam efteråt för att ändringen ska träda i kraft. +Detta raderar dess .lua-fil från Steam\config\stplug-in. Ta bort lua-filer Ta bort {0} lua-filer? -Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efteråt för att ändringarna ska träda i kraft. +Detta raderar .lua-filerna från Steam\config\stplug-in. Borttagningen misslyckades Det gick inte att ta bort filen: {0} @@ -289,7 +294,6 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter {1} {0} filer kunde inte tas bort. Starta om Steam - Starta om Steam nu så att ändringarna träder i kraft? Det gick inte att hitta eller starta Steam. Ange dess plats i Inställningar. Fixar Läser in fixar… @@ -306,8 +310,7 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter Installationen misslyckades Det gick inte att installera — stäng Steam (eller starta om Steam) och försök igen. Fix installerad - Manifest för {0} installerat — Steam startar om. - Manifest för {0} installerat. Starta om Steam för att tillämpa. + Manifest för {0} installerat. Spelet hittades inte Installera {0} i Steam först och tillämpa sedan fixen. Fix delvis tillämpad @@ -347,13 +350,12 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter 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. Det gick inte att installera {0} fil(er) — stäng Steam (eller använd Starta om Steam) och försök igen. - {0} tillagt — lua + {1} manifest. Starta om Steam för att tillämpa. - {0} tillagt — Steam hämtar manifesten. Starta om Steam för att tillämpa. + Lade till {0}: lua + {1} manifest. + Lade till {0}. Steam hämtar manifesten. Öppna på SteamDB DELAD Detta ersätter den installerade lua-filen. Granska vad som ändras: @@ -450,9 +452,9 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter Ta bort den här förinställningen? ”{0}” tas bort från dina sparade förinställningar. Det påverkar inte själva spelet. Den förinställning som används kan inte tas bort. - Använder nu ”{0}”. Starta om Steam för att det ska börja gälla. + Använder nu ”{0}”. Kunde inte byta förinställning — lua-filen kan vara upptagen. - Sparat. Starta om Steam för att det ska börja gälla. + Sparat. Kunde inte spara — lua-filen kan vara upptagen. Sparad som förinställning. Spara till ”{0}” @@ -498,4 +500,134 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter Startalternativen återställdes Steam har skrivit över dina startalternativ för {0} spel. Att tillämpa dem igen stänger och öppnar Steam. Tillämpa igen + Steam-platsen hittades inte. Ange den i Inställningar. + Kunde inte nå GitHub. Kontrollera din anslutning och försök igen. + Kunde inte nå uppdateringsservern. Kontrollera din anslutning och försök igen. + Avbruten. + Avbruten av användaren + Den förväntade nedladdningen saknas i utgåvan. + {0} saknas i utgåvan. + Nedladdningen klarade inte verifieringen (sha256 stämmer inte). + {0} klarade inte verifieringen (sha256 stämmer inte). + Saknas i nedladdningen: {0}. + Kunde inte skriva {0} fil(er). Stäng Steam och försök igen. + Kunde inte skriva {0}. Stäng Steam och försök igen. + Kunde inte öppna nedladdningen: {0} + Ingen tillgänglig källa för det här spelet. + Tidsgränsen för inloggning överskreds. Inget svar från webbläsaren. + Tokenutbytet misslyckades ({0}): {1} + Tokenutbytet returnerade en tom session. + Inte inloggad. + Uppdatering av sessionen misslyckades ({0}). + Uppdateringen av sessionen returnerade en tom session. + Nedladdningslänken var tom. Försök igen. + Nedladdningen misslyckades ({0}). + Begäran misslyckades ({0}) + Sessionen har upphört. Logga in igen. + Din Hubcap-nyckel är ogiltig eller har upphört. + Du har nått din dagliga Hubcap-gräns. + Det finns inget Hubcap-manifest för den här appen. + Hubcap-nedladdningen misslyckades ({0}). + Nedladdningar + Nedladdningar + Inga nedladdningar just nu. Kom igen – ladda ner något! + Kö + Historik + I kö + Laddar ner + Väntar på dig + Installerar + Klar + Misslyckades + Avbruten + Avbryt + Försök igen + Ta bort + Flytta upp + Flytta ner + Rensa historik + Granska + {0} av {1} + {0} kvar + Åtgärd krävs + Avbröts när appen stängdes. + DLC-upplåsning + Installera spelet först för att tillämpa en fix. + Pausad + Verifierar… + Pausa + Återuppta + Depåfiler + Depåer · {0} av {1} + Kunde inte hämta depånedladdaren. + Inga dekrypteringsnycklar hittades för det här spelet. + Depå {0} misslyckades: {1} + Laddade ner {0} depåer till {1}. + Ladda ner + Välj depåer att ladda ner + Ladda ner {0} depåer ({1}) + Den här depån anger ingen version att ladda ner. + Markera alla + Inga depåer valda + Välj var depåfilerna ska sparas + Inte tillräckligt med diskutrymme: kräver {0}, bara {1} ledigt. + Spara i + Kräver {0} · {1} ledigt på {2} + Logga in för att ladda ner den här depån. + 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 + 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. + 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. + 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. + 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 63adfbb..569f177 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 เปลี่ยน… เปิด @@ -148,16 +153,17 @@ Steam จะถูกปิดเพื่อให้สามารถเปลี่ยนไฟล์ได้ จากนั้นจะรีสตาร์ทโดยอัตโนมัติ เกมหรือการดาวน์โหลดที่กำลังทำงานทั้งหมดจะหยุดลง ยกเลิก ปิด Steam และดำเนินการต่อ - SteamTools ตัวเก่าแก่ — สร้างโดยชาวจีน เป็นที่รักทั่วโลก ตัวปลดล็อกที่จุดกระแสทั้งหมด (GreenLuma ไม่มีอยู่จริง จุ๊ ๆ) ⚠️ ขณะนี้ใช้ไม่ได้ ให้ใช้ BETTERSTEAMTOOLS - เวอร์ชันสร้างใหม่จากศูนย์ของ SteamTools ดีกว่า SteamTools แทบทุกด้าน - SteamTools พร้อมแพตช์เจ๋ง ๆ ไม่กี่ตัว — การแก้ไขและการปรับปรุงมากมาย นำ Steam Cloud ดั้งเดิมกลับมาผ่าน Google Drive / OneDrive! + ฟอร์กโอเพนซอร์สของ OpenSteamTools ที่ทีม LuaTools ดูแลอย่างต่อเนื่อง พร้อมการแก้ไขและฟีเจอร์ใหม่ ๆ! จัดการ ไม่สามารถดาวน์โหลดหรือเปิด CloudRedirect ได้ - บิลด์รายคืนของ BetterSteamTools (OST) จากซอร์สล่าสุด มีการแก้ไขล่าสุด แต่อาจเสถียรน้อยกว่า + บิลด์รายคืนของ OpenSteamTools จากซอร์สล่าสุด มีการแก้ไขล่าสุด แต่อาจเสถียรน้อยกว่า + จากนี้ไปตัวใครตัวมัน + ใช้วิธีปลดล็อกของคุณเองได้เลย เราจะไม่ไปยุ่งกับมัน + ตัวปลดล็อกของคุณเอง ทดลอง รองรับ CloudRedirect - นำการรองรับ Steam Cloud ในตัวกลับมาผ่านผู้ให้บริการพื้นที่จัดเก็บภายนอก เช่น Google Drive / OneDrive / Cloudflare R2 และอื่น ๆ ขณะนี้ใช้ได้กับ BetterSteamTools Nightly เท่านั้น อย่าลืมตั้งค่าด้วยการคลิก "จัดการ" หลังเปิดใช้งาน แล้วรีสตาร์ท Steam! - เลือก BetterSteamTools Nightly เพื่อใช้ CloudRedirect + นำการรองรับ Steam Cloud ในตัวกลับมาผ่านผู้ให้บริการพื้นที่จัดเก็บภายนอก เช่น Google Drive / OneDrive / Cloudflare R2 และอื่น ๆ ขณะนี้ใช้ได้กับ OpenSteamTools / BetterSteamTools เท่านั้น อย่าลืมตั้งค่าด้วยการคลิก "จัดการ" หลังเปิดใช้งาน แล้วรีสตาร์ท Steam! + เลือก OpenSteamTools / BetterSteamTools เพื่อใช้ CloudRedirect เปิดใช้งาน ปิดใช้งาน เปิดใช้งานอยู่ @@ -207,7 +213,6 @@ {0} manifest ติดตั้ง {0} ลงใน Steam แล้ว {0} ไฟล์ล้มเหลว — ปิด Steam แล้วลองอีกครั้ง - รีสตาร์ท Steam เพื่อนำไปใช้ ค้นหาด้วยชื่อหรือ App ID... กำลังโหลด… App ID: {0} @@ -275,13 +280,13 @@ depot ที่ใช้ร่วมกัน DLC {0} ลบไฟล์ lua - ลบ "{0}" (App ID {1}) หรือไม่? + นำ “{0}” (App ID {1}) ออกหรือไม่? -การดำเนินการนี้จะลบไฟล์ .lua ของมันออกจาก Steam\config\stplug-in หลังจากนั้นให้รีสตาร์ท Steam เพื่อให้การเปลี่ยนแปลงมีผล +การทำเช่นนี้จะลบไฟล์ .lua ออกจาก Steam\config\stplug-in ลบไฟล์ lua - ลบไฟล์ lua {0} ไฟล์หรือไม่? + นำไฟล์ lua {0} ไฟล์ออกหรือไม่? -การดำเนินการนี้จะลบไฟล์ .lua ออกจาก Steam\config\stplug-in หลังจากนั้นให้รีสตาร์ท Steam เพื่อให้การเปลี่ยนแปลงมีผล +การทำเช่นนี้จะลบไฟล์ .lua ออกจาก Steam\config\stplug-in การลบล้มเหลว ไม่สามารถลบไฟล์ได้: {0} @@ -289,7 +294,6 @@ {1} ไม่สามารถลบ {0} ไฟล์ได้ รีสตาร์ท Steam - รีสตาร์ท Steam ตอนนี้เพื่อให้การเปลี่ยนแปลงมีผลหรือไม่? ไม่พบหรือไม่สามารถเปิด Steam ได้ ตั้งค่าตำแหน่งในการตั้งค่า การแก้ไข กำลังโหลดการแก้ไข… @@ -306,8 +310,7 @@ การติดตั้งล้มเหลว ไม่สามารถติดตั้งได้ — ปิด Steam (หรือรีสตาร์ท Steam) แล้วลองอีกครั้ง ติดตั้งการแก้ไขแล้ว - ติดตั้ง manifest ของ {0} แล้ว — Steam กำลังรีสตาร์ท - ติดตั้ง manifest ของ {0} แล้ว รีสตาร์ท Steam เพื่อนำไปใช้ + ติดตั้งแมนิเฟสต์ของ {0} แล้ว ไม่พบเกม ติดตั้ง {0} ใน Steam ก่อน จากนั้นจึงใช้การแก้ไข ใช้การแก้ไขบางส่วนแล้ว @@ -347,13 +350,12 @@ ไม่สามารถระบุเกมหลักของ DLC นี้ได้ มีบางอย่างผิดพลาด — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง การดาวน์โหลดล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง - การสร้างล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง แทนที่ "{0}" หรือไม่? ไม่มีการเปลี่ยนแปลง depot/DLC — เนื้อหาเหมือนเดิม ยกเลิกการติดตั้งแล้ว — ไฟล์ที่มีอยู่ไม่เปลี่ยนแปลง ไม่สามารถติดตั้ง {0} ไฟล์ได้ — ปิด Steam (หรือใช้ รีสตาร์ท Steam) แล้วลองอีกครั้ง - เพิ่ม {0} แล้ว — lua + {1} manifest รีสตาร์ท Steam เพื่อนำไปใช้ - เพิ่ม {0} แล้ว — Steam จะดึง manifest รีสตาร์ท Steam เพื่อนำไปใช้ + เพิ่ม {0} แล้ว: lua + แมนิเฟสต์ {1} รายการ + เพิ่ม {0} แล้ว Steam จะดึงแมนิเฟสต์ให้ เปิดใน SteamDB ใช้ร่วมกัน การดำเนินการนี้จะแทนที่ lua ที่ติดตั้งไว้ ตรวจสอบสิ่งที่เปลี่ยนแปลง: @@ -450,9 +452,9 @@ ลบพรีเซ็ตนี้หรือไม่? “{0}” จะถูกลบออกจากพรีเซ็ตที่คุณบันทึกไว้ ซึ่งไม่มีผลต่อตัวเกม ไม่สามารถลบพรีเซ็ตที่กำลังใช้งานอยู่ - ตอนนี้ใช้ “{0}” แล้ว รีสตาร์ท Steam เพื่อให้มีผล + กำลังใช้ “{0}” แล้ว สลับพรีเซ็ตไม่สำเร็จ — ไฟล์ lua อาจกำลังถูกใช้งานอยู่ - บันทึกแล้ว รีสตาร์ท Steam เพื่อให้มีผล + บันทึกแล้ว บันทึกไม่สำเร็จ — ไฟล์ lua อาจกำลังถูกใช้งานอยู่ บันทึกเป็นพรีเซ็ตแล้ว บันทึกลงใน “{0}” @@ -498,4 +500,134 @@ ตัวเลือกการเปิดเกมถูกรีเซ็ต Steam เขียนทับตัวเลือกการเปิดเกมของคุณสำหรับ {0} เกม การใช้ใหม่จะปิดแล้วเปิด Steam ใหม่ ใช้อีกครั้ง + ไม่พบตำแหน่งของ Steam ตั้งค่าได้ในหน้าการตั้งค่า + เชื่อมต่อ GitHub ไม่ได้ ตรวจสอบการเชื่อมต่อแล้วลองใหม่ + เชื่อมต่อเซิร์ฟเวอร์อัปเดตไม่ได้ ตรวจสอบการเชื่อมต่อแล้วลองใหม่ + ยกเลิกแล้ว + ผู้ใช้ยกเลิก + รีลีสนี้ไม่มีไฟล์ดาวน์โหลดที่ต้องการ + รีลีสนี้ไม่มี {0} + การดาวน์โหลดไม่ผ่านการตรวจสอบ (sha256 ไม่ตรงกัน) + {0} ไม่ผ่านการตรวจสอบ (sha256 ไม่ตรงกัน) + ไฟล์ที่ขาดในการดาวน์โหลด: {0} + เขียนไฟล์ไม่สำเร็จ {0} ไฟล์ ปิด Steam แล้วลองใหม่ + เขียน {0} ไม่สำเร็จ ปิด Steam แล้วลองใหม่ + เปิดไฟล์ที่ดาวน์โหลดไม่ได้: {0} + ไม่มีแหล่งที่ใช้ได้สำหรับเกมนี้ + หมดเวลาเข้าสู่ระบบ ไม่มีการตอบกลับจากเบราว์เซอร์ + แลกเปลี่ยนโทเคนไม่สำเร็จ ({0}): {1} + การแลกเปลี่ยนโทเคนคืนค่าเซสชันว่าง + ยังไม่ได้เข้าสู่ระบบ + รีเฟรชเซสชันไม่สำเร็จ ({0}) + การรีเฟรชเซสชันคืนค่าเซสชันว่าง + ลิงก์ดาวน์โหลดว่างเปล่า ลองใหม่อีกครั้ง + ดาวน์โหลดไม่สำเร็จ ({0}) + คำขอไม่สำเร็จ ({0}) + เซสชันหมดอายุ กรุณาเข้าสู่ระบบใหม่ + คีย์ Hubcap ของคุณไม่ถูกต้องหรือหมดอายุ + คุณใช้ Hubcap ครบตามขีดจำกัดรายวันแล้ว + ไม่มี manifest ของ Hubcap สำหรับแอปนี้ + ดาวน์โหลดจาก Hubcap ไม่สำเร็จ ({0}) + การดาวน์โหลด + การดาวน์โหลด + ยังไม่มีการดาวน์โหลด มาเริ่มดาวน์โหลดอะไรสักอย่างกัน! + คิว + ประวัติ + อยู่ในคิว + กำลังดาวน์โหลด + รอคุณยืนยัน + กำลังติดตั้ง + เสร็จแล้ว + ล้มเหลว + ยกเลิกแล้ว + ยกเลิก + ลองใหม่ + นำออก + เลื่อนขึ้น + เลื่อนลง + ล้างประวัติ + ตรวจสอบ + {0} จาก {1} + เหลืออีก {0} + ต้องดำเนินการ + ถูกขัดจังหวะเมื่อปิดแอป + ปลดล็อก DLC + ติดตั้งเกมก่อนจึงจะใช้ฟิกซ์ได้ + หยุดชั่วคราว + กำลังตรวจสอบ… + หยุดชั่วคราว + ทำต่อ + ไฟล์ depot + Depot · {0} จาก {1} + ไม่สามารถดึงตัวดาวน์โหลด depot ได้ + ไม่พบคีย์ถอดรหัสสำหรับเกมนี้ + Depot {0} ล้มเหลว: {1} + ดาวน์โหลด {0} depot ไปที่ {1} แล้ว + ดาวน์โหลด + เลือก depot ที่จะดาวน์โหลด + ดาวน์โหลด {0} depot ({1}) + depot นี้ไม่ได้ระบุเวอร์ชันให้ดาวน์โหลด + เลือกทั้งหมด + ยังไม่ได้เลือก depot + เลือกที่จัดเก็บไฟล์ depot + พื้นที่ดิสก์ไม่พอ: ต้องการ {0} แต่เหลือเพียง {1} + บันทึกไปที่ + ต้องการ {0} · เหลือ {1} บน {2} + ลงชื่อเข้าใช้เพื่อดาวน์โหลด depot นี้ + ลงชื่อเข้าใช้เพื่อดาวน์โหลด depot + กำลังดึง manifest + ไม่สามารถดึง 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} รายการออกจากประวัติการดาวน์โหลดหรือไม่ ไฟล์ที่ดาวน์โหลดแล้วจะไม่ได้รับผลกระทบ + แก้ไขนี้ถูกใช้งานอยู่แล้ว ย้อนกลับก่อนหากต้องการติดตั้งอันอื่น + เกมของฉัน + เกมของคุณ {0} เกมมีตัวแก้ไข เลื่อนรายการเพื่อดูเกมที่เหลือ + ยังไม่ได้เพิ่ม lua เพิ่ม (หรือติดตั้ง) เกมเพื่อให้แสดงที่นี่ + ย้อนกลับ + ยกเลิก + ย้อนกลับ "{0}" หรือไม่ ไฟล์ที่ถูกแก้ไขจะถูกกู้คืนจากข้อมูลสำรอง และไฟล์ที่ตัวแก้ไขเพิ่มไว้จะถูกลบ + ย้อนกลับตัวแก้ไขของ {0} หรือไม่ + ย้อนกลับตัวแก้ไขแล้ว + กู้คืน {0} รายการ ลบไฟล์ที่เพิ่ม {1} รายการ + ย้อนกลับตัวแก้ไขไม่สำเร็จ + ไม่พบบันทึกว่าเคยใช้ตัวแก้ไขนี้ อาจถูกย้อนกลับไปแล้ว + ไม่สามารถย้อนกลับแพตช์ได้อย่างปลอดภัย + {0} มีการเปลี่ยนแปลงหลังจากใช้แพตช์นี้ การกู้คืนจะเขียนทับการเปลี่ยนแปลงนั้น หากคุณใช้แพตช์อื่นทับไว้ ให้ย้อนกลับแพตช์นั้นก่อน มีไฟล์ที่ได้รับผลกระทบ {1} ไฟล์ + ย้อนกลับตัวแก้ไขได้บางส่วน + กู้คืนไฟล์ {0} รายการไม่สำเร็จ กรุณาปิดเกมแล้วลองใหม่ + แหล่งนี้ไม่มีเกมนี้ หรือเข้าถึงไม่ได้ + แหล่งแมนิเฟสต์ + แหล่งเพิ่มเติมที่ประกาศไว้ในไฟล์ .json เป็นข้อมูลล้วน ๆ ไฟล์บอกเพียงว่าจะดึงแมนิเฟสต์จากที่ใด และไม่มีอะไรในไฟล์ถูกรัน + เปิดโฟลเดอร์ + ยังไม่มีไฟล์แหล่งข้อมูล + แหล่ง: {0} diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 7645184..6e0ba73 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ç @@ -148,16 +153,17 @@ Dosyalarının değiştirilebilmesi için Steam kapatılacak, ardından otomatik olarak yeniden başlatılacaktır. Çalışan tüm oyunlar veya indirmeler duracaktır. İptal Steam'i kapat ve devam et - Eski güzel SteamTools — Çinliler tarafından yapıldı, dünya tarafından sevildi. Tüm bu akımı başlatan kilit açıcı (GreenLuma diye bir şey yok, şşş). ⚠️ ŞU AN ÇALIŞMIYOR, AŞAĞIDAKİ BETTERSTEAMTOOLS'Yİ KULLANIN - SteamTools'un sıfırdan yeniden yapımı. Neredeyse her açıdan SteamTools'tan daha iyi. - Birkaç şık yama eklenmiş SteamTools — bir sürü düzeltme ve iyileştirme. Google Drive / OneDrive ile yerel Steam Cloud'u geri getirir! + LuaTools ekibi tarafından aktif olarak geliştirilen açık kaynaklı bir OpenSteamTools fork'u. Düzeltmeler ve yeni özelliklerle! Yönet CloudRedirect indirilemedi veya başlatılamadı. - BetterSteamTools'un (OST) en son kaynaktan alınan gecelik derlemesi. En yeni düzeltmeler, ancak daha az kararlı olabilir. + OpenSteamTools'un en son kaynaktan alınan gecelik derlemesi. En yeni düzeltmeler, ancak daha az kararlı olabilir. + Gerisi sana kalmış + Kendi kilit açma çözümünü kullan, ona karışmayacağız + Özel kilit açıcı DENEYSEL CLOUDREDIRECT DESTEĞİ - Google Drive / OneDrive / Cloudflare R2 gibi üçüncü taraf depolama sağlayıcıları aracılığıyla yerleşik Steam Cloud desteğini geri getirir. Şu anda yalnızca BetterSteamTools Nightly ile uyumludur. Etkinleştirdikten sonra "Yönet"e tıklayarak yapılandırmayı ve ardından Steam'i yeniden başlatmayı unutmayın! - CloudRedirect'i kullanmak için BetterSteamTools Nightly'yi seçin. + Google Drive / OneDrive / Cloudflare R2 gibi üçüncü taraf depolama sağlayıcıları aracılığıyla yerleşik Steam Cloud desteğini geri getirir. Şu anda yalnızca OpenSteamTools / BetterSteamTools ile uyumludur. Etkinleştirdikten sonra "Yönet"e tıklayarak yapılandırmayı ve ardından Steam'i yeniden başlatmayı unutmayın! + CloudRedirect'i kullanmak için OpenSteamTools / BetterSteamTools'u seçin. Etkinleştir Devre dışı bırak Etkin. @@ -207,7 +213,6 @@ {0} manifesto {0}, Steam'e kuruldu. {0} dosya başarısız oldu — Steam'i kapatıp yeniden deneyin. - Uygulamak için Steam'i yeniden başlatın. Ada veya App ID'ye göre ara... Yükleniyor… App ID: {0} @@ -275,13 +280,13 @@ Paylaşılan depo DLC {0} Lua dosyasını kaldır - "{0}" (App ID {1}) kaldırılsın mı? + “{0}” (App ID {1}) kaldırılsın mı? -Bu işlem, .lua dosyasını Steam\config\stplug-in konumundan siler. Değişikliğin uygulanması için ardından Steam'i yeniden başlatın. +Bu, .lua dosyasını Steam\config\stplug-in konumundan siler. Lua dosyalarını kaldır {0} lua dosyası kaldırılsın mı? -Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişikliklerin uygulanması için ardından Steam'i yeniden başlatın. +Bu, .lua dosyalarını Steam\config\stplug-in konumundan siler. Kaldırma başarısız Dosya silinemedi: {0} @@ -289,7 +294,6 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik {1} {0} dosya silinemedi. Steam'i Yeniden Başlat - Değişikliklerin uygulanması için Steam şimdi yeniden başlatılsın mı? Steam bulunamadı veya başlatılamadı. Konumunu Ayarlar'da belirtin. Düzeltmeler Düzeltmeler yükleniyor… @@ -306,8 +310,7 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik Kurulum başarısız Kurulamadı — Steam'i kapatın (veya Steam'i yeniden başlatın) ve tekrar deneyin. Düzeltme kuruldu - {0} manifestosu kuruldu — Steam yeniden başlatılıyor. - {0} manifestosu kuruldu. Uygulamak için Steam'i yeniden başlatın. + {0} manifesti kuruldu. Oyun bulunamadı Önce {0} oyununu Steam'e kurun, ardından düzeltmeyi uygulayın. Düzeltme kısmen uygulandı @@ -347,13 +350,12 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik 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. {0} dosya kurulamadı — Steam'i kapatın (veya Steam'i Yeniden Başlat'ı kullanın) ve tekrar deneyin. - {0} eklendi — lua + {1} manifesto. Uygulamak için Steam'i yeniden başlatın. - {0} eklendi — Steam manifestoları getirecek. Uygulamak için Steam'i yeniden başlatın. + {0} eklendi: lua + {1} manifest. + {0} eklendi. Steam manifestleri indirecek. SteamDB'de aç PAYLAŞILAN Bu, kurulu lua'yı değiştirir. Nelerin değiştiğini inceleyin: @@ -450,9 +452,9 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik Bu ön ayar silinsin mi? “{0}” kayıtlı ön ayarlarınızdan kaldırılacak. Bu, oyunun kendisini etkilemez. Kullanımda olan ön ayar silinemez. - Artık “{0}” kullanılıyor. Etkili olması için Steam'i yeniden başlatın. + Artık “{0}” kullanılıyor. Ön ayar değiştirilemedi — lua dosyası kullanımda olabilir. - Kaydedildi. Etkili olması için Steam'i yeniden başlatın. + Kaydedildi. Kaydedilemedi — lua dosyası kullanımda olabilir. Ön ayar olarak kaydedildi. “{0}” içine kaydet @@ -498,4 +500,134 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik Başlatma seçenekleri sıfırlandı Steam, {0} oyun için başlatma seçeneklerinizin üzerine yazdı. Yeniden uygulamak Steam'i kapatıp açar. Yeniden uygula + Steam konumu bulunamadı. Ayarlar'dan belirle. + GitHub'a ulaşılamadı. Bağlantını kontrol edip tekrar dene. + Güncelleme sunucusuna ulaşılamadı. Bağlantını kontrol edip tekrar dene. + İptal edildi. + Kullanıcı tarafından iptal edildi + Sürümde beklenen indirme dosyası yok. + Sürümde {0} yok. + İndirme doğrulamayı geçemedi (sha256 uyuşmuyor). + {0} doğrulamayı geçemedi (sha256 uyuşmuyor). + İndirmede eksik: {0}. + {0} dosya yazılamadı. Steam'i kapatıp tekrar dene. + {0} yazılamadı. Steam'i kapatıp tekrar dene. + İndirme açılamadı: {0} + Bu oyun için kullanılabilir kaynak yok. + Oturum açma zaman aşımına uğradı. Tarayıcıdan yanıt gelmedi. + Token değişimi başarısız ({0}): {1} + Token değişimi boş bir oturum döndürdü. + Oturum açılmadı. + Oturum yenileme başarısız ({0}). + Oturum yenileme boş bir oturum döndürdü. + İndirme bağlantısı boştu. Tekrar dene. + İndirme başarısız ({0}). + İstek başarısız ({0}) + Oturum süresi doldu. Lütfen tekrar giriş yap. + Hubcap anahtarın geçersiz veya süresi dolmuş. + Günlük Hubcap sınırına ulaştın. + Bu uygulama için Hubcap manifesti yok. + Hubcap indirmesi başarısız ({0}). + İndirmeler + İndirmeler + Şu anda indirme yok. Hadi, bir şeyler indir! + Kuyruk + Geçmiş + Kuyrukta + İndiriliyor + Seni bekliyor + Kuruluyor + Tamamlandı + Başarısız + İptal edildi + İptal + Yeniden dene + Kaldır + Yukarı taşı + Aşağı taşı + Geçmişi temizle + İncele + {0} / {1} + {0} kaldı + İşlem gerekiyor + Uygulama kapanınca yarıda kesildi. + DLC kilidi açma + Fix uygulamak için önce oyunu kurun. + Duraklatıldı + Doğrulanıyor… + Duraklat + Sürdür + Depot dosyaları + Depot · {1} içinden {0} + Depot indirici alınamadı. + Bu oyun için şifre çözme anahtarı bulunamadı. + Depot {0} başarısız: {1} + {0} depot {1} konumuna indirildi. + İndir + İndirilecek depotları seç + {0} depot indir ({1}) + Bu depot indirilecek bir sürüm belirtmiyor. + Tümünü seç + Hiç depot seçilmedi + Depot dosyalarının kaydedileceği yeri seç + Yeterli disk alanı yok: {0} gerekiyor, yalnızca {1} boş. + Kaydedilecek yer + {0} gerekiyor · {2} sürücüsünde {1} boş + Bu depotu indirmek için giriş yap. + 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 + 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. + 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 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. + 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 e0bbc4b..ea6423b 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 Змінити… Відкрити @@ -148,16 +153,17 @@ Steam буде закрито, щоб можна було змінити його файли, а потім автоматично перезапущено. Усі запущені ігри або завантаження буде зупинено. Скасувати Закрити Steam і продовжити - Старий добрий SteamTools — створений китайцями, улюблений усім світом. Розблокувальник, з якого почалася вся ця мода (GreenLuma не існує, тсс). ⚠️ НАРАЗІ НЕ ПРАЦЮЄ, ВИКОРИСТОВУЙТЕ BETTERSTEAMTOOLS - Повністю перероблена версія SteamTools, створена з нуля. Краща за SteamTools практично в усьому. - SteamTools із кількома вправними патчами — купа виправлень і покращень. Повертає рідний Steam Cloud через Google Drive / OneDrive! + Форк OpenSteamTools з відкритим кодом, який активно підтримує команда LuaTools. З виправленнями та новими функціями! Керування Не вдалося завантажити або запустити CloudRedirect. - Нічна збірка BetterSteamTools (OST) із найновішого коду. Найсвіжіші виправлення, але може бути менш стабільною. + Нічна збірка OpenSteamTools із найновішого коду. Найсвіжіші виправлення, але може бути менш стабільною. + Далі сам + Використовуй власне рішення для розблокування — ми в нього не лізтимемо + Свій анлокер ЕКСПЕРИМЕНТАЛЬНА ПІДТРИМКА CLOUDREDIRECT - Повертає вбудовану підтримку Steam Cloud через сторонні сховища, як-от Google Drive / OneDrive / Cloudflare R2 та інші. Наразі сумісно лише з BetterSteamTools Nightly. Не забудьте налаштувати його, натиснувши "Керування" після ввімкнення, а потім перезапустіть Steam! - Виберіть BetterSteamTools Nightly, щоб використовувати CloudRedirect. + Повертає вбудовану підтримку Steam Cloud через сторонні сховища, як-от Google Drive / OneDrive / Cloudflare R2 та інші. Наразі сумісно лише з OpenSteamTools / BetterSteamTools. Не забудьте налаштувати його, натиснувши "Керування" після ввімкнення, а потім перезапустіть Steam! + Виберіть OpenSteamTools / BetterSteamTools, щоб використовувати CloudRedirect. Увімкнути Вимкнути Увімкнено. @@ -207,7 +213,6 @@ {0} маніфест(ів) {0} встановлено в Steam. Не вдалося обробити {0} файл(ів) — закрийте Steam і повторіть спробу. - Перезапустіть Steam, щоб застосувати. Пошук за назвою або App ID... Завантаження… App ID: {0} @@ -275,13 +280,13 @@ Спільне депо DLC {0} Видалити файл lua - Видалити «{0}» (App ID {1})? + Вилучити «{0}» (App ID {1})? -Це видалить його файл .lua з Steam\config\stplug-in. Потім перезапустіть Steam, щоб зміна набула чинності. +Це вилучить його файл .lua з Steam\config\stplug-in. Видалити файли lua - Видалити {0} файлів lua? + Вилучити файлів lua: {0}? -Це видалить файли .lua з Steam\config\stplug-in. Потім перезапустіть Steam, щоб зміни набули чинності. +Це вилучить файли .lua з Steam\config\stplug-in. Не вдалося видалити Не вдалося видалити файл: {0} @@ -289,7 +294,6 @@ {1} Не вдалося видалити {0} файлів. Перезапустити Steam - Перезапустити Steam зараз, щоб зміни набули чинності? Не вдалося знайти або запустити Steam. Укажіть його розташування в Налаштуваннях. Фікси Завантаження фіксів… @@ -306,8 +310,7 @@ Помилка встановлення Не вдалося встановити — закрийте Steam (або перезапустіть Steam) і спробуйте ще раз. Фікс встановлено - Маніфест {0} встановлено — Steam перезапускається. - Маніфест {0} встановлено. Перезапустіть Steam, щоб застосувати. + Маніфест {0} встановлено. Гру не знайдено Спочатку встановіть {0} у Steam, а потім застосуйте фікс. Фікс застосовано частково @@ -347,13 +350,12 @@ Не вдалося визначити базову гру для цього DLC Щось пішло не так — перевірте з'єднання і спробуйте ще раз. Помилка завантаження — перевірте з'єднання і спробуйте ще раз. - Помилка генерування — перевірте з'єднання і спробуйте ще раз. Замінити «{0}»? Немає змін депо/DLC — той самий вміст. Встановлення скасовано — наявні файли не змінено. Не вдалося встановити {0} файл(ів) — закрийте Steam (або скористайтеся «Перезапустити Steam») і спробуйте ще раз. - {0} додано — lua + {1} маніфест(ів). Перезапустіть Steam, щоб застосувати. - {0} додано — Steam завантажить маніфести. Перезапустіть Steam, щоб застосувати. + Додано {0}: lua + маніфестів: {1}. + Додано {0}. Steam завантажить маніфести. Відкрити в SteamDB СПІЛЬНИЙ Це замінить встановлений lua. Перегляньте, що зміниться: @@ -450,9 +452,9 @@ Видалити цей пресет? «{0}» буде вилучено зі збережених пресетів. На саму гру це не впливає. Не можна видалити пресет, який зараз використовується. - Тепер використовується «{0}». Перезапустіть Steam, щоб зміни набули чинності. + Тепер використовується «{0}». Не вдалося перемкнути пресет — файл lua може бути зайнятий. - Збережено. Перезапустіть Steam, щоб зміни набули чинності. + Збережено. Не вдалося зберегти — файл lua може бути зайнятий. Збережено як пресет. Зберегти в «{0}» @@ -498,4 +500,134 @@ Параметри запуску було скинуто Steam перезаписав ваші параметри запуску для {0} гри(ігор). Повторне застосування закриє та знову відкриє Steam. Застосувати знову + Теку Steam не знайдено. Вкажіть її в налаштуваннях. + Не вдалося зв'язатися з GitHub. Перевірте підключення та повторіть спробу. + Не вдалося зв'язатися із сервером оновлень. Перевірте підключення та повторіть спробу. + Скасовано. + Скасовано користувачем + У релізі бракує потрібного файлу. + У релізі бракує {0}. + Завантаження не пройшло перевірку (розбіжність sha256). + {0} не пройшов перевірку (розбіжність sha256). + У завантаженні бракує: {0}. + Не вдалося записати файлів: {0}. Закрийте Steam і повторіть спробу. + Не вдалося записати {0}. Закрийте Steam і повторіть спробу. + Не вдалося відкрити завантаження: {0} + Для цієї гри немає доступних джерел. + Час входу вичерпано. Браузер не відповів. + Не вдалося обміняти токен ({0}): {1} + Обмін токена повернув порожню сесію. + Вхід не виконано. + Не вдалося оновити сесію ({0}). + Оновлення сесії повернуло порожню сесію. + Посилання на завантаження виявилося порожнім. Повторіть спробу. + Не вдалося завантажити ({0}). + Запит не виконано ({0}) + Сесія завершилася. Увійдіть знову. + Ваш ключ Hubcap недійсний або прострочений. + Досягнуто денний ліміт Hubcap. + Для цього застосунку немає маніфесту Hubcap. + Не вдалося завантажити з Hubcap ({0}). + Завантаження + Завантаження + Зараз нічого не завантажується. Завантажте вже щось! + Черга + Історія + У черзі + Завантаження + Очікує на вас + Встановлення + Готово + Помилка + Скасовано + Скасувати + Повторити + Вилучити + Вгору + Вниз + Очистити історію + Переглянути + {0} з {1} + Залишилось {0} + Потрібна дія + Перервано під час закриття застосунку. + Розблокування DLC + Спочатку встановіть гру, щоб застосувати фікс. + Призупинено + Перевірка… + Пауза + Продовжити + Файли депо + Депо · {0} з {1} + Не вдалося отримати завантажувач депо. + Ключів розшифрування для цієї гри не знайдено. + Депо {0} не вдалося: {1} + Завантажено депо: {0} до {1}. + Завантажити + Виберіть депо для завантаження + Завантажити {0} депо ({1}) + Це депо не оголошує версію для завантаження. + Вибрати все + Депо не вибрано + Виберіть, куди зберегти файли депо + Недостатньо місця на диску: потрібно {0}, вільно лише {1}. + Зберегти в + Потрібно {0} · вільно {1} на {2} + Увійдіть, щоб завантажити це депо. + Увійдіть, щоб завантажувати депо. + отримання manifest + Не вдалося отримати 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}) з історії завантажень? Завантажені файли не зачіпаються. + Це виправлення вже застосовано. Відкотіть його, щоб встановити інше. + Мої ігри + Виправлення є для {0} ваших ігор. Прогорніть список, щоб побачити решту. + Файли lua ще не додано. Додайте (або встановіть) гру, щоб побачити її тут. + Відкотити + Скасувати + Відкотити «{0}»? Змінені файли буде відновлено з резервної копії, а додані виправленням файли видалено. + Відкотити виправлення для {0}? + Виправлення відкочено + Відновлено {0}, вилучено доданих файлів: {1}. + Не вдалося відкотити виправлення + Немає запису про застосування цього виправлення. Можливо, його вже відкотили. + Не вдається безпечно скасувати фікс + Файл {0} змінився після застосування цього фікса, тож відновлення перезапише ці зміни. Якщо згори застосовано інший фікс, спершу скасуйте його. Порушено файлів: {1}. + Виправлення відкочено частково + Не вдалося відновити файлів: {0}. Закрийте гру та спробуйте ще раз. + У цьому джерелі немає цієї гри, або воно недоступне. + Джерела маніфестів + Додаткові джерела, описані у файлах .json. Лише дані: файл вказує, звідки брати маніфести, і нічого з нього не виконується. + Відкрити теку + Файлів джерел поки немає. + джерела: {0} diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index 2732e14..01ded7d 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ở @@ -148,16 +153,17 @@ Steam sẽ được đóng để có thể thay đổi các tệp của nó, sau đó tự động khởi động lại. Mọi trò chơi hoặc lượt tải đang chạy sẽ dừng lại. Hủy Đóng Steam và tiếp tục - SteamTools quen thuộc — do người Trung Quốc tạo ra, được cả thế giới yêu thích. Công cụ mở khóa đã khởi đầu cả xu hướng (GreenLuma không tồn tại đâu, suỵt). ⚠️ HIỆN KHÔNG HOẠT ĐỘNG, HÃY DÙNG BETTERSTEAMTOOLS - Bản làm lại từ đầu của SteamTools. Tốt hơn SteamTools ở gần như mọi mặt. - SteamTools với một vài bản vá khéo léo — vô số bản sửa lỗi và cải tiến. Khôi phục Steam Cloud gốc qua Google Drive / OneDrive! + Một bản fork mã nguồn mở của OpenSteamTools, được nhóm LuaTools duy trì tích cực. Mang đến các bản sửa lỗi và tính năng mới! Quản lý Không thể tải xuống hoặc khởi chạy CloudRedirect. - Bản dựng nightly của BetterSteamTools (OST) từ mã nguồn mới nhất. Các bản sửa lỗi mới nhất, nhưng có thể kém ổn định hơn. + Bản dựng nightly của OpenSteamTools từ mã nguồn mới nhất. Các bản sửa lỗi mới nhất, nhưng có thể kém ổn định hơn. + Từ giờ bạn tự lo + Dùng giải pháp mở khóa của riêng bạn, chúng tôi sẽ không đụng vào + Trình mở khóa tùy chỉnh THỬ NGHIỆM HỖ TRỢ CLOUDREDIRECT - Mang lại hỗ trợ Steam Cloud gốc thông qua các nhà cung cấp lưu trữ bên thứ ba như Google Drive / OneDrive / Cloudflare R2 và nhiều hơn nữa. Hiện chỉ tương thích với BetterSteamTools Nightly. Đừng quên cấu hình bằng cách nhấp "Quản lý" sau khi bật, rồi khởi động lại Steam! - Chọn BetterSteamTools Nightly để dùng CloudRedirect. + Mang lại hỗ trợ Steam Cloud gốc thông qua các nhà cung cấp lưu trữ bên thứ ba như Google Drive / OneDrive / Cloudflare R2 và nhiều hơn nữa. Hiện chỉ tương thích với OpenSteamTools / BetterSteamTools. Đừng quên cấu hình bằng cách nhấp "Quản lý" sau khi bật, rồi khởi động lại Steam! + Chọn OpenSteamTools / BetterSteamTools để dùng CloudRedirect. Bật Tắt Đã bật. @@ -207,7 +213,6 @@ {0} manifest Đã cài đặt {0} vào Steam. {0} tệp thất bại — đóng Steam và thử lại. - Khởi động lại Steam để áp dụng. Tìm theo tên hoặc App ID... Đang tải… App ID: {0} @@ -275,13 +280,13 @@ depot dùng chung DLC {0} Xóa tệp lua - Xóa "{0}" (App ID {1})? + Gỡ “{0}” (App ID {1})? -Thao tác này sẽ xóa tệp .lua của nó khỏi Steam\config\stplug-in. Sau đó khởi động lại Steam để thay đổi có hiệu lực. +Thao tác này xóa tệp .lua khỏi Steam\config\stplug-in. Xóa tệp lua - Xóa {0} tệp lua? + Gỡ {0} tệp lua? -Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó khởi động lại Steam để các thay đổi có hiệu lực. +Thao tác này xóa các tệp .lua khỏi Steam\config\stplug-in. Xóa thất bại Không thể xóa tệp: {0} @@ -289,7 +294,6 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó {1} Không thể xóa {0} tệp. Khởi động lại Steam - Khởi động lại Steam ngay để các thay đổi có hiệu lực? Không tìm thấy hoặc không thể khởi chạy Steam. Đặt vị trí của nó trong Cài đặt. Bản sửa lỗi Đang tải bản sửa lỗi… @@ -306,8 +310,7 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó Cài đặt thất bại Không thể cài đặt — đóng Steam (hoặc khởi động lại Steam) và thử lại. Đã cài đặt bản sửa lỗi - Đã cài đặt manifest của {0} — Steam đang khởi động lại. - Đã cài đặt manifest của {0}. Khởi động lại Steam để áp dụng. + Đã cài manifest của {0}. Không tìm thấy trò chơi Hãy cài đặt {0} trên Steam trước, rồi áp dụng bản sửa lỗi. Đã áp dụng một phần bản sửa lỗi @@ -347,13 +350,12 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó 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. Không thể cài đặt {0} tệp — đóng Steam (hoặc dùng Khởi động lại Steam) và thử lại. - Đã thêm {0} — lua + {1} manifest. Khởi động lại Steam để áp dụng. - Đã thêm {0} — Steam sẽ lấy các manifest. Khởi động lại Steam để áp dụng. + Đã thêm {0}: lua + {1} manifest. + Đã thêm {0}. Steam sẽ tải manifest. Mở trên SteamDB DÙNG CHUNG Thao tác này sẽ thay thế lua đã cài đặt. Xem lại những gì thay đổi: @@ -450,9 +452,9 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó Xóa cài đặt sẵn này? “{0}” sẽ bị xóa khỏi các cài đặt sẵn đã lưu của bạn. Điều này không ảnh hưởng đến chính trò chơi. Không thể xóa cài đặt sẵn đang được sử dụng. - Hiện đang dùng “{0}”. Khởi động lại Steam để có hiệu lực. + Hiện đang dùng “{0}”. Không thể chuyển cài đặt sẵn — tệp lua có thể đang được sử dụng. - Đã lưu. Khởi động lại Steam để có hiệu lực. + Đã lưu. Không thể lưu — tệp lua có thể đang được sử dụng. Đã lưu thành cài đặt sẵn. Lưu vào “{0}” @@ -498,4 +500,134 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó Các tùy chọn khởi chạy đã bị đặt lại Steam đã ghi đè các tùy chọn khởi chạy của bạn cho {0} trò chơi. Áp dụng lại sẽ đóng và mở lại Steam. Áp dụng lại + Không tìm thấy vị trí Steam. Hãy đặt trong Cài đặt. + Không kết nối được tới GitHub. Kiểm tra kết nối và thử lại. + Không kết nối được tới máy chủ cập nhật. Kiểm tra kết nối và thử lại. + Đã hủy. + Người dùng đã hủy + Bản phát hành thiếu tệp tải xuống cần thiết. + Bản phát hành thiếu {0}. + Tệp tải xuống không qua được kiểm tra (sha256 không khớp). + {0} không qua được kiểm tra (sha256 không khớp). + Thiếu trong bản tải xuống: {0}. + Không ghi được {0} tệp. Hãy đóng Steam và thử lại. + Không ghi được {0}. Hãy đóng Steam và thử lại. + Không mở được tệp tải xuống: {0} + Không có nguồn nào khả dụng cho trò chơi này. + Đăng nhập đã hết thời gian chờ. Trình duyệt không phản hồi. + Trao đổi token thất bại ({0}): {1} + Trao đổi token trả về phiên rỗng. + Chưa đăng nhập. + Làm mới phiên thất bại ({0}). + Làm mới phiên trả về phiên rỗng. + Liên kết tải xuống trống. Hãy thử lại. + Tải xuống thất bại ({0}). + Yêu cầu thất bại ({0}) + Phiên đã hết hạn. Vui lòng đăng nhập lại. + Khóa Hubcap của bạn không hợp lệ hoặc đã hết hạn. + Bạn đã đạt giới hạn Hubcap hằng ngày. + Không có manifest Hubcap cho ứng dụng này. + Tải xuống từ Hubcap thất bại ({0}). + 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! + Hàng đợi + Lịch sử + Đang chờ + Đang tải + Đang chờ bạn + Đang cài đặt + Xong + Thất bại + Đã hủy + Hủy + Thử lại + Xóa + Lên trên + Xuống dưới + Xóa lịch sử + Xem lại + {0} trên {1} + Còn {0} + Cần thao tác + Bị gián đoạn khi đóng ứng dụng. + Mở khóa DLC + Hãy cài game trước để áp dụng bản fix. + Đã tạm dừng + Đang xác minh… + Tạm dừng + Tiếp tục + Tệp depot + Depot · {0} trên {1} + Không lấy được trình tải depot. + Không tìm thấy khoá giải mã cho game này. + Depot {0} thất bại: {1} + Đã tải {0} depot về {1}. + Tải xuống + Chọn depot để tải + Tải {0} depot ({1}) + Depot này không khai báo phiên bản nào để tải. + Chọn tất cả + Chưa chọn depot nào + Chọn nơi lưu tệp depot + Không đủ dung lượng đĩa: cần {0}, chỉ còn trống {1}. + Lưu vào + Cần {0} · còn trống {1} trên {2} + Đăng nhập để tải depot này. + Đă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 + 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. + 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. + 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. + 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 cbe62b9..a988ab0 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 位置 更改… 打开 @@ -156,16 +161,17 @@ 将关闭 Steam 以便更改其文件,随后自动重启。所有正在运行的游戏或下载都会停止。 取消 关闭 Steam 并继续 - 经典的 SteamTools——由中国人打造,受全世界喜爱。开启整个潮流的解锁工具(GreenLuma 并不存在,嘘)。⚠️ 当前无法使用,请使用下方的 BETTERSTEAMTOOLS - 从零打造的 SteamTools 重制版。几乎在各方面都优于 SteamTools。 - 带有若干巧妙补丁的 SteamTools——大量修复与改进。 通过 Google Drive / OneDrive 恢复原生 Steam 云存档! + OpenSteamTools 的开源分支,由 LuaTools 团队积极维护。带来各种修复和新功能! 管理 无法下载或启动 CloudRedirect。 - 从最新源码构建的 BetterSteamTools(OST)每夜版。包含最新修复,但可能不太稳定。 + 从最新源码构建的 OpenSteamTools 每夜版。包含最新修复,但可能不太稳定。 + 全靠你自己了 + 使用你自己的解锁方案,我们不会插手 + 自定义解锁器 实验性 支持 CloudRedirect - 通过 Google Drive / OneDrive / Cloudflare R2 等第三方存储服务,恢复原生 Steam 云存档支持。目前仅兼容 BetterSteamTools 每夜版。启用后别忘了点击“管理”进行配置,然后重启 Steam! - 选择 BetterSteamTools 每夜版以使用 CloudRedirect。 + 通过 Google Drive / OneDrive / Cloudflare R2 等第三方存储服务,恢复原生 Steam 云存档支持。目前仅兼容 OpenSteamTools / BetterSteamTools。启用后别忘了点击“管理”进行配置,然后重启 Steam! + 选择 OpenSteamTools / BetterSteamTools 以使用 CloudRedirect。 启用 禁用 已启用。 @@ -217,7 +223,6 @@ {0} 个清单 已安装 {0} 到 Steam。 {0} 个文件失败——请关闭 Steam 后重试。 - 重启 Steam 以生效。 按名称或 App ID 搜索… @@ -289,13 +294,13 @@ 共享仓库 DLC {0} 删除 lua 文件 - 删除"{0}"(App ID {1})? + 移除“{0}”(App ID {1})? -这将从 Steam\config\stplug-in 中删除其 .lua 文件。删除后请重启 Steam 以使更改生效。 +这将从 Steam\config\stplug-in 删除它的 .lua 文件。 删除 lua 文件 - 删除 {0} 个 lua 文件? + 移除 {0} 个 lua 文件? -这将从 Steam\config\stplug-in 中删除这些 .lua 文件。删除后请重启 Steam 以使更改生效。 +这将从 Steam\config\stplug-in 删除这些 .lua 文件。 删除失败 无法删除文件: {0} @@ -303,7 +308,6 @@ {1} 有 {0} 个文件无法删除。 重启 Steam - 立即重启 Steam 以使更改生效? 无法找到或启动 Steam。请在设置中设置其位置。 @@ -322,8 +326,7 @@ 安装失败 无法安装——请关闭 Steam(或使用"重启 Steam")后重试。 修复已安装 - {0} 清单已安装——Steam 正在重启。 - {0} 清单已安装。重启 Steam 以生效。 + 已安装 {0} 的清单。 未找到游戏 请先在 Steam 中安装 {0},然后再应用修复。 修复部分应用 @@ -365,13 +368,12 @@ 无法确定此 DLC 的本体游戏 出了点问题——请检查你的网络连接后重试。 下载失败——请检查你的网络连接后重试。 - 生成失败——请检查你的网络连接后重试。 替换"{0}"? 仓库/DLC 无变化——内容相同。 安装已取消——现有文件保持不变。 无法安装 {0} 个文件——请关闭 Steam(或使用"重启 Steam")后重试。 - 已添加 {0}——lua + {1} 个清单。重启 Steam 以生效。 - 已添加 {0}——Steam 将自动获取清单。重启 Steam 以生效。 + 已添加 {0}:lua + {1} 个清单。 + 已添加 {0}。Steam 将获取清单。 在 SteamDB 中打开 @@ -470,9 +472,9 @@ 删除这个预设? “{0}”将从你保存的预设中移除。这不会影响游戏本身。 无法删除正在使用的预设。 - 已切换到“{0}”。重启 Steam 后生效。 + 现在使用“{0}”。 切换预设失败 — lua 文件可能正被占用。 - 已保存。重启 Steam 后生效。 + 已保存。 保存失败 — lua 文件可能正被占用。 已另存为预设。 保存到“{0}” @@ -518,4 +520,134 @@ 启动选项已被重置 Steam 覆盖了你为 {0} 款游戏设置的启动选项。重新应用会关闭并重新打开 Steam。 重新应用 + 未找到 Steam 位置。请在设置中指定。 + 无法连接 GitHub。请检查网络连接后重试。 + 无法连接更新服务器。请检查网络连接后重试。 + 已取消。 + 用户已取消 + 该版本缺少所需的下载文件。 + 该版本缺少 {0}。 + 下载未通过校验(sha256 不匹配)。 + {0} 未通过校验(sha256 不匹配)。 + 下载内容缺少:{0}。 + 有 {0} 个文件无法写入。请关闭 Steam 后重试。 + 无法写入 {0}。请关闭 Steam 后重试。 + 无法打开下载的文件:{0} + 该游戏没有可用的来源。 + 登录超时。浏览器没有响应。 + 令牌交换失败({0}):{1} + 令牌交换返回了空会话。 + 尚未登录。 + 会话刷新失败({0})。 + 会话刷新返回了空会话。 + 下载链接为空。请重试。 + 下载失败({0})。 + 请求失败({0}) + 会话已过期。请重新登录。 + 你的 Hubcap 密钥无效或已过期。 + 已达到每日 Hubcap 限额。 + 此应用没有可用的 Hubcap 清单。 + Hubcap 下载失败({0})。 + 下载 + 下载 + 当前没有下载。快去下载点什么吧! + 队列 + 历史记录 + 排队中 + 下载中 + 等待你确认 + 安装中 + 已完成 + 失败 + 已取消 + 取消 + 重试 + 移除 + 上移 + 下移 + 清空历史记录 + 查看 + {0} / {1} + 剩余 {0} + 需要操作 + 应用关闭时被中断。 + DLC 解锁 + 请先安装游戏,然后才能应用修复。 + 已暂停 + 正在校验… + 暂停 + 继续 + 仓库文件 + 仓库 · 第 {0} 个,共 {1} 个 + 无法获取仓库下载器。 + 未找到该游戏的解密密钥。 + 仓库 {0} 失败:{1} + 已将 {0} 个仓库下载到 {1}。 + 下载 + 选择要下载的仓库 + 下载 {0} 个仓库({1}) + 该仓库未声明可下载的版本。 + 全选 + 未选择仓库 + 选择仓库文件的保存位置 + 磁盘空间不足:需要 {0},仅剩 {1}。 + 保存到 + 需要 {0} · {2} 剩余 {1} + 登录后可下载该仓库。 + 登录后可下载仓库。 + 正在获取 manifest + 无法获取该仓库的 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} 条记录吗?已下载的文件不会受影响。 + 此修复已应用。若要安装其他修复,请先还原。 + 我的游戏 + 你的 {0} 款游戏有可用修复。滚动列表可浏览其余内容。 + 尚未添加 lua,添加或安装一款游戏后会显示在这里。 + 还原 + 取消 + 要还原“{0}”吗?修改过的文件将从备份恢复,修复添加的文件将被删除。 + 还原 {0} 的修复? + 修复已还原 + 已恢复 {0} 个,已删除 {1} 个新增文件。 + 无法还原修复 + 没有应用此修复的记录,可能已经还原过了。 + 无法安全还原此修复 + 自应用此修复以来,{0} 已被更改,还原会覆盖这些更改。如果你在其上应用了另一个修复,请先还原那一个。共影响 {1} 个文件。 + 修复部分还原 + 有 {0} 个文件无法恢复。请关闭游戏后重试。 + 此来源没有这个游戏,或无法连接。 + 清单来源 + 在 .json 文件中声明的额外来源。纯数据:文件只说明从哪里获取清单,其中的任何内容都不会被执行。 + 打开文件夹 + 还没有来源文件。 + 来源:{0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 74b0522..ac4f25e 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 位置 變更… 開啟 @@ -148,16 +153,17 @@ 將關閉 Steam 以便變更其檔案,隨後自動重新啟動。所有執行中的遊戲或下載都會停止。 取消 關閉 Steam 並繼續 - 經典的 SteamTools——由中國人打造,受全世界喜愛。開啟整個潮流的解鎖工具(GreenLuma 並不存在,噓)。⚠️ 目前無法使用,請使用下方的 BETTERSTEAMTOOLS - 從零打造的 SteamTools 重製版。幾乎在各方面都優於 SteamTools。 - 帶有若干巧妙修補的 SteamTools——大量修正與改進。 透過 Google Drive / OneDrive 恢復原生 Steam 雲端存檔! + OpenSteamTools 的開源分支,由 LuaTools 團隊積極維護。帶來各種修正和新功能! 管理 無法下載或啟動 CloudRedirect。 - 從最新原始碼建置的 BetterSteamTools(OST)每夜版。包含最新修正,但可能較不穩定。 + 從最新原始碼建置的 OpenSteamTools 每夜版。包含最新修正,但可能較不穩定。 + 全靠你自己了 + 使用你自己的解鎖方案,我們不會插手 + 自訂解鎖器 實驗性 支援 CloudRedirect - 透過 Google Drive / OneDrive / Cloudflare R2 等第三方儲存服務,恢復原生 Steam 雲端存檔支援。目前僅相容於 BetterSteamTools 每夜版。啟用後別忘了點擊「管理」進行設定,然後重新啟動 Steam! - 選擇 BetterSteamTools 每夜版以使用 CloudRedirect。 + 透過 Google Drive / OneDrive / Cloudflare R2 等第三方儲存服務,恢復原生 Steam 雲端存檔支援。目前僅相容於 OpenSteamTools / BetterSteamTools。啟用後別忘了點擊「管理」進行設定,然後重新啟動 Steam! + 選擇 OpenSteamTools / BetterSteamTools 以使用 CloudRedirect。 啟用 停用 已啟用。 @@ -207,7 +213,6 @@ {0} 個資訊清單 已安裝 {0} 到 Steam。 {0} 個檔案失敗——請關閉 Steam 後重試。 - 重新啟動 Steam 以套用。 依名稱或 App ID 搜尋... 載入中… App ID:{0} @@ -275,13 +280,13 @@ 共享倉庫 DLC {0} 刪除 lua 檔案 - 刪除「{0}」(App ID {1})? + 移除「{0}」(App ID {1})? -這會從 Steam\config\stplug-in 中刪除其 .lua 檔案。刪除後請重新啟動 Steam 以使變更生效。 +這會從 Steam\config\stplug-in 刪除它的 .lua 檔案。 刪除 lua 檔案 - 刪除 {0} 個 lua 檔案? + 移除 {0} 個 lua 檔案? -這會從 Steam\config\stplug-in 中刪除這些 .lua 檔案。刪除後請重新啟動 Steam 以使變更生效。 +這會從 Steam\config\stplug-in 刪除這些 .lua 檔案。 刪除失敗 無法刪除檔案: {0} @@ -289,7 +294,6 @@ {1} 有 {0} 個檔案無法刪除。 重新啟動 Steam - 立即重新啟動 Steam 以使變更生效? 無法找到或啟動 Steam。請在設定中設定其位置。 修正 正在載入修正… @@ -306,8 +310,7 @@ 安裝失敗 無法安裝——請關閉 Steam(或使用「重新啟動 Steam」)後重試。 修正已安裝 - {0} 資訊清單已安裝——Steam 正在重新啟動。 - {0} 資訊清單已安裝。重新啟動 Steam 以套用。 + 已安裝 {0} 的資訊清單。 找不到遊戲 請先在 Steam 中安裝 {0},然後再套用修正。 修正部分套用 @@ -347,13 +350,12 @@ 無法確定此 DLC 的本體遊戲 發生錯誤——請檢查你的連線後重試。 下載失敗——請檢查你的連線後重試。 - 產生失敗——請檢查你的連線後重試。 取代「{0}」? 倉庫/DLC 無變更——內容相同。 安裝已取消——現有檔案保持不變。 無法安裝 {0} 個檔案——請關閉 Steam(或使用「重新啟動 Steam」)後重試。 - 已新增 {0}——lua + {1} 個資訊清單。重新啟動 Steam 以套用。 - 已新增 {0}——Steam 將自動擷取資訊清單。重新啟動 Steam 以套用。 + 已新增 {0}:lua + {1} 個資訊清單。 + 已新增 {0}。Steam 將擷取資訊清單。 在 SteamDB 中開啟 共享 這將取代已安裝的 lua。請檢視變更內容: @@ -450,9 +452,9 @@ 刪除這個預設集? 「{0}」將從你儲存的預設集中移除。這不會影響遊戲本身。 無法刪除正在使用的預設集。 - 已切換到「{0}」。重新啟動 Steam 後生效。 + 現在使用「{0}」。 切換預設集失敗 — lua 檔案可能正被占用。 - 已儲存。重新啟動 Steam 後生效。 + 已儲存。 儲存失敗 — lua 檔案可能正被占用。 已另存為預設集。 儲存到「{0}」 @@ -498,4 +500,134 @@ 啟動選項已被重設 Steam 覆寫了你為 {0} 款遊戲設定的啟動選項。重新套用會關閉並重新開啟 Steam。 重新套用 + 找不到 Steam 位置。請在設定中指定。 + 無法連線至 GitHub。請檢查網路連線後重試。 + 無法連線至更新伺服器。請檢查網路連線後重試。 + 已取消。 + 使用者已取消 + 此版本缺少所需的下載檔案。 + 此版本缺少 {0}。 + 下載未通過驗證(sha256 不符)。 + {0} 未通過驗證(sha256 不符)。 + 下載內容缺少:{0}。 + 有 {0} 個檔案無法寫入。請關閉 Steam 後重試。 + 無法寫入 {0}。請關閉 Steam 後重試。 + 無法開啟下載的檔案:{0} + 此遊戲沒有可用的來源。 + 登入逾時。瀏覽器沒有回應。 + 權杖交換失敗({0}):{1} + 權杖交換傳回了空的工作階段。 + 尚未登入。 + 工作階段更新失敗({0})。 + 工作階段更新傳回了空的工作階段。 + 下載連結是空的。請重試。 + 下載失敗({0})。 + 要求失敗({0}) + 工作階段已過期。請重新登入。 + 你的 Hubcap 金鑰無效或已過期。 + 已達到每日 Hubcap 上限。 + 此應用程式沒有可用的 Hubcap 資訊清單。 + Hubcap 下載失敗({0})。 + 下載 + 下載 + 目前沒有下載。快去下載點什麼吧! + 佇列 + 歷史紀錄 + 排隊中 + 下載中 + 等待你確認 + 安裝中 + 已完成 + 失敗 + 已取消 + 取消 + 重試 + 移除 + 上移 + 下移 + 清除歷史紀錄 + 檢視 + {0} / {1} + 剩餘 {0} + 需要操作 + 應用程式關閉時被中斷。 + DLC 解鎖 + 請先安裝遊戲,才能套用修正。 + 已暫停 + 正在驗證… + 暫停 + 繼續 + 倉庫檔案 + 倉庫 · 第 {0} 個,共 {1} 個 + 無法取得倉庫下載器。 + 找不到這款遊戲的解密金鑰。 + 倉庫 {0} 失敗:{1} + 已將 {0} 個倉庫下載到 {1}。 + 下載 + 選擇要下載的倉庫 + 下載 {0} 個倉庫({1}) + 這個倉庫未宣告可下載的版本。 + 全選 + 未選擇倉庫 + 選擇倉庫檔案的儲存位置 + 磁碟空間不足:需要 {0},僅剩 {1}。 + 儲存至 + 需要 {0} · {2} 剩餘 {1} + 登入後可下載這個倉庫。 + 登入後可下載倉庫。 + 正在取得 manifest + 無法取得這個倉庫的 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} 筆記錄嗎?已下載的檔案不會受影響。 + 此修復已套用。若要安裝其他修復,請先還原。 + 我的遊戲 + 你的 {0} 款遊戲有可用修復。捲動清單可瀏覽其餘內容。 + 尚未新增 lua,新增或安裝一款遊戲後會顯示在這裡。 + 還原 + 取消 + 要還原「{0}」嗎?修改過的檔案將從備份復原,修復新增的檔案將被刪除。 + 還原 {0} 的修復? + 修復已還原 + 已復原 {0} 個,已刪除 {1} 個新增檔案。 + 無法還原修復 + 沒有套用此修復的紀錄,可能已經還原過了。 + 無法安全還原此修復 + 自套用此修復以來,{0} 已被變更,還原會覆寫這些變更。如果你在其上套用了另一個修復,請先還原那一個。共影響 {1} 個檔案。 + 修復部分還原 + 有 {0} 個檔案無法復原。請關閉遊戲後重試。 + 此來源沒有這個遊戲,或無法連線。 + 資訊清單來源 + 在 .json 檔案中宣告的額外來源。純資料:檔案只說明從何處取得資訊清單,其中的任何內容都不會被執行。 + 開啟資料夾 + 尚未有來源檔案。 + 來源:{0} diff --git a/src/LuaToolsGui/Services/AnalyticsService.cs b/src/LuaToolsGui/Services/AnalyticsService.cs index b63226e..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; @@ -7,12 +7,12 @@ 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 { - 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() @@ -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/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/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/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/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/AuthService.cs b/src/LuaToolsGui/Services/AuthService.cs index 6cc2efd..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; @@ -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..0b81128 100644 --- a/src/LuaToolsGui/Services/CacheService.cs +++ b/src/LuaToolsGui/Services/CacheService.cs @@ -1,9 +1,9 @@ -using System.IO; +using System.IO; using System.Text.Json; 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,28 @@ 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; } + + // ── Downloads tab history ──────────────────────────────────────── + // 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, 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. + public string? DepotDownloaderVersion { get; set; } + 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; } } /// -/// 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. /// @@ -46,6 +62,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(); @@ -112,6 +142,65 @@ public void SaveLoadedAppIds(IEnumerable ids) Save(); } + // ── Downloads tab history ──────────────────────────────────────── + + /// Finished downloads, newest first. + public IReadOnlyList GetDownloadHistory() => _cache.DownloadHistory; + + /// Replace the history, keeping only the newest entries. + public void SaveDownloadHistory(IEnumerable records, int cap = 100) + { + _cache.DownloadHistory = records + .OrderByDescending(r => r.CompletedAtMs) + .Take(cap) + .ToList(); + 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(); } + } + + /// 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() { @@ -149,6 +238,11 @@ private void Load() } private void Save() + { + lock (_gate) { SaveLocked(); } + } + + private void SaveLocked() { bool empty = _cache.OpenSteamToolsInstalledVersion is null && _cache.OpenSteamToolsInstalledZipDigest is null @@ -157,7 +251,14 @@ private void Save() && _cache.HardwareAppIds.Count == 0 && _cache.HardwareAppIdsFetchedAtMs == 0 && _cache.LoadedAppIds.Count == 0 - && !_cache.OnboardingComplete; + && !_cache.OnboardingComplete + && _cache.DownloadHistory.Count == 0 + && _cache.DepotDownloaderVersion is null + && _cache.DepotDownloaderCheckedAtMs == 0 + && _cache.SteamlessVersion is null + && _cache.SteamlessCheckedAtMs == 0 + && _cache.SteamAutoCrackVersion is null + && _cache.SteamAutoCrackCheckedAtMs == 0; if (empty) { try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { /* best effort */ } @@ -165,6 +266,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/CefInjectorService.cs b/src/LuaToolsGui/Services/CefInjectorService.cs index 9aaf23a..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) @@ -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..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; @@ -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; @@ -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) @@ -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/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 new file mode 100644 index 0000000..724139c --- /dev/null +++ b/src/LuaToolsGui/Services/DepotDownloaderService.cs @@ -0,0 +1,658 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services; + +/// One depot to fetch: which depot, which version, where its manifest is, and how big it is. +/// +/// Absolute path to the .manifest in Steam's depotcache, or null when it isn't there yet. Null is +/// normal at pick time: the run loop resolves it, fetching from the API if needed, and rewrites the +/// record with sel with { ManifestPath = … } before handing it to +/// . +/// +/// +/// Null for a shared redistributable: its gid lives under , not the game's +/// app-info, and is resolved at download time along with the real size. +/// +public record DepotSelection(long DepotId, string? ManifestId, string? ManifestPath, long Size) +{ + /// Owning app for a shared depot (see ), else null. + public long? FromAppId { get; init; } +} + +/// Outcome of one depot's download. +public record DepotRunResult(bool Ok, string? Error); + +/// +/// Runs DepotDownloaderMod to pull raw depot content from Steam's CDN. The tool is downloaded once +/// (via , so blocked regions work) and cached under +/// %AppData%\LuaToolsGui\depotdownloader, mirroring . +/// +/// +/// No account is ever used. We never pass -username or -qr, so the tool takes +/// its anonymous branch (steamUser.LogOnAnonymous()). Those flags are the only paths that reach a +/// Console.ReadLine(), and with stdout redirected a prompt would block forever — hence the silence +/// watchdog below. An anonymous account owns nothing, which is exactly why both inputs must be supplied: +/// the depot key (-depotkeys) and the manifest (-manifestfile). +/// +/// One process per depot. The tool accepts multiple -depot/-manifest pairs, but +/// -manifestfile is a single value applied to every depot in its loop, so batching would feed them +/// all the same manifest. +/// +/// Resume needs -validate. Files are pre-allocated at full size, and because +/// -manifestfile makes the tool's "previous" and "new" manifests identical, its hash check always +/// matches — so a re-run WITHOUT -validate downloads nothing and reports success over a +/// half-written file. Always pass validate when resuming a partial depot. +/// +/// Serialized app-wide behind _runGate. Concurrent anonymous sessions share a +/// 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, + AuthService auth, + CacheService cache, + ILogger log) +{ + /// + /// Whether missing manifests can be fetched from the API. Guests can still download depots whose + /// manifest Steam already has — they just can't pull new ones. Checked locally so the picker never + /// needs a request to decide what to grey out. + /// + public bool CanFetchManifests => !auth.IsGuest; + + private static readonly string ToolDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "depotdownloader"); + + private static string ExePath => Path.Combine(ToolDir, "DepotDownloaderMod.exe"); + + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + /// Kill the child if it produces no output for this long — it's prompting or wedged. + private static readonly TimeSpan SilenceTimeout = TimeSpan.FromMinutes(10); + + /// + /// Chunks fetched concurrently per depot (the tool's -max-downloads). This is the main + /// throughput knob and the tool's own README points at it: "Try increasing -max-downloads to + /// saturate the network more." + /// + /// + /// Raised from the tool's default of 8, which does not saturate a fast connection. The value feeds + /// MaxDegreeOfParallelism over both the file loop and the chunk queue, and requests are + /// round-robined across Steam's real CDN server list, so this is spread over several hosts rather + /// than hammering one. Memory cost is bounded — each in-flight chunk rents roughly its uncompressed + /// size (about 1 MB) from an ArrayPool. + /// + /// Beyond some point the limit stops being the network: SteamKit2 decompresses chunks in + /// managed code, so on a very fast link CPU becomes the ceiling and raising this further buys + /// nothing. If downloads ever look CPU-bound or the CDN starts refusing connections, this is the + /// first number to turn back down. + /// + private const int MaxChunkDownloads = 32; + + 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(); + + // ── Tool acquisition ───────────────────────────────────────────── + + /// 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) && CheckedRecently(cache.DepotDownloaderCheckedAtMs)) return ExePath; + + await _toolGate.WaitAsync(ct); + bool have = false; + try + { + 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); + 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("DepotDownloader release has no .zip asset"); + 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"); + // 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 */ } + + 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"); + // 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(); } + } + + // ── Input sourcing ─────────────────────────────────────────────── + + /// + /// Depot-id to decryption key for an app: from its installed lua first (that's where LuaTools puts + /// them), falling back to Steam's own config.vdf for depots the lua doesn't carry. + /// + public IReadOnlyDictionary ResolveKeys(long appId) + { + var keys = new Dictionary(); + + try + { + if (steam.StPlugInDir is { } dir) + { + string lua = Path.Combine(dir, $"{appId}.lua"); + if (File.Exists(lua) && LuaFileParser.Parse(lua, appId) is { } parsed) + { + // DisabledEntries too: a depot switched off on the Depots page still has a valid key, + // and the user explicitly picked it for download. + foreach (var e in parsed.Entries.Concat(parsed.DisabledEntries)) + if (e.Key is { Length: > 0 }) keys[e.Id] = e.Key; + } + } + } + catch (Exception ex) { log.LogDebug(ex, "Reading depot keys from lua failed for {AppId}", appId); } + + try + { + if (steam.EffectivePath is { } root) + { + string vdf = Path.Combine(root, "config", "config.vdf"); + if (File.Exists(vdf)) + { + foreach (var (depot, key) in DonateKeysService.ExtractKeys(File.ReadAllText(vdf))) + if (long.TryParse(depot, out long id) && !keys.ContainsKey(id)) keys[id] = key; + } + } + } + catch (Exception ex) { log.LogDebug(ex, "Reading depot keys from config.vdf failed"); } + + return keys; + } + + /// + /// The depotcache path for a depot at a given manifest version, or null when Steam doesn't have it. + /// A game added with "Auto Update Apps" on has its pins commented out and its manifests skipped, so + /// this is genuinely absent for many installs — callers must check BEFORE queueing, not mid-run. + /// + public string? ResolveManifestPath(long depotId, string manifestId) + { + 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// Shared by the depot picker (which warns before you commit) and the job's own pre-check (which + /// refuses before a byte is allocated). One implementation so the two can never disagree. + /// + public static long? FreeSpaceFor(string path) + { + try + { + string? root = Path.GetPathRoot(Path.GetFullPath(path)); + return root is null ? null : new DriveInfo(root).AvailableFreeSpace; + } + catch { return null; } // unmapped/UNC path: let the download try and fail on its own terms + } + + /// The volume label a path lives on ("C:\"), for display. Empty when it can't be resolved. + public static string DriveOf(string path) + { + try { return Path.GetPathRoot(Path.GetFullPath(path)) ?? ""; } + catch { return ""; } + } + + /// + /// Write the depotID;hexKey file the -depotkeys flag expects. Staged under the shared downloads + /// staging folder so SweepStale() reclaims it if we crash before deleting it. + /// + /// The caller MUST delete this when the run finishes — it contains decryption keys. + public static string WriteKeysFile(IReadOnlyDictionary keys) + { + Directory.CreateDirectory(Downloads.HttpFileDownloader.StagingFolder); + string path = Path.Combine(Downloads.HttpFileDownloader.StagingFolder, $"depotkeys_{Guid.NewGuid():N}.txt"); + + var sb = new StringBuilder(); + foreach (var (id, key) in keys) sb.Append(id).Append(';').Append(key).Append('\n'); + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + return path; + } + + // ── Process invocation ─────────────────────────────────────────── + + /// + /// Download one depot. reports 0..1 for THIS depot; the caller + /// aggregates across depots. Serialized app-wide (see class remarks). + /// + public async Task RunAsync( + long appId, DepotSelection sel, string keysFile, string outDir, bool validate, + 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"); + + await _runGate.WaitAsync(ct); + try + { + Directory.CreateDirectory(outDir); + + var psi = new ProcessStartInfo(exe) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = ToolDir, + }; + // ArgumentList quotes each value itself, so paths with spaces need no manual escaping. + // Deliberately NO -username/-qr (would prompt) and NO -loginid (ignored when anonymous). + // Both resolved by the run loop before we get here: the id from the owning app for a shared + // depot, the path from depotcache (or fetched into it). + ArgumentNullException.ThrowIfNull(sel.ManifestId); + ArgumentNullException.ThrowIfNull(sel.ManifestPath); + + foreach (string a in new[] + { + "-app", appId.ToString(), + "-depot", sel.DepotId.ToString(), + "-manifest", sel.ManifestId!, + "-depotkeys", keysFile, + "-manifestfile", sel.ManifestPath, + "-dir", outDir, + "-max-downloads", MaxChunkDownloads.ToString(), + }) psi.ArgumentList.Add(a); + + // Mandatory on a resume: without it the tool short-circuits and reports success over a + // partially-written file. See class remarks. + if (validate) psi.ArgumentList.Add("-validate"); + + using var proc = Process.Start(psi); + if (proc is null) return new DepotRunResult(false, "spawn"); + + long lastOutput = DateTime.UtcNow.Ticks; + string? lastError = null; + string? lastLine = null; + + DepotPhase? lastPhase = null; + proc.OutputDataReceived += (_, e) => + { + if (e.Data is null) return; + Interlocked.Exchange(ref lastOutput, DateTime.UtcNow.Ticks); + + var m = ProgressRegex().Match(e.Data); + 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. + // + // Skip stack frames: an unhandled exception prints its message and THEN a dozen "at ..." + // lines, so keeping the literal last line surfaced "at DepotDownloader.Program.
" + // rather than the message that actually explains the failure. + string line = e.Data.Trim(); + if (line.Length > 0 && !line.StartsWith("at ", StringComparison.Ordinal)) lastLine = line; + }; + proc.ErrorDataReceived += (_, e) => + { + if (e.Data is null) return; + Interlocked.Exchange(ref lastOutput, DateTime.UtcNow.Ticks); + lastError = e.Data; + }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + // Cancellation (user cancelled, or Pause) and the watchdog both resolve to "kill the child". + using var reg = ct.Register(() => TryKill(proc)); + bool timedOut = false; + + while (!proc.WaitForExit(2000)) + { + if (DateTime.UtcNow.Ticks - Interlocked.Read(ref lastOutput) <= SilenceTimeout.Ticks) continue; + log.LogDebug("DepotDownloader silent for {Timeout}, killing", SilenceTimeout); + timedOut = true; + TryKill(proc); + break; + } + + // Blocking overload with no timeout also waits for the async readers to drain. + proc.WaitForExit(); + ct.ThrowIfCancellationRequested(); + + if (timedOut) return new DepotRunResult(false, "timeout"); + if (proc.ExitCode != 0) + { + string? why = lastError ?? lastLine; + if (why is { Length: > 300 }) why = why[..300]; + log.LogDebug("DepotDownloader exited {Code} for depot {Depot}: {Err}", + proc.ExitCode, sel.DepotId, why); + return new DepotRunResult(false, why ?? $"exit {proc.ExitCode}"); + } + + depotFraction?.Report(1d); + return new DepotRunResult(true, null); + } + 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/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 87fea07..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(); @@ -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. } } @@ -70,7 +70,9 @@ public async Task SendPendingKeysIfEnabledAsync(CancellationToken ct = default) /// Walk a parsed config.vdf tree and collect (appid, key) for every object carrying a string /// "DecryptionKey" (the object's parent key is the appid). Only validated pairs are returned. ///
- private static List<(string appid, string key)> ExtractKeys(string content) + /// Depot-id → decryption key pairs from a config.vdf. Internal so the depot downloader can + /// reuse it as a key source for depots whose lua carries no key. + internal static List<(string appid, string key)> ExtractKeys(string content) { var root = ParseVdf(content); var result = new List<(string, string)>(); @@ -88,7 +90,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/Downloads/ByteFormat.cs b/src/LuaToolsGui/Services/Downloads/ByteFormat.cs new file mode 100644 index 0000000..23770e9 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/ByteFormat.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace LuaToolsGui.Services.Downloads; + +/// +/// Display formatting for download metrics: transferred size, transfer rate and remaining time. +/// +/// +/// Intentionally separate from the FormatSize helpers in DownloadViewModel, +/// BuildsViewModel and DropInstallViewModel. Those format *depot* sizes for diff rows: +/// they floor at MB and return an empty string for zero, which is right for a depot list but wrong for +/// live progress, where a small lua file is a few KB and "0" must render as "0 B" rather than vanish. +/// +/// Unit suffixes are deliberately not localized, matching the existing depot-size helpers, which +/// hardcode "MB"/"GB" in every language. +/// +public static class ByteFormat +{ + private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; + + /// "1.4 GB" / "812 MB" / "43 KB" / "512 B". Never returns an empty string. + public static string Size(long bytes) + { + if (bytes < 0) bytes = 0; + double gb = bytes / 1024d / 1024d / 1024d; + if (gb >= 1) return gb.ToString("0.##", Inv) + " GB"; + double mb = bytes / 1024d / 1024d; + if (mb >= 1) return mb.ToString("0.#", Inv) + " MB"; + double kb = bytes / 1024d; + if (kb >= 1) return kb.ToString("0", Inv) + " KB"; + return bytes.ToString("0", Inv) + " B"; + } + + /// "4.2 MB/s". Empty when the rate is not yet measurable, so callers can hide the label. + /// "/s" is a unit symbol, not prose: it stays unlocalized alongside the KB/MB/GB above. + public static string Rate(double bytesPerSecond) + { + if (double.IsNaN(bytesPerSecond) || bytesPerSecond <= 0) return ""; + return Size((long)bytesPerSecond) + "/s"; + } + + /// "1m 12s" / "8s" / "2h 5m". Empty for a non-positive or absurd duration. + public static string Duration(TimeSpan t) + { + if (t <= TimeSpan.Zero || t.TotalDays >= 1) return ""; + if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes}m"; + if (t.TotalMinutes >= 1) return $"{(int)t.TotalMinutes}m {t.Seconds}s"; + return $"{Math.Max(1, (int)Math.Ceiling(t.TotalSeconds))}s"; + } +} diff --git a/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs b/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs new file mode 100644 index 0000000..a05a9a6 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/DownloadHistory.cs @@ -0,0 +1,79 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LuaToolsGui.Services.Downloads; + +/// +/// The persisted shape of a finished download. Deliberately a flat POCO of primitives: a +/// holds delegates and cannot be serialized, so history records what +/// happened rather than anything that could be resumed. +/// +public sealed record DownloadHistoryRecord( + string Id, + string Kind, + long AppId, + string Title, + string SubTitle, + long Bytes, + string Status, + string? Message, + 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 +{ + public DownloadHistoryEntry(DownloadHistoryRecord record) + { + Record = record; + Status = Enum.TryParse(record.Status, out var s) ? s : DownloadStatus.Completed; + } + + public DownloadHistoryRecord Record { get; } + public DownloadStatus Status { get; } + + public string Id => Record.Id; + public long AppId => Record.AppId; + public string Title => Record.Title; + public string SubTitle => Record.SubTitle; + public string? Message => Record.Message; + public bool HasMessage => !string.IsNullOrWhiteSpace(Record.Message); + public bool Failed => Status is DownloadStatus.Failed; + + public string SizeLabel => Record.Bytes > 0 ? ByteFormat.Size(Record.Bytes) : ""; + + public string StatusLabel => Status switch + { + DownloadStatus.Completed => Resources.Strings.Downloads_Status_Completed, + DownloadStatus.Failed => Resources.Strings.Downloads_Status_Failed, + _ => 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"); + + public static DownloadHistoryRecord From(DownloadItem item, DownloadStatus status) => new( + item.Id, + item.Job.Kind.ToString(), + item.AppId, + item.Title, + item.SubTitle, + item.BytesRead, + status.ToString(), + item.Message, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + item.RevealPath); +} diff --git a/src/LuaToolsGui/Services/Downloads/DownloadItem.cs b/src/LuaToolsGui/Services/Downloads/DownloadItem.cs new file mode 100644 index 0000000..f4bb1d8 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/DownloadItem.cs @@ -0,0 +1,315 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LuaToolsGui.Services.Downloads; + +public enum DownloadStatus +{ + Queued, + Downloading, + /// Downloaded, waiting on the user's overwrite confirmation. Holds no concurrency slot. + AwaitingConfirmation, + Installing, + /// Depot download only: the user paused it. Not terminal - Resume picks it back up. + Paused, + /// Depot download only: re-hashing on-disk chunks after a resume, before bytes move again. + Verifying, + Completed, + Failed, + Cancelled, +} + +/// +/// A live row in the download queue: the job, its current phase, and its byte/speed/ETA metrics. +/// +/// +/// Every mutable property is written on the WPF dispatcher by , so bindings +/// never need to marshal. Speed and ETA are derived here rather than in the download services, because +/// the services only ever see one chunk at a time and have no notion of a sampling window. +/// +public partial class DownloadItem : ObservableObject +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + // 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); + + /// + /// 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) + { + Job = job; + Cts = new CancellationTokenSource(); + EnqueuedAt = DateTimeOffset.Now; + } + + public string Id { get; } = Guid.NewGuid().ToString("N"); + public DownloadJob Job { get; } + public DateTimeOffset EnqueuedAt { get; } + internal CancellationTokenSource Cts { get; private set; } + + /// + /// Set by DownloadQueue.Pause before it cancels the token, so the run loop can tell a pause + /// from a real cancellation — both surface as an OperationCanceledException, but only one of them + /// should settle the item. + /// + internal bool PauseRequested { get; set; } + + /// + /// Set by Resume. Makes the next depot run pass -validate, which is MANDATORY on a resume: without + /// it the downloader short-circuits and reports success over a half-written, pre-allocated file. + /// Cleared once consumed, so only the interrupted depot pays the re-hash cost. + /// + internal bool NeedsValidate { get; set; } + + /// Swap in a fresh token source so a paused item can run again. + internal void ResetCts() + { + try { Cts.Dispose(); } catch { /* already disposed */ } + Cts = new CancellationTokenSource(); + } + + /// + /// Completes when the item reaches a terminal state, with the install result (null if it never got + /// that far). NEVER faults. Inspect to distinguish failure from cancellation. + /// + /// + /// This is what the protocol/silent-install path and PluginAddService await in place of the + /// old inline download→install chain. Without it, backgrounding the download would let + /// App's post-silent-install shutdown timer fire while the download was still running. + /// + public Task Completion => _completion.Task; + + public string Title => Job.Title; + public string SubTitle => Job.SubTitle; + public string? CoverPath => Job.CoverPath; + public long AppId => Job.AppId; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsActive), nameof(IsRunning), nameof(StatusLabel), + nameof(CanCancel), nameof(CanRetry), nameof(CanRemove), nameof(CanReorder), + nameof(ShowProgress), nameof(NeedsAction), nameof(RateLabel), nameof(EtaLabel), + nameof(CanPause), nameof(CanResume))] + private DownloadStatus _status = DownloadStatus.Queued; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Percent), nameof(SizeLabel))] + private long _bytesRead; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Percent), nameof(SizeLabel), nameof(IsIndeterminate))] + private long? _totalBytes; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RateLabel))] + private double _bytesPerSecond; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EtaLabel))] + private TimeSpan? _eta; + + /// Error text, or the install status line once finished. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMessage))] + private string? _message; + + public bool HasMessage => !string.IsNullOrWhiteSpace(Message); + + /// Extra sub-line while running, e.g. a depot job's "Depots - 3 of 12". + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDetail))] + private string? _detail; + + public bool HasDetail => !string.IsNullOrWhiteSpace(Detail); + + /// + /// Depot ids this job already finished. Resume skips them outright rather than re-hashing tens of GB. + /// In-memory only: a DownloadJob holds delegates and is not serializable, so a paused item does not + /// survive an app restart (same as every other in-flight item). + /// + 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; + + // Paused/Verifying are non-terminal, so they count as active: the item keeps its Cancel button, + // is never auto-dismissed, and is recorded as cancelled if the app shuts down while it sits there. + public bool IsActive => Status is DownloadStatus.Queued or DownloadStatus.Downloading + or DownloadStatus.AwaitingConfirmation or DownloadStatus.Installing + or DownloadStatus.Paused or DownloadStatus.Verifying; + public bool IsRunning => Status is DownloadStatus.Downloading or DownloadStatus.Installing + or DownloadStatus.Verifying; + + public bool ShowProgress => Status is DownloadStatus.Downloading or DownloadStatus.Installing + or DownloadStatus.Paused or DownloadStatus.Verifying; + public bool NeedsAction => Status is DownloadStatus.AwaitingConfirmation; + public bool CanCancel => IsActive; + public bool CanRetry => Status is DownloadStatus.Failed or DownloadStatus.Cancelled; + public bool CanRemove => !IsActive; + + /// Reordering only means anything before the item starts; priority is its index in the queue. + public bool CanReorder => Status is DownloadStatus.Queued; + + // Pause/Resume exist only for depot downloads: they're the only job kind whose progress survives the + // process being killed, because the bytes are already on disk and the tool can re-validate them. + // Verifying counts too: re-hashing a large depot can run for minutes, and losing the Pause button + // for exactly that stretch is when you'd most want it. Pausing a verify is safe — validation only + // reads, and Resume re-validates from scratch anyway. + public bool CanPause => Status is (DownloadStatus.Downloading or DownloadStatus.Verifying) + && Job.Kind is DownloadKind.Depot; + public bool CanResume => Status is DownloadStatus.Paused; + + public string StatusLabel => Status switch + { + DownloadStatus.Queued => Resources.Strings.Downloads_Status_Queued, + DownloadStatus.Downloading => Resources.Strings.Downloads_Status_Downloading, + DownloadStatus.AwaitingConfirmation => Resources.Strings.Downloads_Status_AwaitingConfirm, + DownloadStatus.Installing => Resources.Strings.Downloads_Status_Installing, + DownloadStatus.Paused => Resources.Strings.Downloads_Status_Paused, + DownloadStatus.Verifying => Resources.Strings.Downloads_Status_Verifying, + DownloadStatus.Completed => Resources.Strings.Downloads_Status_Completed, + DownloadStatus.Failed => Resources.Strings.Downloads_Status_Failed, + _ => Resources.Strings.Downloads_Status_Cancelled, + }; + + /// "412 MB of 1.4 GB", or just "412 MB" when the total length is unknown. + public string SizeLabel + { + get + { + if (BytesRead <= 0 && TotalBytes is not > 0) return ""; + if (TotalBytes is > 0) + return string.Format(Resources.Strings.Downloads_Of, + ByteFormat.Size(BytesRead), ByteFormat.Size(TotalBytes.Value)); + return ByteFormat.Size(BytesRead); + } + } + + public string RateLabel => Status is DownloadStatus.Downloading ? ByteFormat.Rate(BytesPerSecond) : ""; + + public string EtaLabel + { + get + { + if (Status is not DownloadStatus.Downloading || Eta is not { } eta) return ""; + string d = ByteFormat.Duration(eta); + return d.Length == 0 ? "" : string.Format(Resources.Strings.Downloads_Eta, d); + } + } + + /// + /// Fold one progress reading in and recompute rate/ETA. Called on the dispatcher from the queue's + /// throttled pump, NOT once per network chunk. + /// + internal void ApplySample(long bytesRead, long? total) + { + BytesRead = bytesRead; + TotalBytes = total; + + long now = UtcNow().Ticks; + _samples.Enqueue((now, bytesRead)); + + 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; // the very first reading: nothing to measure against yet + + var (oldTicks, oldBytes) = _samples.Peek(); + double seconds = (now - oldTicks) / (double)TimeSpan.TicksPerSecond; + + // 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; + Eta = rate > 0 && total is > 0 && total.Value > bytesRead + ? TimeSpan.FromSeconds((total.Value - bytesRead) / rate) + : null; + } + + /// Clear the rate window so a retry doesn't inherit the previous attempt's samples. + internal void ResetMetrics() + { + _samples.Clear(); + BytesRead = 0; + TotalBytes = null; + BytesPerSecond = 0; + Eta = null; + Message = null; + } + + /// Settle . Idempotent: a retry re-enqueues a NEW item. + internal void SettleCompletion(JobResult? result) => _completion.TrySetResult(result); +} diff --git a/src/LuaToolsGui/Services/Downloads/DownloadJob.cs b/src/LuaToolsGui/Services/Downloads/DownloadJob.cs new file mode 100644 index 0000000..261afeb --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/DownloadJob.cs @@ -0,0 +1,99 @@ +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, Tool } + +/// +/// A job refused to start for a reason the user can act on — e.g. the game a Denuvo fix targets isn't +/// installed. Carries a ready-to-display, already-localized message. +/// +/// +/// Distinct from on purpose: this is thrown *before* any request is made, so +/// 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, 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. +public sealed record JobResult(bool Ok, string? Message, string? InstalledPath = null); + +/// +/// One unit of work handed to . +/// +/// +/// The queue is a pure scheduler. It knows nothing about Hubcap vs lua.tools vs signed R2 URLs, and +/// nothing about LuaInstaller or SteamService; all of that lives in the delegates, which +/// in practice are always built by . That keeps the three formerly +/// duplicated download+install implementations (the Add page, the plugin add service and the HTTP +/// server) sharing exactly one code path. +/// +/// Deliberate consequence of holding delegates: a job is NOT serializable, so nothing resumes across an +/// app restart. Only the completed-history persists. +/// +public sealed record DownloadJob( + DownloadKind Kind, + + /// + /// Identity for duplicate suppression: "manifest:730", "dlc:12345", "denuvo:{fixId}:{slot}". + /// Enqueuing a key that is already active returns the existing item instead of a second download. + /// This is what replaces the old per-page if (IsBusy) return; gates. + /// + string DedupeKey, + + long AppId, + string Title, + string SubTitle, + string? CoverPath, + + /// + /// Fetch the bytes to a staged file. Runs on a background thread. + /// + /// + /// Receives the live so a multi-step job (the depot downloader, which runs + /// one child process per depot) can report which step it's on. Single-step jobs ignore it. Any + /// observable property it touches must be set on the dispatcher. + /// + Func, CancellationToken, Task> DownloadAsync, + + /// Consume the staged file (install into Steam). Runs serialized against other installs. + Func> InstallAsync, + + /// + /// Optional gate between download and install; true proceeds, false discards the staged file. + /// While this is awaited the item is . Nothing else + /// waits on it: the queue has no concurrency cap, so an unanswered dialog cannot wedge anything. + /// + Func>? ConfirmAsync = null, + + /// + /// Fired on the dispatcher once the item reaches a terminal state: usage-badge refresh, install + /// banner, plugin AddState mutation. Exceptions are swallowed so a bad continuation cannot kill + /// the pump. + /// + Action? OnFinished = null, + + /// Target of the Downloads tab's "Review" / "Reveal" button (e.g. navigate to Add). + 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/DownloadProgress.cs b/src/LuaToolsGui/Services/Downloads/DownloadProgress.cs new file mode 100644 index 0000000..e64529e --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/DownloadProgress.cs @@ -0,0 +1,32 @@ +namespace LuaToolsGui.Services.Downloads; + +/// +/// Byte-level progress from a streaming download. is null when the response +/// carried no Content-Length (chunked), in which case the UI shows an indeterminate bar. +/// +/// +/// This replaces the old IProgress<double?> contract for manifest/Denuvo downloads. The +/// byte counts were always available inside the copy loop; they were folded into a fraction and thrown +/// away, which made size/speed/ETA impossible to show. Out-of-scope downloads (everything routed through +/// : updates, unlocker, plugin, Steamless, CloudRedirect) still use the old +/// fraction shape. +/// +public readonly record struct DownloadProgress(long BytesRead, long? TotalBytes) +{ + /// 0..1 completion, or null when the total length is unknown. + public double? Fraction => TotalBytes is > 0 ? (double)BytesRead / TotalBytes.Value : null; +} + +/// +/// A minimal that invokes its callback synchronously on the reporting thread. +/// +/// +/// Deliberately NOT : that type captures the creating SynchronizationContext and +/// posts every single report to it. A download reports once per 80 KB chunk, so a 2 GB file would post +/// ~25,000 messages to the WPF dispatcher and flood the UI thread. The queue does its own time-throttled +/// marshalling instead, so reports must stay on the calling thread and be cheap. +/// +public sealed class ProgressRelay(Action onReport) : IProgress +{ + public void Report(T value) => onReport(value); +} diff --git a/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs b/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs new file mode 100644 index 0000000..6bf8210 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/DownloadQueue.cs @@ -0,0 +1,460 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services.Downloads; + +/// +/// The app's single download scheduler: manifests and Denuvo fixes from every entry point (the Add +/// page, the Fixes page, the Steam store plugin and the protocol handler) run through this one queue. +/// +/// +/// Threading. All queue state — , , every +/// property and every scheduling decision — lives on the WPF dispatcher. +/// Only the HTTP stream and the install call run on background threads. That removes the need for any +/// locking around the collections and makes bindings safe by construction. The dispatcher is touched +/// once per state transition plus a throttled progress tick, never once per network chunk. +/// +/// No download cap. Every queued item starts as soon as the pump sees it. Downloads are +/// the only phase that runs in parallel, so an item's index in stops mattering once +/// it is in flight — reordering is only meaningful in the moment between Enqueue and the pump. +/// +/// Installs are always serialized behind _installGate, and are now the only limiter +/// in the pipeline: LuaInstaller.InstallZip writes into Steam's shared depotcache with a +/// File.Exists guard that two concurrent installs would race. +/// +public class DownloadQueue : IHostedService +{ + private readonly CacheService _cache; + private readonly ILogger _log; + + /// Signals the pump that the schedule may have changed. + private readonly SemaphoreSlim _kick = new(0); + + /// Serializes the install phase. See the remarks above. + private readonly SemaphoreSlim _installGate = new(1, 1); + + /// How long a successful item lingers in the queue before clearing itself. + private static readonly TimeSpan AutoDismissDelay = TimeSpan.FromSeconds(3); + + private readonly CancellationTokenSource _shutdown = new(); + private Task? _pump; + + public DownloadQueue(CacheService cache, ILogger log) + { + _cache = cache; + _log = log; + } + + private Dispatcher Dispatcher => + Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + + /// Active and recently finished items, in scheduling order. Index = priority. + public ObservableCollection Items { get; } = []; + + /// Finished downloads from this and previous sessions, newest first. + public ObservableCollection History { get; } = []; + + /// Raised when changes. + public event Action? StateChanged; + + public int ActiveCount => Items.Count(i => i.IsActive); + + // ── Lifecycle ──────────────────────────────────────────────────── + + public Task StartAsync(CancellationToken ct) + { + foreach (var r in _cache.GetDownloadHistory().OrderByDescending(r => r.CompletedAtMs)) + History.Add(new DownloadHistoryEntry(r)); + + _pump = Task.Run(() => PumpAsync(_shutdown.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + /// Cancel everything in flight and record it. Anything still active when the app closes is written + /// to history as cancelled rather than silently vanishing. + /// + public async Task StopAsync(CancellationToken ct) + { + _shutdown.Cancel(); + + List active = []; + await Dispatcher.InvokeAsync(() => active = Items.Where(i => i.IsActive).ToList()); + + foreach (var item in active) + { + try { item.Cts.Cancel(); } catch { /* already disposed */ } + } + + await Dispatcher.InvokeAsync(() => + { + foreach (var item in active) + { + if (!item.IsActive) continue; + item.Message ??= Resources.Strings.Downloads_Err_Interrupted; + item.Status = DownloadStatus.Cancelled; + History.Insert(0, new DownloadHistoryEntry( + DownloadHistoryEntry.From(item, DownloadStatus.Cancelled))); + item.SettleCompletion(null); + } + PersistHistory(); + }); + + if (_pump is not null) + { + try { await _pump.WaitAsync(TimeSpan.FromSeconds(3), ct); } + catch { /* pump is parked on _kick; shutdown proceeds regardless */ } + } + } + + // ── Public API ─────────────────────────────────────────────────── + + /// + /// Add a job, or return the existing active item with the same + /// . This is the app-wide replacement for the per-page + /// if (IsBusy) return; gates and for the HTTP server's duplicate-appid 409 check. + /// + public DownloadItem Enqueue(DownloadJob job) + { + return Dispatcher.Invoke(() => + { + if (FindActiveCore(job.DedupeKey) is { } existing) + { + _log.LogDebug("Download already queued, reusing item: {Key}", job.DedupeKey); + return existing; + } + + var item = new DownloadItem(job); + Items.Add(item); + item.PropertyChanged += OnItemPropertyChanged; + StateChanged?.Invoke(); + Kick(); + return item; + }); + } + + /// The in-flight item for a dedupe key, or null. + public DownloadItem? FindActive(string dedupeKey) => + Dispatcher.Invoke(() => FindActiveCore(dedupeKey)); + + private DownloadItem? FindActiveCore(string dedupeKey) => + Items.FirstOrDefault(i => i.IsActive && + string.Equals(i.Job.DedupeKey, dedupeKey, StringComparison.OrdinalIgnoreCase)); + + public void Cancel(DownloadItem item) => Dispatcher.Invoke(() => + { + if (!item.IsActive) return; + + // 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 (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(); + }); + + /// + /// Pause a running depot download. Kills the child process but leaves its bytes on disk; Resume + /// picks up from the first unfinished depot. Only depot jobs can pause (see DownloadItem.CanPause) — + /// they're the only kind whose partial work survives the process dying. + /// + public void Pause(DownloadItem item) => Dispatcher.Invoke(() => + { + if (!item.CanPause) return; + item.PauseRequested = true; + item.Status = DownloadStatus.Paused; + item.BytesPerSecond = 0; + item.Eta = null; + try { item.Cts.Cancel(); } catch { /* already disposed */ } + StateChanged?.Invoke(); + }); + + /// Resume a paused depot download from the first depot it hadn't finished. + public void Resume(DownloadItem item) => Dispatcher.Invoke(() => + { + if (!item.CanResume) return; + item.PauseRequested = false; + item.NeedsValidate = true; // the interrupted depot must be re-hashed, not trusted + item.ResetCts(); + item.Status = DownloadStatus.Queued; + StateChanged?.Invoke(); + Kick(); + }); + + /// Re-enqueue a failed or cancelled item's job as a fresh item at the tail. + public DownloadItem Retry(DownloadItem item) + { + Dispatcher.Invoke(() => Remove(item)); + var fresh = Enqueue(item.Job); + + // A depot job's progress lives on disk, not in the item, so a retry must inherit what the failed + // attempt finished. Without this the new item restarts at depot 1 with NeedsValidate false, and + // the half-written depot that caused the failure would be skipped as "already complete" — + // silently leaving a corrupt install. + 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; + } + + /// Move a pending item up (-1) or down (+1). No-op once it has started. + public void Move(DownloadItem item, int delta) => Dispatcher.Invoke(() => + { + if (item.Status != DownloadStatus.Queued) return; + int from = Items.IndexOf(item); + if (from < 0) return; + int to = Math.Clamp(from + delta, 0, Items.Count - 1); + if (to == from) return; + Items.Move(from, to); + Kick(); + }); + + /// Drop a finished item from the active list. History keeps the record. + public void Remove(DownloadItem item) => Dispatcher.Invoke(() => + { + if (item.IsActive) return; + item.PropertyChanged -= OnItemPropertyChanged; + Items.Remove(item); + StateChanged?.Invoke(); + }); + + public void ClearHistory() => Dispatcher.Invoke(() => + { + History.Clear(); + 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(); + } + + // ── Scheduler ──────────────────────────────────────────────────── + + private void Kick() + { + try { _kick.Release(); } + catch (SemaphoreFullException) { /* already signalled */ } + catch (ObjectDisposedException) { /* shutting down */ } + } + + private async Task PumpAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await _kick.WaitAsync(ct); } + catch (OperationCanceledException) { return; } + + try + { + await Dispatcher.InvokeAsync(StartEligible); + } + catch (Exception ex) + { + _log.LogDebug(ex, "Download pump cycle failed"); + } + } + } + + /// Start every queued item. Dispatcher thread only. + private void StartEligible() + { + // Snapshot first: RunItemAsync can settle an item synchronously and mutate Items re-entrantly. + var ready = Items.Where(i => i.Status == DownloadStatus.Queued + && !i.Cts.IsCancellationRequested).ToList(); + + foreach (var item in ready) + { + item.Status = DownloadStatus.Downloading; + StateChanged?.Invoke(); + _ = RunItemAsync(item); + } + } + + private async Task RunItemAsync(DownloadItem item) + { + DownloadedFile? file = null; + var ct = item.Cts.Token; + + try + { + // ── 1. Download ────────────────────────────────────────── + // Progress is time-throttled here rather than in the services: a report arrives per 80 KB + // chunk (~25,000 for a 2 GB zip) and posting each one would flood the UI thread. + long lastPostTicks = 0; + var sink = new ProgressRelay(p => + { + long now = DateTime.UtcNow.Ticks; + bool done = p.TotalBytes is > 0 && p.BytesRead >= p.TotalBytes.Value; + if (!done && now - lastPostTicks < TimeSpan.TicksPerMillisecond * 100) return; + lastPostTicks = now; + _ = Dispatcher.InvokeAsync(() => item.ApplySample(p.BytesRead, p.TotalBytes), + DispatcherPriority.Background); + }); + + file = await Task.Run(() => item.Job.DownloadAsync(item, sink, ct), ct); + + // ── 2. Optional confirmation gate ──────────────────────── + if (item.Job.ConfirmAsync is { } confirm) + { + await Dispatcher.InvokeAsync(() => + { + item.Status = DownloadStatus.AwaitingConfirmation; + StateChanged?.Invoke(); + }); + + bool proceed; + try { proceed = await confirm(file, item, ct); } + catch (OperationCanceledException) { proceed = false; } + catch (Exception ex) + { + _log.LogDebug(ex, "Download confirm gate threw; treating as declined"); + proceed = false; + } + + if (!proceed || ct.IsCancellationRequested) + { + DeleteStaged(file.FilePath); + await Dispatcher.InvokeAsync(() => + Finish(item, DownloadStatus.Cancelled, Resources.Strings.Add_Status_Cancelled, null)); + return; + } + } + + // ── 3. Install (always serialized) ─────────────────────── + await Dispatcher.InvokeAsync(() => item.Status = DownloadStatus.Installing); + + await _installGate.WaitAsync(CancellationToken.None); + JobResult result; + try + { + result = await Task.Run(() => item.Job.InstallAsync(file!, item, ct), CancellationToken.None); + } + finally { _installGate.Release(); } + + await Dispatcher.InvokeAsync(() => Finish( + item, + result.Ok ? DownloadStatus.Completed : DownloadStatus.Failed, + result.Message, + result)); + } + catch (OperationCanceledException) + { + // A pause cancels the same token a real cancel does. Leave the item parked in Paused and + // keep its bytes: Resume re-enters this method and skips the depots already finished. + if (item.PauseRequested) return; + if (file is not null) DeleteStaged(file.FilePath); + await Dispatcher.InvokeAsync(() => + Finish(item, DownloadStatus.Cancelled, Resources.Strings.Err_CancelledByUser, null)); + } + catch (Exception ex) + { + if (file is not null) DeleteStaged(file.FilePath); + _log.LogDebug(ex, "Download job failed: {Key}", item.Job.DedupeKey); + // Both of these carry a message meant for the user; anything else is unexpected, so it gets + // the generic text and the detail goes to the log above. + string message = ex is ApiException or DownloadAbortedException + ? ex.Message + : Resources.Strings.Add_Err_Download; + + // 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 + { + Kick(); + } + } + + /// Settle an item into a terminal state and record it. Dispatcher thread only. + private void Finish(DownloadItem item, DownloadStatus status, string? message, JobResult? result) + { + if (!item.IsActive) return; // already settled (e.g. cancelled while queued) + + 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(); + + item.SettleCompletion(result); + + // Per-job continuation only. There is deliberately no queue-wide "finished" event: each entry + // point already reports its own outcome, and a global subscriber double-notified all of them. + try { item.Job.OnFinished?.Invoke(item, result); } + catch (Exception ex) { _log.LogDebug(ex, "Download OnFinished continuation threw"); } + + StateChanged?.Invoke(); + + // A successful item has nothing left to act on — History keeps the record, so clear it from the + // queue. Failed/Cancelled stay put: their message and Retry button are the only copy the user gets. + if (status == DownloadStatus.Completed) _ = AutoDismissAsync(item); + } + + /// Drop a completed item from the queue after a beat, so the user can read the outcome first. + private async Task AutoDismissAsync(DownloadItem item) + { + try { await Task.Delay(AutoDismissDelay, _shutdown.Token); } + catch (OperationCanceledException) { return; } // shutting down; leave the list alone + + try + { + await Dispatcher.InvokeAsync(() => + { + // Re-check: the user may have dismissed it, or Retry may have swapped it out. + if (item.Status == DownloadStatus.Completed && Items.Contains(item)) Remove(item); + }); + } + catch (Exception ex) { _log.LogDebug(ex, "Auto-dismiss failed"); } + } + + private void PersistHistory() + { + try { _cache.SaveDownloadHistory(History.Select(h => h.Record)); } + catch (Exception ex) { _log.LogDebug(ex, "Persisting download history failed"); } + } + + /// Best-effort delete of a staged file the install never consumed. + private static void DeleteStaged(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } + } +} diff --git a/src/LuaToolsGui/Services/Downloads/HttpFileDownloader.cs b/src/LuaToolsGui/Services/Downloads/HttpFileDownloader.cs new file mode 100644 index 0000000..7593121 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/HttpFileDownloader.cs @@ -0,0 +1,77 @@ +using System.IO; +using System.Net.Http; + +namespace LuaToolsGui.Services.Downloads; + +/// +/// The shared response→file copy loop for manifest and Denuvo-fix downloads, plus the interim staging +/// folder they land in. +/// +/// +/// This body previously existed twice, byte for byte: LuaToolsApiClient.SaveResponseAsync and +/// HubcapService.SaveResponseAsync, each with its own private copy of the staging path constant. +/// Both now delegate here, so the progress contract and the filename sanitising live in one place. +/// +/// Staging is under %TEMP% so downloads never pollute the user's Downloads folder. Files are deleted by +/// the caller once installed; catches whatever a crash or a declined overwrite +/// confirmation left behind. +/// +internal static class HttpFileDownloader +{ + /// Interim staging destination. Downloads land here, then are deleted once installed. + public static readonly string StagingFolder = + Path.Combine(Path.GetTempPath(), "LuaToolsGui", "downloads"); + + /// Stream the response body to a staged file, reporting byte counts as it goes. + public static async Task SaveResponseAsync( + HttpResponseMessage res, string fallbackName, + IProgress? progress, CancellationToken ct) + { + string fileName = res.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? fallbackName; + foreach (char c in Path.GetInvalidFileNameChars()) fileName = fileName.Replace(c, '_'); + + Directory.CreateDirectory(StagingFolder); + string filePath = Path.Combine(StagingFolder, fileName); + + long? total = res.Content.Headers.ContentLength; + await using var src = await res.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(filePath); + + var buffer = new byte[81920]; + long written = 0; + int read; + while ((read = await src.ReadAsync(buffer, ct)) > 0) + { + await dst.WriteAsync(buffer.AsMemory(0, read), ct); + written += read; + progress?.Report(new DownloadProgress(written, total)); + } + + // One final report so a zero-length or single-chunk body still settles the bar at 100%. + progress?.Report(new DownloadProgress(written, total ?? written)); + + return new DownloadedFile(filePath, fileName); + } + + /// + /// Best-effort delete of staged files older than a day. Called once at startup: a download that was + /// interrupted by a crash, or whose overwrite confirmation was never answered, leaves its zip behind. + /// + public static void SweepStale() + { + try + { + if (!Directory.Exists(StagingFolder)) return; + var cutoff = DateTime.UtcNow.AddDays(-1); + foreach (string path in Directory.EnumerateFiles(StagingFolder)) + { + try + { + if (File.GetLastWriteTimeUtc(path) < cutoff) File.Delete(path); + } + catch { /* in use or gone. Next startup gets it */ } + } + } + catch { /* best effort */ } + } +} diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs new file mode 100644 index 0000000..6296ce2 --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -0,0 +1,998 @@ +using System.IO; +using System.IO.Compression; +using LuaToolsGui.Models; + +namespace LuaToolsGui.Services.Downloads; + +/// +/// Builds the s for every in-scope download: game manifests, DLC generation +/// and Denuvo fixes. +/// +/// +/// This is the convergence point for what used to be three separate download+install implementations: +/// DownloadViewModel.DownloadFromSourceAsync, PluginAddService.DownloadAsync and +/// HttpServerService.DownloadAndInstallAsync. The Hubcap-vs-lua.tools branch, the ZIP byte +/// sniff (previously copy-pasted into three files) and the staged-file cleanup now exist exactly once. +/// +public class ManifestJobFactory( + LuaToolsApiClient api, + HubcapService hubcap, + SettingsService settings, + LuaInstaller installer, + SteamLibraryService library, + CoverCache covers, + ToastService toast, + DepotDownloaderService depotTool, + SteamDepotInfo depotInfo, + SteamAutoCrackService sac, + AppliedFixIndexService fixIndex, + Sources.PackSourceService packSources) +{ + // ── Job builders ───────────────────────────────────────────────── + + /// A base-game manifest from a named source (Hubcap uses the user's own key). + public DownloadJob CreateManifestJob( + long appId, string? gameName, string sourceName, bool needsKey, + Func>? confirm = null, + Action? onFinished = null, + Action? onReveal = null) + { + string title = gameName ?? appId.ToString(); + return new DownloadJob( + DownloadKind.Manifest, + $"manifest:{appId}", + appId, + title, + SourceMeta.Get(sourceName).DisplayName ?? sourceName, + covers.GetLocalPath(appId), + (_, progress, ct) => needsKey + ? hubcap.DownloadManifestAsync(appId.ToString(), settings.HubcapApiKey ?? "", progress, ct) + : api.DownloadManifestAsync(appId.ToString(), sourceName, gameName, progress, ct), + (file, _, _) => Task.FromResult(InstallManifest(file, appId, title)), + confirm, + onFinished, + 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, + Action? onFinished = null, + Action? onReveal = null) + { + string title = gameName ?? appId.ToString(); + return new DownloadJob( + DownloadKind.Dlc, + $"dlc:{appId}", + appId, + title, + Resources.Strings.Downloads_Kind_Dlc, + covers.GetLocalPath(appId), + (_, progress, ct) => api.GenerateDlcAsync(appId.ToString(), baseAppId, gameName, progress, ct), + (file, _, _) => Task.FromResult(InstallManifest(file, appId, title)), + ConfirmAsync: null, + OnFinished: onFinished, + OnReveal: onReveal); + } + + /// + /// A Denuvo fix slot. "manifest" installs force-locked into Steam (fixes must stay version-pinned); + /// "fix" extracts the zip into the game's install folder. Neither restarts Steam. + /// + public DownloadJob CreateDenuvoJob( + string fixId, string slot, string fallbackName, + long appId, string gameName, string fixTitle, + Action? onFinished = null) + { + bool isManifestSlot = slot == "manifest"; + return new DownloadJob( + isManifestSlot ? DownloadKind.DenuvoManifest : DownloadKind.DenuvoFix, + $"denuvo:{fixId}:{slot}", + appId, + gameName, + fixTitle, + covers.GetLocalPath(appId), + (_, progress, ct) => + { + // Verify the game is on disk BEFORE the request. /api/denuvo/download spends a slot of + // the server-side daily limit and the fix zip is game binaries, so discovering "not + // installed" in the install phase (where ApplyDenuvoFix still checks, as a backstop) + // costs a slot and a full download for nothing. The Fixes page disables the button for + // uninstalled games, but a queued fix can outlive that check if the user uninstalls + // while it waits its turn. + if (!isManifestSlot && library.GetInstallDir(appId) is null) + throw new DownloadAbortedException( + string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName)); + return api.DownloadDenuvoAsync(fixId, slot, fallbackName, progress, ct); + }, + (file, _, _) => Task.FromResult(isManifestSlot + ? InstallDenuvoManifest(file, appId, gameName) + : ApplyDenuvoFix(file, appId, fixId, gameName)), + ConfirmAsync: null, + OnFinished: onFinished); + } + + /// + /// Raw depot content for a game. ONE queue item covers the whole selection; internally it runs the + /// downloader once per depot, in list order. + /// + /// + /// Sequential by necessity, not preference: the tool's -manifestfile is a single value applied + /// to every depot in its own loop, so a batched call would feed them all the same manifest. + /// + public DownloadJob CreateDepotJob( + long appId, string gameName, IReadOnlyList selections, string outDir, + Action? onFinished = null) + { + return new DownloadJob( + DownloadKind.Depot, + $"depot:{appId}", + appId, + gameName, + Resources.Strings.Downloads_Kind_Depot, + covers.GetLocalPath(appId), + (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)), + 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); + } + + private async Task RunDepotsAsync( + DownloadItem item, long appId, string gameName, IReadOnlyList selections, + 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) }; + + // 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); + } + + // ── 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 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))); + + // ── Phase 3: download ──────────────────────────────────────────────────────────────────────── + string keysFile = DepotDownloaderService.WriteKeysFile(keys); + try + { + long done = 0; + for (int i = 0; i < resolved.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var ready = resolved[i]; + + // 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; } + + // 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); + + // 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. + // + // An existing output folder forces the same treatment even on a fresh item: it means a + // previous session already wrote here, and CompletedDepots does not survive an app + // restart. Skipping validation there would hand back a half-written file reported as + // complete, which is this tool's worst failure mode. + bool validate = item.NeedsValidate || outDirExisted; + item.NeedsValidate = false; + + long baseBytes = done; + 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(() => + { + item.Detail = ph switch + { + 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 + : string.Format(Resources.Strings.Depot_Err_Failed, ready.DepotId, res.Error ?? "")); + + item.CompletedDepots.Add(ready.DepotId); + done += ready.Size; + progress.Report(new DownloadProgress(done, totalSize)); + } + + OnUi(() => item.Detail = null); + // Sentinel for the queue's file plumbing: a directory, so the staged-file cleanup no-ops on it. + return new DownloadedFile(outDir, gameName); + } + finally + { + DeleteStaged(keysFile); // holds decryption keys; never leave it lying around + } + } + + /// + /// 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). + /// + private async Task ResolveSharedAsync(DepotSelection sel, CancellationToken ct) + { + if (sel.ManifestId is not null || sel.FromAppId is not { } owner) return sel; + + var info = await depotInfo.GetAsync(owner, ct); + if (info?.Depots.FirstOrDefault(d => d.Id == sel.DepotId) is not { PublicManifestId: not null } owned) + throw new DownloadAbortedException(Resources.Strings.Depot_Err_NoManifest); + + return sel with { ManifestId = owned.PublicManifestId, Size = owned.Size }; + } + + /// + /// The depotcache path for a depot's manifest, fetching it from the API and installing it there if + /// it's missing. Never returns null — it throws with a user-facing reason instead. + /// + 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); + + OnUi(() => item.Detail = $"{step} · {Resources.Strings.Downloads_Depot_FetchingManifest}"); + + DownloadedFile staged; + try + { + staged = await api.DownloadDepotManifestAsync(sel.DepotId, sel.ManifestId!, null, ct); + } + catch (AuthException) + { + // Signed out between opening the picker and the download starting. + throw new DownloadAbortedException(Resources.Strings.Depot_Err_SignIn); + } + + // The API is expected to serve raw manifest bytes, but has been observed returning them inside + // a ZIP (a single entry named "z"). Sniff rather than assume, exactly as InstallManifest does for + // lua/zip: writing the wrapper into depotcache yields a file SteamKit rejects with + // "Unrecognized magic value 4034B50" (0x04034B50 being the PK header), and it is sticky + // once written because InstallManifestFile skips an existing destination. + string unzipDir = Path.Combine(Path.GetDirectoryName(staged.FilePath)!, "mf_" + Guid.NewGuid().ToString("N")); + try + { + string manifestFile = staged.FilePath; + if (IsZip(staged.FilePath)) + { + Directory.CreateDirectory(unzipDir); + using var archive = ZipFile.OpenRead(staged.FilePath); + var entry = archive.Entries.FirstOrDefault(e => !string.IsNullOrEmpty(e.Name)) + ?? throw new DownloadAbortedException(Resources.Strings.Depot_Err_NoManifest); + + // InstallManifestFile names the destination after this file, so it must already carry + // the _.manifest name; the entry inside the zip is just called "z". + manifestFile = Path.Combine(unzipDir, $"{sel.DepotId}_{sel.ManifestId}.manifest"); + entry.ExtractToFile(manifestFile, overwrite: true); + } + + // Check BEFORE writing. A bad file that reaches depotcache is sticky, so every later run + // resolves it locally and fails identically with no way back short of deleting it by hand. + if (!IsSteamManifest(manifestFile)) + throw new DownloadAbortedException(Resources.Strings.Depot_Err_NoManifest); + + // Reuse the same depotcache write that manifest installs already use: it keeps the + // _.manifest name, skips an identical existing file and stamps the mtime. + var result = installer.InstallManifestFile(manifestFile); + if (result.AnyFailed) + throw new DownloadAbortedException(result.Error ?? Resources.Strings.Depot_Err_NoManifest); + } + finally + { + DeleteStaged(staged.FilePath); + try { if (Directory.Exists(unzipDir)) Directory.Delete(unzipDir, recursive: true); } catch { } + } + + OnUi(() => item.Detail = step); + + return depotTool.ResolveManifestPath(sel.DepotId, sel.ManifestId!) + ?? throw new DownloadAbortedException(Resources.Strings.Depot_Err_NoManifest); + } + + /// Marshal an observable-property write onto the dispatcher (this runs on a worker). + private static void OnUi(Action a) => + System.Windows.Application.Current?.Dispatcher.InvokeAsync(a); + + // ── Install phases ─────────────────────────────────────────────── + + /// + /// Install a downloaded manifest/lua into Steam and turn the result into a user-facing message. + /// + /// + /// The file is always staged as "<appid>.zip", but some sources return a BARE .lua with no zip + /// wrapper. Unzipping that throws "End of Central Directory record could not be found", so the + /// bytes are sniffed rather than the extension trusted. + /// + private JobResult InstallManifest(DownloadedFile file, long appId, string gameName) + { + try + { + var result = IsZip(file.FilePath) + ? installer.InstallZip(file.FilePath, appId) + : installer.InstallLua(file.FilePath, appId); + + if (result.Error is not null) return new JobResult(false, result.Error); + if (result.AnyFailed) + return new JobResult(false, + string.Format(Resources.Strings.Add_Status_InstallFailed, result.Failed.Count)); + + string message = result.ManifestCount > 0 + ? string.Format(Resources.Strings.Add_Status_AddedManifests, gameName, result.ManifestCount) + : string.Format(Resources.Strings.Add_Status_AddedFetch, gameName); + + // 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 + { + DeleteStaged(file.FilePath); // consumed by the install + } + } + + /// + /// Denuvo manifest slot: force-locked install (version-pinned so an auto-update can't break the fix). + /// + /// + /// This used to call SteamService.RestartSteam() unconditionally and without asking, which + /// killed Steam and every running game on each fix install. OpenSteamTools/BetterSteamTools watch + /// the lua directories listed in opensteamtool.toml's [lua] paths — which includes the + /// config/stplug-in we just wrote to — so the write itself applies the change live. + /// + private JobResult InstallDenuvoManifest(DownloadedFile file, long appId, string gameName) + { + try + { + bool isZip = file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + var result = isZip + ? installer.InstallZip(file.FilePath, appId, forceLocked: true) + : installer.InstallLuaFile(file.FilePath, appId, forceLocked: true); + + if (result.AnyFailed) + { + string err = result.Error ?? Resources.Strings.Fixes_Toast_InstallFailed_Body; + toast.Show(Resources.Strings.Fixes_Toast_InstallFailed, err, error: true); + return new JobResult(false, err); + } + + string message = string.Format(Resources.Strings.Fixes_Toast_FixInstalled_Body, gameName); + toast.Show(Resources.Strings.Fixes_Toast_FixInstalled, message); + return new JobResult(true, message, installer.ReadInstalledLua(appId)); // see InstallManifest + } + finally + { + DeleteStaged(file.FilePath); + } + } + + /// 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 + { + string? installDir = library.GetInstallDir(appId); + if (installDir is null) + { + 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 fixKey = SafeFixKey(fixId); + string fixDir = Path.Combine(installDir, FixRecordDir); + string recordPath = Path.Combine(fixDir, $"{fixKey}.json"); + + 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; + 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; } + + // 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 + { + DenuvoFixRecordEntry recordEntry; + if (bakRel is not null) + { + 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); + } + // 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 + { + 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++; } // entry stays OUT of `applied`, so the settled record won't claim it + } + + // ── 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) + { + string err = string.Format(Resources.Strings.Fixes_Toast_PartiallyApplied_Body, failed); + toast.Show(Resources.Strings.Fixes_Toast_PartiallyApplied, err, error: true); + return new JobResult(false, err); + } + + string message = string.Format(Resources.Strings.Fixes_Toast_FixApplied_Body, gameName); + toast.Show(Resources.Strings.Fixes_Toast_FixApplied, 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); + } + finally + { + DeleteStaged(file.FilePath); + } + } + + // ── Fix revert ────────────────────────────────────────────────── + + private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + 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 record itself. + /// + internal static string SafeFixKey(string fixId) => + string.Concat(fixId.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); + + /// 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 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 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. + /// + 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's revert record from disk, or null if it doesn't exist. + internal static DenuvoFixRecord? ReadFixRecord(string recordPath) + { + try + { + 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 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) + { + 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 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, conflicts = 0; + string? conflictFile = null; + + foreach (var entry in record.Files) + { + // 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; } + + try + { + if (entry.Action == "modified" && entry.BackupPath is { } bakRel) + { + if (ResolveInside(Path.Combine(installDir, FixRecordDir), bakRel) + is not { } bakAbs) { errors++; continue; } + 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)) + { + 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++; + } + } + } + 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 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 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 record is what makes the + // Revert button appear — both go, and the fix reads as un-applied again. + string backupDir = Path.Combine(installDir, FixRecordDir, SafeFixKey(fixId)); + try { if (Directory.Exists(backupDir)) Directory.Delete(backupDir, recursive: true); } 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); + return new JobResult(true, message, installDir); + } + catch (Exception ex) + { + // "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); + } + } + + // ── Helpers ────────────────────────────────────────────────────── + + /// + /// True if the file begins with the ZIP local-file-header magic (PK\x03\x04). A bare .lua (or any + /// non-zip a source returned under a .zip name) returns false, so it installs as a loose lua. + /// + public static bool IsZip(string path) + { + try + { + using var fs = File.OpenRead(path); + Span sig = stackalloc byte[4]; + return fs.Read(sig) == 4 && sig[0] == 0x50 && sig[1] == 0x4B && sig[2] == 0x03 && sig[3] == 0x04; + } + catch { return false; } + } + + /// + /// True if the file starts with Steam's depot-manifest magic (0x71F617D0, little-endian on disk). + /// A cheap guard against storing something that merely arrived under a .manifest name. + /// + private static bool IsSteamManifest(string path) + { + try + { + using var fs = File.OpenRead(path); + Span sig = stackalloc byte[4]; + return fs.Read(sig) == 4 && + sig[0] == 0xD0 && sig[1] == 0x17 && sig[2] == 0xF6 && sig[3] == 0x71; + } + catch { return false; } + } + + /// Best-effort delete of a staged download once it has been consumed. + public static void DeleteStaged(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } + } +} 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/GithubProxy.cs b/src/LuaToolsGui/Services/GithubProxy.cs index 4d80a18..7076ca8 100644 --- a/src/LuaToolsGui/Services/GithubProxy.cs +++ b/src/LuaToolsGui/Services/GithubProxy.cs @@ -1,23 +1,23 @@ -using System.IO; +using System.IO; using System.Net.Http; 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. /// 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) => @@ -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..4009248 100644 --- a/src/LuaToolsGui/Services/HttpServerService.cs +++ b/src/LuaToolsGui/Services/HttpServerService.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using System.IO.Compression; using System.Net; @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Windows; +using LuaToolsGui.Services.Downloads; using LuaToolsGui.ViewModels; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -13,20 +14,6 @@ namespace LuaToolsGui.Services; -public record DownloadState -{ - public string Status { get; set; } = "queued"; // queued, downloading, processing, done, error, cancelled - public long BytesRead { get; set; } - public long TotalBytes { get; set; } - public string? CurrentApi { get; set; } - public Dictionary ApiErrors { get; set; } = new(); - public string? Error { get; set; } - public string? InstalledPath { get; set; } - public bool Success { get; set; } - public string? Api { get; set; } - public CancellationTokenSource? Cts { get; set; } -} - public class HttpServerService : IHostedService { private readonly LuaInstaller _installer; @@ -37,7 +24,9 @@ public class HttpServerService : IHostedService private HttpListener? _listener; private CancellationTokenSource? _appCts; - private readonly ConcurrentDictionary _downloads = new(); + // appid -> the queue item for its manifest download. Retained after completion so a late poll from + // the store-page popup still sees "done" (the previous DownloadState dictionary behaved the same way). + private readonly ConcurrentDictionary _downloads = new(); private List _apiSources = new(); private bool _apiSourcesLoaded = false; @@ -95,7 +84,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 +100,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 +162,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 +232,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 +314,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 { @@ -357,54 +346,86 @@ private static bool MatchPost(string? path, string pattern, out string id) => if (string.IsNullOrWhiteSpace(source)) return (400, JsonErr("source is required")); - if (_downloads.TryGetValue(appId, out var existing) && existing.Status is "downloading" or "processing") + // The queue's DedupeKey is the real duplicate guard; this keeps the documented 409 contract. + var queue = _services.GetRequiredService(); + if (queue.FindActive($"manifest:{appId}") is not null) return (409, JsonErr("Download already in progress for this app")); - var cts = new CancellationTokenSource(); - var state = new DownloadState - { - Status = "queued", - CurrentApi = source, - Cts = cts, - }; - _downloads[appId] = state; - - _ = DownloadAndInstallAsync(appId, source, cts.Token); + var jobs = _services.GetRequiredService(); + _downloads[appId] = queue.Enqueue(jobs.CreateManifestJob(appId, null, source, needsKey: false)); return (200, Json(new { success = true })); } + /// + /// Project a queue item onto the EXACT JSON the store-page plugin already expects. The field names + /// and the status vocabulary are a published contract consumed by luatools.js (via main.lua's + /// GetAddViaLuaToolsStatus), so nothing here may drift. + /// private (int, string) HandleStatus(long appId) { - if (!_downloads.TryGetValue(appId, out var state)) + if (!_downloads.TryGetValue(appId, out var item)) return (200, Json(new { success = true, state = (object?)null })); + bool done = item.Status == Services.Downloads.DownloadStatus.Completed; + var (bytesRead, totalBytes) = LegacyBytes(item); + var payload = new { - status = state.Status, - bytesRead = state.BytesRead, - totalBytes = state.TotalBytes, - currentApi = state.CurrentApi, - apiErrors = state.ApiErrors.Count > 0 ? state.ApiErrors : null, - error = state.Error, - installedPath = state.InstalledPath, - success = state.Success, - api = state.Api, + status = StatusWireName(item.Status), + bytesRead, + totalBytes, + currentApi = item.SubTitle, + apiErrors = (object?)null, // nothing ever populated this + error = item.Status is Services.Downloads.DownloadStatus.Failed + or Services.Downloads.DownloadStatus.Cancelled ? item.Message : null, + installedPath = (string?)null, + success = done, + api = done ? item.SubTitle : null, }; return (200, Json(new { success = true, state = payload })); } + /// + /// The wire vocabulary. Note "failed", NOT "error": the plugin's startPolling shows its failure UI + /// on "failed", and the old DownloadState comment claiming "error" was simply wrong. + /// + private static string StatusWireName(Services.Downloads.DownloadStatus s) => s switch + { + Services.Downloads.DownloadStatus.Queued => "queued", + Services.Downloads.DownloadStatus.Downloading => "downloading", + Services.Downloads.DownloadStatus.AwaitingConfirmation => "processing", + Services.Downloads.DownloadStatus.Installing => "processing", + Services.Downloads.DownloadStatus.Completed => "done", + Services.Downloads.DownloadStatus.Cancelled => "cancelled", + _ => "failed", + }; + + /// + /// Real byte counts when the response had a Content-Length. When it did not, fall back to exactly + /// what this endpoint used to synthesize (0 of 100) rather than 0/0, so an unknown-length download + /// renders no worse in the popup than it did before. + /// + private static (long BytesRead, long TotalBytes) LegacyBytes(Services.Downloads.DownloadItem item) => + item.TotalBytes is > 0 ? (item.BytesRead, item.TotalBytes.Value) : (0L, 100L); + + /// + /// Cancel this app's in-flight manifest download. + /// + /// + /// Resolved through the queue rather than this class's own dictionary, which means it now also + /// cancels adds started by the store-page popup's own pipeline (PluginAddService, /add/{appid}). + /// Those share the DedupeKey "manifest:{appid}" but never touched _downloads, so before the queue + /// existed this endpoint silently did nothing for them and the download ran on after the popup closed. + /// private (int, string) HandleCancel(long appId) { - if (_downloads.TryGetValue(appId, out var state) && state.Status is "queued" or "downloading" or "processing") - { - state.Cts?.Cancel(); - state.Status = "cancelled"; - state.Error = "Cancelled by user"; - _downloads[appId] = state; - return (200, Json(new { success = true })); - } - return (200, Json(new { success = true, message = "Nothing to cancel" })); + var queue = _services.GetRequiredService(); + var item = queue.FindActive($"manifest:{appId}"); + if (item is null) return (200, Json(new { success = true, message = "Nothing to cancel" })); + + queue.Cancel(item); + return (200, Json(new { success = true })); } private (int, string) HandleRemove(long appId) @@ -519,7 +540,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(); @@ -578,62 +599,6 @@ private static bool MatchPost(string? path, string pattern, out string id) => // ── Download worker ─────────────────────────────────────────────── - private async Task DownloadAndInstallAsync(long appId, string source, CancellationToken ct) - { - var state = _downloads[appId]; - try - { - state.Status = "downloading"; - state.BytesRead = 0; - state.TotalBytes = 100; // progress reported as a 0..100 percentage - - var api = _services.GetRequiredService(); - var progress = new Progress(p => - { - if (p is not null) - { - state.TotalBytes = 100; - state.BytesRead = (long)(p.Value * 100); - } - }); - - // Download through the app's authenticated lua.tools proxy BY SOURCE NAME - // (same path as DownloadViewModel.DownloadFromSourceAsync). Works for every - // dynamic source, not just ones with a public URL. - var download = await api.DownloadManifestAsync(appId.ToString(), source, null, progress, ct); - - state.Status = "processing"; - var result = _installer.InstallZip(download.FilePath, appId); - try { if (File.Exists(download.FilePath)) File.Delete(download.FilePath); } catch { } - - if (result.Error is not null) - { - state.Status = "failed"; // frontend startPolling shows failure UI on "failed" - state.Error = result.Error; - return; - } - - state.Status = "done"; - state.Success = true; - state.Api = source; - } - catch (OperationCanceledException) - { - state.Status = "cancelled"; - state.Error = "Cancelled by user"; - } - catch (Exception ex) - { - state.Status = "failed"; // frontend startPolling shows failure UI on "failed" - state.Error = ex.Message; - } - finally - { - state.Cts?.Dispose(); - state.Cts = null; - } - } - // ── Helpers ─────────────────────────────────────────────────────── private static void SetCors(HttpListenerResponse resp) diff --git a/src/LuaToolsGui/Services/HubcapService.cs b/src/LuaToolsGui/Services/HubcapService.cs index 26701dc..4fac409 100644 --- a/src/LuaToolsGui/Services/HubcapService.cs +++ b/src/LuaToolsGui/Services/HubcapService.cs @@ -1,42 +1,34 @@ -using System.IO; -using System.Net; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; 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 { // 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 }; - // Interim staging destination, mirroring LuaToolsApiClient (downloads land here before install, - // then are deleted once installed). Under %TEMP% so it never pollutes the user's Downloads folder. - private static readonly string InterimDownloadsFolder = - Path.Combine(Path.GetTempPath(), "LuaToolsGui", "downloads"); - [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 @@ -65,7 +57,7 @@ public partial class HubcapService /// Download the manifest zip for an app directly from Hubcap (counts toward the key's daily /// limit). Throws on failure so the download flow can report it. public async Task DownloadManifestAsync( - string appid, string key, IProgress? progress, CancellationToken ct = default) + string appid, string key, IProgress? progress, CancellationToken ct = default) { var url = $"/api/v1/manifest/{Uri.EscapeDataString(appid)}?api_key={Uri.EscapeDataString(key)}"; using var res = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); @@ -73,44 +65,18 @@ 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); } - return await SaveResponseAsync(res, $"{appid}.zip", progress, ct); + return await HttpFileDownloader.SaveResponseAsync(res, $"{appid}.zip", progress, ct); } // ── Plumbing ──────────────────────────────────────────────────── private static async Task ReadJsonAsync(HttpResponseMessage res, CancellationToken ct) => JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); - - private static async Task SaveResponseAsync( - HttpResponseMessage res, string fallbackName, IProgress? progress, CancellationToken ct) - { - string fileName = res.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? fallbackName; - foreach (char c in Path.GetInvalidFileNameChars()) fileName = fileName.Replace(c, '_'); - - Directory.CreateDirectory(InterimDownloadsFolder); - string filePath = Path.Combine(InterimDownloadsFolder, fileName); - - long? total = res.Content.Headers.ContentLength; - await using var src = await res.Content.ReadAsStreamAsync(ct); - await using var dst = File.Create(filePath); - - var buffer = new byte[81920]; - long written = 0; - int read; - while ((read = await src.ReadAsync(buffer, ct)) > 0) - { - await dst.WriteAsync(buffer.AsMemory(0, read), ct); - written += read; - progress?.Report(total is > 0 ? (double)written / total.Value : null); - } - - return new DownloadedFile(filePath, fileName); - } } 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..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; @@ -7,17 +7,32 @@ 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". /// /// -public record LuaEntry(long Id, bool HasKey, string? ManifestId, string? CommentedManifestId, string? Comment); +/// +/// The depot decryption key from addappid(id, 1, "key"), or null for a bare addappid(id) +/// (a DLC entitlement rather than a content depot). Carried, not just counted, because the depot +/// downloader needs the actual key bytes to decrypt CDN chunks. +/// +public record LuaEntry(long Id, string? Key, string? ManifestId, string? CommentedManifestId, 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. /// /// 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 +48,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; } @@ -58,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. @@ -71,7 +88,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)) @@ -90,17 +107,18 @@ public static partial class LuaFileParser // ANY occurrence counts (keeping first-seen order), otherwise the keyless line wins and // we'd wrongly report the depot/DLC as "not in lua". var order = new List(); - var hasKeyById = new Dictionary(); + 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 disabledHasKey = new Dictionary(); + var disabledKeyById = new Dictionary(); // Match one line at a time: a single-line regex can't span lines, so each addappid stays // 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("--"); @@ -109,16 +127,25 @@ 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; - // A commented-out addappid is a DISABLED declaration, not an active one — matched against + // 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); 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); + string? key = m.Groups[2].Success && !string.IsNullOrEmpty(m.Groups[2].Value) + ? m.Groups[2].Value + : null; - // 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". @@ -127,32 +154,36 @@ public static partial class LuaFileParser (!commentById.TryGetValue(id, out var prev) || comment.Length > prev.Length)) commentById[id] = comment; + // Merge keeps the first key seen for an id: a keyed line must never be overwritten by a + // later bare addappid(id) for the same depot (same reasoning as the old bool OR-merge). if (commented) { - if (disabledHasKey.TryGetValue(id, out bool had)) disabledHasKey[id] = had || hasKey; - else { disabledHasKey[id] = hasKey; disabledOrder.Add(id); } + if (disabledKeyById.TryGetValue(id, out var hadKey)) disabledKeyById[id] = hadKey ?? key; + else { disabledKeyById[id] = key; disabledOrder.Add(id); } continue; } - if (hasKeyById.TryGetValue(id, out bool existing)) - hasKeyById[id] = existing || hasKey; - else { hasKeyById[id] = hasKey; order.Add(id); } + if (keyById.TryGetValue(id, out var existing)) + keyById[id] = existing ?? key; + else { keyById[id] = key; order.Add(id); } } var entries = order - .Select(id => new LuaEntry(id, hasKeyById[id], + .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 // (the merge above already counted it), so it must not also be reported as disabled. var disabled = disabledOrder - .Where(id => !hasKeyById.ContainsKey(id)) - .Select(id => new LuaEntry(id, disabledHasKey[id], + .Where(id => !keyById.ContainsKey(id)) + .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). @@ -161,7 +192,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..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 @@ -23,7 +26,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 +37,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 +48,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 +74,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 +89,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 +141,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 +173,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 +225,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 +240,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 +262,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 +270,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 +300,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..a9986df 100644 --- a/src/LuaToolsGui/Services/LuaToolsApiClient.cs +++ b/src/LuaToolsGui/Services/LuaToolsApiClient.cs @@ -1,9 +1,9 @@ -using System.IO; -using System.Net; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.Services; @@ -17,16 +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) { - // Interim staging destination: downloads land here, get installed into Steam, then are deleted. - // Under %TEMP% (not the user's Downloads) so nothing accumulates in a user-visible folder. - private static readonly string InterimDownloadsFolder = - Path.Combine(Path.GetTempPath(), "LuaToolsGui", "downloads"); - - 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 }; @@ -34,7 +27,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 +57,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 +70,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 +86,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 +97,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 +117,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 +127,7 @@ public async Task> CheckSourcesAsync(string appid, Ca } catch { - return null; // decorative — never block on it + return null; // decorative, never block on it } } @@ -155,15 +148,37 @@ public async Task> CheckSourcesAsync(string appid, Ca } public Task DownloadManifestAsync( - string appid, string source, string? gameName, IProgress? progress, CancellationToken ct = default) + string appid, string source, string? gameName, + IProgress? progress, CancellationToken ct = default) { string url = $"/api/manifest/download?appid={appid}&source={Uri.EscapeDataString(source)}"; if (!string.IsNullOrEmpty(gameName)) url += $"&game_name={Uri.EscapeDataString(gameName)}"; return DownloadFileAsync(url, $"{appid}.zip", progress, ct); } + /// + /// One depot's raw .manifest by id, so a depot download no longer depends on Steam happening + /// to have the file in its depotcache. + /// + /// + /// Unlike every other endpoint here the ids go in the PATH, not the query string. Auth is the same + /// Bearer token as the rest, and the response is raw bytes on 200 / a JSON error otherwise, which + /// already turns into an . + /// + /// This one writes no history row and does NOT consume the daily download cap. It is instead + /// limited to 120 requests per 10 minutes per user, and only cache misses count. A large game is + /// ~20 depots, comfortably inside that — which is why manifests are fetched lazily per depot at + /// download time rather than eagerly when the picker opens. + /// + public Task DownloadDepotManifestAsync( + long depotId, string manifestId, + IProgress? progress, CancellationToken ct = default) + => DownloadFileAsync($"/api/givemethemanifestpunk/{depotId}/{manifestId}", + $"{depotId}_{manifestId}.manifest", progress, ct); + public Task GenerateDlcAsync( - string appid, string baseAppId, string? gameName, IProgress? progress, CancellationToken ct = default) + string appid, string baseAppId, string? gameName, + IProgress? progress, CancellationToken ct = default) { string url = $"/api/dlc/generate?appid={appid}&base={baseAppId}"; if (!string.IsNullOrEmpty(gameName)) url += $"&game_name={Uri.EscapeDataString(gameName)}"; @@ -172,7 +187,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 +195,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,20 +204,21 @@ 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( - string fixId, string slot, string fallbackName, IProgress? progress, CancellationToken ct = default) + string fixId, string slot, string fallbackName, + IProgress? progress, CancellationToken ct = default) { // 1. Ask the API for a signed URL (auth + daily-limit gate live here). var res = await SendAsync(HttpMethod.Get, $"/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 +235,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); } @@ -235,48 +254,21 @@ private async Task SendAsync( JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); private async Task DownloadFileAsync( - string url, string fallbackName, IProgress? progress, CancellationToken ct) + string url, string fallbackName, IProgress? progress, CancellationToken ct) { var res = await SendAsync(HttpMethod.Get, url, ct, HttpCompletionOption.ResponseHeadersRead); - return await SaveResponseAsync(res, fallbackName, progress, ct); + return await HttpFileDownloader.SaveResponseAsync(res, fallbackName, progress, ct); } /// Download a file from an absolute URL with NO auth header (e.g. a signed R2 link). private async Task DownloadFromUrlAsync( - string url, string fallbackName, IProgress? progress, CancellationToken ct) + string url, string fallbackName, IProgress? progress, CancellationToken ct) { // New request (not via SendAsync) so no Bearer header and the absolute URL isn't prefixed. 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); - return await SaveResponseAsync(res, fallbackName, progress, ct); - } - - private async Task SaveResponseAsync( - HttpResponseMessage res, string fallbackName, IProgress? progress, CancellationToken ct) - { - string fileName = res.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? fallbackName; - foreach (char c in Path.GetInvalidFileNameChars()) fileName = fileName.Replace(c, '_'); - - string folder = InterimDownloadsFolder; - Directory.CreateDirectory(folder); - string filePath = Path.Combine(folder, fileName); - - long? total = res.Content.Headers.ContentLength; - await using var src = await res.Content.ReadAsStreamAsync(ct); - await using var dst = File.Create(filePath); - - var buffer = new byte[81920]; - long written = 0; - int read; - while ((read = await src.ReadAsync(buffer, ct)) > 0) - { - await dst.WriteAsync(buffer.AsMemory(0, read), ct); - written += read; - progress?.Report(total is > 0 ? (double)written / total.Value : null); - } - - return new DownloadedFile(filePath, fileName); + throw new ApiException(string.Format(Resources.Strings.Api_Err_DownloadFailed, (int)res.StatusCode), res.StatusCode); + return await HttpFileDownloader.SaveResponseAsync(res, fallbackName, progress, ct); } } diff --git a/src/LuaToolsGui/Services/LuaVault.cs b/src/LuaToolsGui/Services/LuaVault.cs index 2d151cc..b70bfae 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,10 +146,23 @@ public IReadOnlyList GetVariants(long appId) } /// - /// 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. + /// "Has this game been captured at all". Reads the index rather than probing for the directory: + /// a folder can outlive its contents (or be half-written), and answering yes for an empty one kept + /// deleted games alive on the Depots page. /// - public bool HasVariants(long appId) => Directory.Exists(AppDir(appId)); + /// + /// This used to be a bare Directory.Exists because the game list called it per game on every + /// filter pass. It no longer does — badges are computed once per load in RefreshBadgesAsync, + /// off the UI thread, and its caller reads (which parses the index) two + /// lines later regardless. + /// + public bool HasVariants(long appId) => HasStoredVariants(appId); + + /// At least one variant recorded in this app's index. + private bool HasStoredVariants(long appId) + { + lock (_gate) return LoadIndex(appId).Variants.Count > 0; + } /// The variant Steam is currently using, or null if there is none / it was edited externally. public LuaVariant? GetActiveVariant(long appId) @@ -160,7 +173,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 +189,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 +201,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; @@ -204,7 +217,10 @@ public void AdoptLooseBuildLuas(long appId) return Apply(appId, build) ? build : null; } - /// Appids that have at least one stored variant. + /// + /// Appids that have at least one stored variant. A directory alone is not enough — an empty or + /// half-written one reports nothing, rather than a game with no versions to offer. + /// public IReadOnlyList AppsWithVariants() { try @@ -212,7 +228,7 @@ public IReadOnlyList AppsWithVariants() if (!Directory.Exists(_root)) return []; return Directory.EnumerateDirectories(_root) .Select(d => long.TryParse(Path.GetFileName(d), out long id) ? id : 0) - .Where(id => id > 0) + .Where(id => id > 0 && HasStoredVariants(id)) .ToList(); } catch { return []; } @@ -291,28 +307,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 +337,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 +379,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 +391,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 +423,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 +436,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 +444,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 +503,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 +517,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 +535,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 +590,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/ManifestFile.cs b/src/LuaToolsGui/Services/ManifestFile.cs new file mode 100644 index 0000000..c927b29 --- /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 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/ModeMigration.cs b/src/LuaToolsGui/Services/ModeMigration.cs new file mode 100644 index 0000000..6b4b1eb --- /dev/null +++ b/src/LuaToolsGui/Services/ModeMigration.cs @@ -0,0 +1,60 @@ +using LuaToolsGui.Models; + +namespace LuaToolsGui.Services; + +/// +/// Rewrites a pre-3-mode SelectedMode value into the current set. +/// +/// The old app had four modes: SteamTools, OpenSteamTools (upstream OST, but *displayed* as +/// "BetterSteamTools"), OpenSteamToolsNightly (displayed "BetterSteamTools Nightly") and a +/// CloudRedirect CLI fix. The two BetterSteamTools-branded ones become . +/// That's the name those users chose and it's still the name of what they get. SteamTools and +/// CloudRedirect are gone with no successor, so those users are reset to "no mode" and re-onboarded. +/// +/// Why this is safe to run on every launch. None of the legacy strings parse as a current +/// enum member, so "unparseable" is an exact synonym for "written by an older build". A user already +/// on Ost/Bst/Custom parses fine and is left completely alone, which is why no persisted +/// has-migrated marker is needed. This property is the entire reason the enum members are named +/// Ost/Bst/Custom: had OpenSteamTools been reused for the nightly channel, every legacy +/// stable-OST user would have silently parsed into the wrong mode, and the migration would have had +/// no way to tell those users apart from legitimate post-migration ones. +/// +public static class ModeMigration +{ + /// + /// Apply the migration to persisted settings. Returns true if onboarding must be re-shown. + /// + public static bool Apply(SettingsService settings) + { + var (newMode, resetOnboarding) = Migrate(settings.SelectedMode); + if (newMode != settings.SelectedMode) settings.SelectedMode = newMode; + return resetOnboarding; + } + + /// + /// The migration itself, as a pure function of the stored string so it can be tested without + /// touching the real settings file. + /// + /// Raw SelectedMode as found on disk. + /// + /// The value to store (null = no mode selected), and whether onboarding must be re-shown. True + /// only when the user WAS on a mode that has since been retired, so they now have none. + /// + public static (string? Mode, bool ResetOnboarding) Migrate(string? stored) + { + if (string.IsNullOrWhiteSpace(stored)) return (null, false); // fresh install; onboarding handles it + if (Enum.TryParse(stored, out var current)) + return (current.ToString(), false); // already current. Leave it be + + return stored switch + { + // Both were branded "BetterSteamTools" in the UI, so BST is what these users think they run. + "OpenSteamTools" or "OpenSteamToolsNightly" => (UnlockerMode.Bst.ToString(), false), + + // SteamTools and the CloudRedirect fix are retired with nothing to map onto. Clear the mode + // and send them back through onboarding to choose deliberately. Anything else unrecognised + // (hand-edited, or from a build we don't know) lands here too. Safer than guessing. + _ => (null, true), + }; + } +} diff --git a/src/LuaToolsGui/Services/PluginAddService.cs b/src/LuaToolsGui/Services/PluginAddService.cs index a7d39e5..e19a04a 100644 --- a/src/LuaToolsGui/Services/PluginAddService.cs +++ b/src/LuaToolsGui/Services/PluginAddService.cs @@ -1,13 +1,14 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; 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 . /// @@ -16,7 +17,8 @@ public class PluginAddService( HubcapService hubcap, SettingsService settings, AuthService auth, - LuaInstaller installer) + DownloadQueue queue, + ManifestJobFactory jobs) { private const string HubcapSourceName = "Sadie (Morrenus)"; @@ -51,19 +53,6 @@ public class AddState private readonly ConcurrentDictionary _states = new(); - /// True if the file begins with the ZIP magic (PK\x03\x04). A bare .lua returns false so - /// it's installed as a loose lua instead of unzipped. - private static bool IsZip(string path) - { - try - { - using var fs = File.OpenRead(path); - Span sig = stackalloc byte[4]; - return fs.Read(sig) == 4 && sig[0] == 0x50 && sig[1] == 0x4B && sig[2] == 0x03 && sig[3] == 0x04; - } - catch { return false; } - } - public AddState? GetState(long appId) => _states.TryGetValue(appId, out var s) ? s : null; /// Begin a headless add: check sources (+ Hubcap synth + gating + usage) and, if FastFetch @@ -119,7 +108,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 +181,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 +215,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) { @@ -269,6 +258,18 @@ private async Task FillStandardBadgeAsync(List rows) catch { } } + /// + /// Queue the pick through the shared and mirror its progress into this + /// service's plain-POCO state, which the store-page popup polls over HTTP. + /// + /// + /// The download and install themselves live in ManifestJobFactory, the same code path the Add + /// page uses. Mirroring (rather than data binding) is correct here: is + /// serialized to JSON on each poll, so it only needs to hold the latest values. + /// + /// Routing through the queue also makes the popup's Cancel button work for the first time: it posts + /// to /cancel/{appid}, which now resolves the same queue item this method enqueued. + /// private async Task DownloadAsync(long appId, AddState state, SourceRow row) { if (state.Busy) return; @@ -279,44 +280,39 @@ private async Task DownloadAsync(long appId, AddState state, SourceRow row) row.Downloading = true; row.Indeterminate = true; row.Progress = 0; + try { - var progress = new Progress(p => - { - row.Indeterminate = p is null; - if (p is not null) row.Progress = p.Value * 100; - }); + var job = jobs.CreateManifestJob(appId, state.GameName, row.Name, row.NeedsKey); + var item = queue.Enqueue(job); - DownloadedFile dl = row.NeedsKey - ? 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 - // 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) - ? installer.InstallZip(dl.FilePath, appId) - : installer.InstallLua(dl.FilePath, appId); - try { if (File.Exists(dl.FilePath)) File.Delete(dl.FilePath); } catch { } - - if (result.Error is not null) + void OnChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { - state.Error = result.Error; - state.InstallFailed = true; - PluginLog.Log($"PluginAdd.Download appid={appId} source='{row.Name}' INSTALL ERROR: {result.Error}"); + row.Indeterminate = item.IsIndeterminate; + row.Progress = item.Percent; + row.Downloading = item.IsActive; } - else + item.PropertyChanged += OnChanged; + + try { - // Reuse the GUI add flow's localized strings (see DownloadViewModel.ReportInstall) instead - // of a hardcoded English string, so the plugin popup is translated + consistent with the app. - // "· via {source}" mirrors the GUI's FastFetch suffix, naming the source this add came from. - var name = string.IsNullOrEmpty(state.GameName) ? "lua" : state.GameName; - state.InstallStatus = result.ManifestCount > 0 - ? string.Format(Resources.Strings.Add_Status_AddedManifests, name, result.ManifestCount) - : string.Format(Resources.Strings.Add_Status_AddedFetch, name); - state.InstallStatus += " " + string.Format(Resources.Strings.Add_FastFetch_Via, row.Name); - PluginLog.Log($"PluginAdd.Download appid={appId} source='{row.Name}' OK: {state.InstallStatus}"); + var result = await item.Completion; + + if (result is null || !result.Ok) + { + state.Error = result?.Message ?? item.Message; + state.InstallFailed = true; + PluginLog.Log($"PluginAdd.Download appid={appId} source='{row.Name}' FAILED: {state.Error}"); + } + else + { + // "· via {source}" mirrors the GUI's FastFetch suffix, naming the source this add used. + state.InstallStatus = result.Message + + " " + string.Format(Resources.Strings.Add_FastFetch_Via, row.Name); + PluginLog.Log($"PluginAdd.Download appid={appId} source='{row.Name}' OK: {state.InstallStatus}"); + } } + finally { item.PropertyChanged -= OnChanged; } } catch (Exception ex) { diff --git a/src/LuaToolsGui/Services/PluginInstallerService.cs b/src/LuaToolsGui/Services/PluginInstallerService.cs index 6a0d50d..b8c6143 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; @@ -20,12 +19,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 +32,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 +46,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 +74,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 +84,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 +104,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 +116,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}\"") { @@ -144,17 +143,17 @@ 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 /// 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 +254,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. @@ -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)); @@ -351,22 +350,22 @@ 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]); + SlotPath(slot) is not { } cur || !File.Exists(cur) || AssetHash.OfFile(cur) != slotShas[slot]); bool dllNeedsUpdate = !DllUpdateDisabled && (anySlotNeedsUpdate || legacyPresent); if (dllNeedsUpdate) { @@ -377,17 +376,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 +399,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 +422,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 +442,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 +460,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 +485,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 +509,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 +541,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 +579,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; } @@ -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/ProxiedFileDownloader.cs b/src/LuaToolsGui/Services/ProxiedFileDownloader.cs index b3481da..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; @@ -7,12 +9,27 @@ 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. -/// Wraps Velopack's stock , only swapping the URL per attempt. +/// (), so the in-app self-update works where github.com is blocked. +/// +/// 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 9f43004..9ffe08e 100644 --- a/src/LuaToolsGui/Services/SettingsService.cs +++ b/src/LuaToolsGui/Services/SettingsService.cs @@ -7,8 +7,10 @@ public class AppSettings { public string? SteamPathOverride { get; set; } - // ── Unlocker mode (Mode page) — user's chosen backend ──────────── - public string? SelectedMode { get; set; } // "SteamTools" | "OpenSteamTools" + // ── Unlocker mode (Mode page). User's chosen backend ──────────── + // "Ost" | "Bst" | "Custom". Older builds wrote "SteamTools" | "OpenSteamTools" | + // "OpenSteamToolsNightly" | "CloudRedirect"; see ModeMigration, which rewrites those on startup. + public string? SelectedMode { get; set; } // ── Install behavior ───────────────────────────────────────────── // When true (default), installs comment out setManifestid() lines and skip copying .manifest @@ -28,7 +30,7 @@ public class AppSettings // (→ default 24) is distinguishable from an explicit choice. public int? FixesPageSize { get; set; } - // Builds-page game-list results-per-page. 0 = "All". Kept separate from ManagePageSize — the Builds + // Builds-page game-list results-per-page. 0 = "All". Kept separate from ManagePageSize. The Builds // list is a narrow sidebar, so a size that suits the Manage grid rarely suits both. public int? BuildsPageSize { get; set; } @@ -36,7 +38,7 @@ public class AppSettings public string? Language { get; set; } // The user's own Hubcap (hubcapmanifest.com) API key ("smm_…"). Null = not configured; key-gated - // sources stay locked until set. Stored locally — the app calls Hubcap directly with it. + // sources stay locked until set. Stored locally. The app calls Hubcap directly with it. public string? HubcapApiKey { get; set; } // When true, register the app to launch on Windows sign-in (HKCU …\Run). Nullable so "never set" @@ -50,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 @@ -109,7 +116,7 @@ public int FixesPageSize } /// Builds-page game-list results-per-page (default 10). 0 = "All". Smaller than the other - /// pages' 24 — this list is a narrow sidebar next to the build detail, not a full-width grid. + /// pages' 24. This list is a narrow sidebar next to the build detail, not a full-width grid. public int BuildsPageSize { get => _settings.BuildsPageSize ?? 10; // default 10 @@ -151,14 +158,29 @@ 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"; private void Load() { // Prefer the primary file; fall back to the last-good .bak. Crucially, NEVER silently reset a - // corrupt-but-present file to defaults (a later Save would then overwrite it and lose real data) — - // move it aside to .corrupt so it's preserved and can't be clobbered. + // corrupt-but-present file to defaults (a later Save would then overwrite it and lose real data). + // Move it aside to .corrupt so it's preserved and can't be clobbered. if (TryLoad(FilePath)) return; PreserveCorrupt(FilePath); if (TryLoad(BakPath)) return; @@ -221,7 +243,7 @@ private void Save() string json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); // Atomic write: fill a temp file, then rename it over the target. A crash/kill mid-write can only - // ever truncate the .tmp — the live settings.json is replaced by an atomic move (same-volume rename) + // ever truncate the .tmp. The live settings.json is replaced by an atomic move (same-volume rename) // and is therefore never left half-written. (This class of loss is exactly what a forced kill during // a plain WriteAllText caused.) A .bak of the last good file is kept as a second recovery source. File.WriteAllText(TmpPath, json); 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/Services/SteamAppInfoCache.cs b/src/LuaToolsGui/Services/SteamAppInfoCache.cs index f61f75b..53b7222 100644 --- a/src/LuaToolsGui/Services/SteamAppInfoCache.cs +++ b/src/LuaToolsGui/Services/SteamAppInfoCache.cs @@ -1,10 +1,11 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using System.Net; using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; using LuaToolsGui.Models; +using Microsoft.Extensions.Logging; namespace LuaToolsGui.Services; @@ -42,7 +43,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,11 +63,11 @@ 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"); - 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. @@ -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,15 +136,77 @@ 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. + /// 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 @@ -158,23 +224,32 @@ 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; 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 +341,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) && @@ -286,9 +361,24 @@ 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 + /// 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 +436,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 +492,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++) @@ -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. @@ -482,8 +580,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..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; @@ -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/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/SteamDepotInfo.cs b/src/LuaToolsGui/Services/SteamDepotInfo.cs index abeeebe..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; @@ -7,20 +7,32 @@ 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, string? PublicManifestId = null) { public bool IsDlc => DlcAppId is not null; + + /// + /// The app that actually owns this depot's content (depotfromapp), for shared redistributables + /// like the VC++/DirectX runtimes under app 228980. Null for a game's own depots. + /// + /// + /// A shared depot appears in the consuming game's app-info as a three-field stub — config, + /// depotfromapp, sharedinstall — with NO manifests block at all, so it has neither a gid nor a size + /// here. Both live under the owning app, which is why this id has to be kept rather than collapsed + /// into the bool: it's the only way to go and look them up. + /// + public long? FromAppId { get; init; } } /// /// 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 +43,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. @@ -42,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. @@ -64,7 +76,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,8 +114,10 @@ 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. - bool isShared = v.TryGetProperty("depotfromapp", out _); + // Shared redistributable (VC++, DirectX, …). Belongs to another app. Keep it, flagged, + // AND keep the owning app id — the manifest can only be resolved from there. + bool isShared = v.TryGetProperty("depotfromapp", out var fromEl); + long? fromAppId = isShared && long.TryParse(fromEl.GetString(), out long fa) ? fa : null; long? dlcAppId = v.TryGetProperty("dlcappid", out var dlcEl) && long.TryParse(dlcEl.GetString(), out long dlc) ? dlc : null; @@ -117,7 +131,7 @@ public void Invalidate(long appId) } depots.Add(new ContentDepot(depotId, ReadPublicSize(v), dlcAppId, isShared, os, lang, - ReadPublicManifestId(v))); + ReadPublicManifestId(v)) { FromAppId = fromAppId }); } } @@ -132,7 +146,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..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). @@ -44,10 +53,60 @@ 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; } + /// + /// 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/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..26c940b 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)); @@ -42,8 +74,32 @@ public string? EffectivePath 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). @@ -54,9 +110,48 @@ 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. + /// 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 +210,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..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); - // Extract the WHOLE zip — the CLI needs its plugin DLLs alongside it. + // 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(); } } @@ -99,7 +144,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 +153,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/UnlockerService.cs b/src/LuaToolsGui/Services/UnlockerService.cs index c4241a5..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; @@ -9,10 +8,11 @@ namespace LuaToolsGui.Services; /// -/// Manages the mutually-exclusive Steam fixes (SteamTools / OpenSteamTools / CloudRedirect). Only one -/// is active at a time. Each fetches its own GitHub release, verifies files by sha256, and installs -/// into the Steam root (CloudRedirect runs a CLI that patches + deploys itself). Switching overwrites -/// shared files but doesn't delete the previous mode's leftovers. The active mode persists in settings. +/// Manages the mutually-exclusive Steam unlockers (OpenSteamTools / BetterSteamTools / Custom). Only +/// one is active at a time. Each managed mode resolves its own build, verifies files by sha256, and +/// installs into the Steam root; Custom downloads and verifies nothing, since the user owns those +/// files. Switching overwrites shared files but doesn't delete the previous mode's leftovers. The +/// active mode persists in settings. /// public class UnlockerService(SteamService steam, SettingsService settings, CacheService cache, GithubProxy gh) { @@ -23,52 +23,45 @@ public class UnlockerService(SteamService steam, SettingsService settings, Cache // fresh fetch (30s cooldown) for anyone who wants certainty sooner. private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); private readonly Dictionary _releaseCache = new(); + private readonly Dictionary _manifestCache = new(); + + /// BetterSteamTools publishes its version + payload hash here instead of via the releases + /// API. See . + private const string BstManifestUrl = + "https://raw.githubusercontent.com/madoiscool/BetterSteamTools/refs/heads/updates/opensteamtool/latest.toml"; public IReadOnlyList Modes { get; } = [ - // SteamTools now publishes each DLL on its OWN dynamically-tagged release (st-) — - // one tag per release, DLLs not released together — so there's no fixed tag. Status/install - // fetch ALL releases (one call, per_page=100) and resolve each DLL from its own latest st* release. - new(UnlockerMode.SteamTools, "SteamTools", - Description: Resources.Strings.Mode_Desc_SteamTools, - Kind: ModeKind.Loose, - Owner: "mendy-tools", Repo: "verynotsusdllsthataredefnotstrelated", - FixedTag: null, - PlaceFiles: ["dwmapi.dll", "xinput1_4.dll"], - ZipAssetPattern: null, - CliAssetName: null, CliArgs: null, VerifyFile: null), - - // DisplayName "BetterSteamTools" is the user-facing brand; the repo/dll/zip identifiers below stay - // the upstream "OpenSteamTool" names (real download targets — renaming them breaks install). - new(UnlockerMode.OpenSteamTools, "BetterSteamTools", - Description: Resources.Strings.Mode_Desc_OpenSteamTools, + // The nightly channel of upstream OpenSteamTool, built from main into our own OST-Nightly repo. + // Carries native CloudRedirect support (see the add-on below). + new(UnlockerMode.Ost, "OpenSteamTools", + Description: Resources.Strings.Mode_Desc_Ost, Kind: ModeKind.Zip, - Owner: "OpenSteam001", Repo: "OpenSteamTool", + Owner: "madoiscool", Repo: "OST-Nightly", FixedTag: null, PlaceFiles: ["dwmapi.dll", "xinput1_4.dll", "OpenSteamTool.dll"], - ZipAssetPattern: "OpenSteamTool-{version}-Release.zip", - CliAssetName: null, CliArgs: null, VerifyFile: null), + ZipAssetPattern: "OpenSteamTool-{version}-Release.zip"), - // Nightly build of OpenSteamTool (our own madoiscool/OST-Nightly, built from upstream main) — - // kept as its own mode because it adds native CloudRedirect support (see the add-on below). - // Same install/config shape as stable BST (same binary, same opensteamtool.toml). - new(UnlockerMode.OpenSteamToolsNightly, "BetterSteamTools Nightly", - Description: Resources.Strings.Mode_Desc_OpenSteamToolsNightly, + // Our fork of OpenSteamTool. The dll/zip identifiers stay the upstream "OpenSteamTool" names. + // They're real download and file targets inherited from the fork, and renaming them breaks + // install. Only the mode's DisplayName is the new brand. + new(UnlockerMode.Bst, "BetterSteamTools", + Description: Resources.Strings.Mode_Desc_Bst, Kind: ModeKind.Zip, - Owner: "madoiscool", Repo: "OST-Nightly", + Owner: "madoiscool", Repo: "BetterSteamTools", FixedTag: null, PlaceFiles: ["dwmapi.dll", "xinput1_4.dll", "OpenSteamTool.dll"], ZipAssetPattern: "OpenSteamTool-{version}-Release.zip", - CliAssetName: null, CliArgs: null, VerifyFile: null), + UpdateManifestUrl: BstManifestUrl), - new(UnlockerMode.CloudRedirect, "CloudRedirect (SteamTools Fix)", - Description: Resources.Strings.Mode_Desc_CloudRedirect, - Kind: ModeKind.Cli, - Owner: "Selectively11", Repo: "CloudRedirect", + // Opt-out: the user installs and updates their own unlocker, and we place/verify nothing. + new(UnlockerMode.Custom, Resources.Strings.Mode_Name_Custom, + Description: Resources.Strings.Mode_Desc_Custom, + Kind: ModeKind.Manual, + Owner: "", Repo: "", FixedTag: null, - PlaceFiles: ["cloud_redirect.dll"], - ZipAssetPattern: null, - CliAssetName: "CloudRedirectCLI.exe", CliArgs: "/stfixer", VerifyFile: "cloud_redirect.dll"), + PlaceFiles: [], + ZipAssetPattern: null), ]; private ModeDefinition Def(UnlockerMode mode) => Modes.First(m => m.Mode == mode); @@ -81,6 +74,27 @@ public class UnlockerService(SteamService steam, SettingsService settings, Cache public string? SelectedModeDisplayName => SelectedMode is { } m ? Def(m).DisplayName : null; + /// + /// Make sure the active OST/BST install is watching config/stplug-in, so luas written there + /// hot-reload instead of needing a Steam restart. + /// + /// + /// The app no longer tells users to restart Steam after a lua change, because OST/BST re-read any + /// directory listed in opensteamtool.toml's [lua] paths. That makes this registration + /// load-bearing rather than a nicety: previously it ran only inside , so a + /// user who set their unlocker up outside this app got neither hot-reload nor restart advice. + /// + /// Safe to call repeatedly — the underlying edit is targeted, comment-preserving and append-only, and + /// no-ops when the path is already present. Skipped for Custom, whose unlocker we know nothing + /// about, and when no mode is selected. + /// + public void EnsureLuaPathRegistered() + { + if (SelectedMode is not (UnlockerMode.Ost or UnlockerMode.Bst)) return; + if (steam.EffectivePath is not { } root) return; + try { EnsureOpenSteamToolLuaPath(root); } catch { /* config tweak is best-effort */ } + } + // ── State query ───────────────────────────────────────────────── /// Query GitHub + local files → this mode's status. Returns Unknown on any failure/offline. @@ -90,81 +104,73 @@ public async Task GetStateAsync(UnlockerMode mode, bool forceRefresh var def = Def(mode); bool active = SelectedMode == mode; + // Custom: the user owns their files. Nothing to fetch, nothing to compare. + if (def.Kind == ModeKind.Manual) + return new ModeState(mode, ModeStatus.UserManaged, active, null); + string? root = steam.EffectivePath; if (root is null || !steam.IsValid) return new ModeState(mode, ModeStatus.Unknown, active, null); - // OpenSteamTools status uses our mendy-tools "ost-" mirror (real per-DLL hashes), since the - // upstream OST release only publishes a zip digest, not per-file hashes. - if (mode == UnlockerMode.OpenSteamTools) + // BST publishes version + payload hash in a raw-hosted manifest, no releases API call. + if (def.UpdateManifestUrl is not null) { - var (ostStatus, latestTag) = await OstMirrorStatusAsync(root, ct); - return new ModeState(mode, ostStatus, active, latestTag); + var manifest = await FetchUpdateManifestAsync(def, forceRefresh, ct); + if (manifest is null) return new ModeState(mode, ModeStatus.Unknown, active, null); + return new ModeState(mode, ManifestStatus(manifest, root), active, manifest.Version); } - // SteamTools: each DLL has its own per-timestamp "st-…" release, so resolve the latest of each - // across ALL releases (not a single fixed tag). - if (mode == UnlockerMode.SteamTools) + // OST: recognise BOTH channels. An exact match against the nightly release means up to date; + // an exact match against the stable "ost-" mirror means the user is on stable OST, which is + // still OST, but this mode ships nightly, so offer them the move. + if (mode == UnlockerMode.Ost) { - var releases = await FetchAllReleasesAsync(def.Owner, def.Repo, null, ct); - if (releases is null) return new ModeState(mode, ModeStatus.Unknown, active, null); - var (stStatus, latestTag) = SteamToolsStatus(def, releases, root); - return new ModeState(mode, stStatus, active, latestTag); + var (ostStatus, latestTag) = await OstStatusAsync(def, root, forceRefresh, ct); + return new ModeState(mode, ostStatus, active, latestTag); } - // CloudRedirect (CLI) verifies its placed file by digest off a single release. - GithubRelease? release = await FetchReleaseAsync(def, forceRefresh, ct); - if (release is null) - return new ModeState(mode, ModeStatus.Unknown, active, null); - return new ModeState(mode, LooseModeStatus(def, release, root), active, release.TagName); + return new ModeState(mode, ModeStatus.Unknown, active, null); } - private static ModeStatus LooseModeStatus(ModeDefinition def, GithubRelease release, string root) + /// + /// Status for a manifest-backed mode: the manifest names one payload file and its hash (for BST, + /// OpenSteamTool.dll: the real change indicator; dwmapi/xinput are loaders that rarely move, so + /// they're placed but not compared). + /// + private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) { - bool anyPresent = false, anyMissingOrStale = false; - foreach (string file in def.PlaceFiles) - { - string local = Path.Combine(root, file); - var asset = release.Assets.FirstOrDefault(a => a.Name.Equals(file, StringComparison.OrdinalIgnoreCase)); - string? wanted = ParseDigest(asset?.Digest); - - if (!File.Exists(local)) { anyMissingOrStale = true; continue; } - anyPresent = true; - if (wanted is null || !Sha256OfFile(local).Equals(wanted, StringComparison.OrdinalIgnoreCase)) - anyMissingOrStale = true; - } - - if (!anyPresent) return ModeStatus.NotInstalled; - return anyMissingOrStale ? ModeStatus.UpdateAvailable : ModeStatus.UpToDate; + string local = Path.Combine(root, manifest.File); + if (!File.Exists(local)) return ModeStatus.NotInstalled; + return AssetHash.OfFile(local).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase) + ? ModeStatus.UpToDate + : ModeStatus.UpdateAvailable; } /// - /// SteamTools status across per-timestamp releases: for each required DLL, compare the on-disk file - /// to the digest of the NEWEST st* release that carries it. Up to date only if every present DLL - /// matches its own latest. Returns the newest st* tag overall for display. + /// OST status across both channels. Nightly builds differ per build in OpenSteamTool.dll, so that's + /// the nightly indicator; stable OST is detected via the mendy-tools "ost-" mirror's per-DLL hashes + /// (upstream only publishes a zip digest, not per-file ones). /// - private static (ModeStatus status, string? latestTag) SteamToolsStatus( - ModeDefinition def, IReadOnlyList releases, string root) + private async Task<(ModeStatus status, string? latestTag)> OstStatusAsync( + ModeDefinition def, string root, bool forceRefresh, CancellationToken ct) { - bool anyPresent = false, anyMissingOrStale = false; - foreach (string file in def.PlaceFiles) - { - string local = Path.Combine(root, file); - string? wanted = ParseDigest(LatestAssetFor(releases, file)?.Digest); + var nightly = await FetchReleaseAsync(def, forceRefresh, ct); + string ostDll = Path.Combine(root, "OpenSteamTool.dll"); - if (!File.Exists(local)) { anyMissingOrStale = true; continue; } - anyPresent = true; - if (wanted is null || !Sha256OfFile(local).Equals(wanted, StringComparison.OrdinalIgnoreCase)) - anyMissingOrStale = true; - } + if (nightly is not null && File.Exists(ostDll) + && AssetDigest(nightly, "OpenSteamTool.dll") == AssetHash.OfFile(ostDll)) + return (ModeStatus.UpToDate, nightly.TagName); - string? latestTag = releases - .Where(r => r.TagName.StartsWith("st", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(r => r.PublishedAt ?? DateTimeOffset.MinValue) - .FirstOrDefault()?.TagName; + // Not the current nightly. Fall back to the stable mirror to tell "on stable OST" apart from + // "nothing installed": both end up as UpdateAvailable, but only the former is really OST. + var (mirrorStatus, mirrorTag) = await OstMirrorStatusAsync(root, ct); + string? tag = nightly?.TagName ?? mirrorTag; - if (!anyPresent) return (ModeStatus.NotInstalled, latestTag); - return (anyMissingOrStale ? ModeStatus.UpdateAvailable : ModeStatus.UpToDate, latestTag); + if (mirrorStatus == ModeStatus.NotInstalled && !File.Exists(ostDll)) + return (ModeStatus.NotInstalled, tag); + if (nightly is null && mirrorStatus == ModeStatus.Unknown) + return (ModeStatus.Unknown, tag); + return (ModeStatus.UpdateAvailable, tag); } private const string MirrorRepoOwner = "mendy-tools"; @@ -189,7 +195,7 @@ private static (ModeStatus status, string? latestTag) SteamToolsStatus( 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. @@ -204,39 +210,40 @@ public async Task InstallAsync( UnlockerMode mode, IProgress? progress = null, CancellationToken ct = default) { var def = Def(mode); - string? root = steam.EffectivePath; - if (root is null || !steam.IsValid) - return ModeInstallResult.Fail("Steam location not found — set it in Settings."); - // SteamTools: each DLL has its own per-timestamp "st-…" release → resolve each from its own - // latest. Other modes use a single release. - string? steamToolsTag = null; - Func? resolveSteamToolsAsset = null; - if (mode == UnlockerMode.SteamTools) + // Custom: selecting it is the whole operation. Nothing is downloaded, nothing is written to + // the Steam root, and whatever the user has installed is left exactly as it is. + if (def.Kind == ModeKind.Manual) { - var releases = await FetchAllReleasesAsync(def.Owner, def.Repo, null, ct); - if (releases is null) - return ModeInstallResult.Fail("Couldn't reach GitHub — check your connection and try again."); - resolveSteamToolsAsset = file => LatestAssetFor(releases, file); - steamToolsTag = releases - .Where(r => r.TagName.StartsWith("st", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(r => r.PublishedAt ?? DateTimeOffset.MinValue) - .FirstOrDefault()?.TagName; + settings.SelectedMode = mode.ToString(); + return ModeInstallResult.Ok(); } + string? root = steam.EffectivePath; + if (root is null || !steam.IsValid) + return ModeInstallResult.Fail(Resources.Strings.Err_SteamNotFound); + + // Resolve the build to install: manifest-backed modes (BST) name their own version and payload + // hash; the rest use the same (cached) release the card's status was based on, so what installs + // matches what was shown. GithubRelease? release = null; - if (mode != UnlockerMode.SteamTools) + UpdateManifest? manifest = null; + string? version; + if (def.UpdateManifestUrl is not null) + { + manifest = await FetchUpdateManifestAsync(def, forceRefresh: false, ct); + if (manifest is null) + return ModeInstallResult.Fail(Resources.Strings.Err_UpdateServerUnreachable); + version = manifest.Version; + } + else { - // Use the same (cached) release the card's status was based on, so install matches what was shown. release = await FetchReleaseAsync(def, forceRefresh: false, ct); if (release is null) - return ModeInstallResult.Fail("Couldn't reach GitHub — check your connection and try again."); + return ModeInstallResult.Fail(Resources.Strings.Err_GithubUnreachable); + version = release.TagName; } - // CLI modes (CloudRedirect) have a completely different flow: download a tool, run it, verify. - if (def.Kind == ModeKind.Cli) - return await InstallViaCliAsync(def, release!, root, progress, ct); - string staging = Path.Combine(Path.GetTempPath(), "LuaToolsGui", "mode", Guid.NewGuid().ToString("N")); try { @@ -245,41 +252,42 @@ public async Task InstallAsync( // 1. Stage + verify into temp. Dictionary staged; // filename → staged path string? zipDigest = null; - if (def.Kind == ModeKind.Zip) { - var asset = FindZipAsset(def, release!); - if (asset is null) return ModeInstallResult.Fail("Release is missing the expected download."); + // Manifest modes build the asset URL from the reported version; release modes read it + // off the release. Either way we land on the same "--Release.zip" shape. + string zipUrl, zipName; + string? wantedZipDigest = null; + if (manifest is not null) + { + zipName = (def.ZipAssetPattern ?? "").Replace("{version}", manifest.Version); + zipUrl = $"https://github.com/{def.Owner}/{def.Repo}/releases/download/{manifest.Version}/{zipName}"; + } + else + { + var asset = FindZipAsset(def, release!); + if (asset is null) return ModeInstallResult.Fail(Resources.Strings.Err_ReleaseMissingDownload); + zipName = asset.Name; + zipUrl = asset.DownloadUrl; + wantedZipDigest = AssetHash.ParseDigest(asset.Digest); + } - string zipPath = Path.Combine(staging, asset.Name); - await DownloadToFileAsync(asset.DownloadUrl, zipPath, progress, ct); + string zipPath = Path.Combine(staging, zipName); + await DownloadToFileAsync(zipUrl, zipPath, progress, ct); - zipDigest = Sha256OfFile(zipPath); - if (ParseDigest(asset.Digest) is { } want && !zipDigest.Equals(want, StringComparison.OrdinalIgnoreCase)) - return ModeInstallResult.Fail("Download failed verification (sha256 mismatch)."); + zipDigest = AssetHash.OfFile(zipPath); + if (wantedZipDigest is { } want && !zipDigest.Equals(want, StringComparison.OrdinalIgnoreCase)) + return ModeInstallResult.Fail(Resources.Strings.Err_VerifyFailed); staged = ExtractWanted(zipPath, def.PlaceFiles, staging); var missing = def.PlaceFiles.Where(f => !staged.ContainsKey(f)).ToList(); if (missing.Count > 0) - return ModeInstallResult.Fail($"Download is missing: {string.Join(", ", missing)}."); - } - else - { - staged = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string file in def.PlaceFiles) - { - // SteamTools resolves each DLL from its own latest st* release; others from the one release. - var asset = resolveSteamToolsAsset is not null - ? resolveSteamToolsAsset(file) - : release!.Assets.FirstOrDefault(a => a.Name.Equals(file, StringComparison.OrdinalIgnoreCase)); - if (asset is null) return ModeInstallResult.Fail($"Couldn't find {file} in any release."); - - string dest = Path.Combine(staging, file); - await DownloadToFileAsync(asset.DownloadUrl, dest, progress, ct); - - if (ParseDigest(asset.Digest) is { } want && !Sha256OfFile(dest).Equals(want, StringComparison.OrdinalIgnoreCase)) - return ModeInstallResult.Fail($"{file} failed verification (sha256 mismatch)."); - staged[file] = dest; - } + return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_DownloadMissingFiles, string.Join(", ", missing))); + + // 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) + && !AssetHash.OfFile(payload).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) + return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, manifest.File)); } // 2. Copy verified files into the Steam root (overwrite). Locked files → Failed (Steam running). @@ -298,26 +306,23 @@ public async Task InstallAsync( } } - // 3. This mode is now the active one. (No cleanup of other modes' files — just overwrite.) + // 3. This mode is now the active one. (No cleanup of other modes' files. Just overwrite.) settings.SelectedMode = mode.ToString(); - // Record the installed OST zip digest/version (kept for reference; the up-to-date check now - // uses the mendy-tools "ost-" mirror's per-DLL hashes). Point its config at stplug-in too. - if (def.Kind == ModeKind.Zip) - { - cache.OpenSteamToolsInstalledZipDigest = zipDigest; - cache.OpenSteamToolsInstalledVersion = release!.TagName; // Zip ⇒ OST ⇒ release fetched above - } - if (mode is UnlockerMode.OpenSteamTools or UnlockerMode.OpenSteamToolsNightly) - try { EnsureOpenSteamToolLuaPath(root); } catch { /* config tweak is best-effort */ } + // Record the installed zip digest/version for reference (the up-to-date check uses per-DLL + // hashes, not this). Both remaining install modes are OpenSteamTool-derived, so both want + // their config pointed at stplug-in. + cache.OpenSteamToolsInstalledZipDigest = zipDigest; + cache.OpenSteamToolsInstalledVersion = version; + try { EnsureOpenSteamToolLuaPath(root); } catch { /* config tweak is best-effort */ } return failed.Count > 0 - ? new ModeInstallResult(false, $"Couldn't write {failed.Count} file(s) — close Steam and try again.", failed) + ? new ModeInstallResult(false, string.Format(Resources.Strings.Err_WriteFailedCount, failed.Count), failed) : ModeInstallResult.Ok(); } catch (OperationCanceledException) { - return ModeInstallResult.Fail("Cancelled."); + return ModeInstallResult.Fail(Resources.Strings.Err_Cancelled); } catch (Exception ex) { @@ -329,149 +334,72 @@ public async Task InstallAsync( } } - /// - /// CLI mode (CloudRedirect): download the latest fixer tool, run it (it closes Steam, patches, and - /// deploys cloud_redirect.dll itself), then confirm the deployed dll matches the latest release hash. - /// - private async Task InstallViaCliAsync( - ModeDefinition def, GithubRelease release, string root, IProgress? progress, CancellationToken ct) - { - var cliAsset = release.Assets.FirstOrDefault(a => a.Name.Equals(def.CliAssetName, StringComparison.OrdinalIgnoreCase)); - if (cliAsset is null) return ModeInstallResult.Fail($"Release is missing {def.CliAssetName}."); - - var verifyAsset = release.Assets.FirstOrDefault(a => a.Name.Equals(def.VerifyFile, StringComparison.OrdinalIgnoreCase)); - string? wantedDigest = ParseDigest(verifyAsset?.Digest); - - string staging = Path.Combine(Path.GetTempPath(), "LuaToolsGui", "mode", Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(staging); - - // 1. Download + verify the CLI tool. - string cliPath = Path.Combine(staging, def.CliAssetName!); - await DownloadToFileAsync(cliAsset.DownloadUrl, cliPath, progress, ct); - if (ParseDigest(cliAsset.Digest) is { } cliWant && !Sha256OfFile(cliPath).Equals(cliWant, StringComparison.OrdinalIgnoreCase)) - return ModeInstallResult.Fail($"{def.CliAssetName} failed verification (sha256 mismatch)."); - - // 2. Run it. It closes Steam, patches SteamTools, and deploys the dll on its own. - progress?.Report(null); // indeterminate — no progress signal from the external tool - int exit = await RunProcessAsync(cliPath, def.CliArgs ?? "", ct); - if (exit != 0) - return ModeInstallResult.Fail($"{def.CliAssetName} exited with code {exit}."); - - // 3. Confirm the deployed file is the expected (latest) version. - string deployed = Path.Combine(root, def.VerifyFile!); - if (!File.Exists(deployed)) - return ModeInstallResult.Fail($"{def.VerifyFile} was not deployed — the fix didn't complete."); - if (wantedDigest is not null && !Sha256OfFile(deployed).Equals(wantedDigest, StringComparison.OrdinalIgnoreCase)) - return ModeInstallResult.Fail($"{def.VerifyFile} is not the expected version — the update didn't apply."); - - settings.SelectedMode = def.Mode.ToString(); // CloudRedirect is now the active mode - return ModeInstallResult.Ok(); - } - catch (OperationCanceledException) { return ModeInstallResult.Fail("Cancelled."); } - catch (Exception ex) { return ModeInstallResult.Fail(ex.Message); } - finally - { - try { Directory.Delete(staging, recursive: true); } catch { /* best effort */ } - } - } - - private static async Task RunProcessAsync(string exePath, string args, CancellationToken ct) - { - var psi = new ProcessStartInfo(exePath, args) - { - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory, - }; - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start the fixer."); - await proc.WaitForExitAsync(ct); - return proc.ExitCode; - } - // ── First-run auto-detect ──────────────────────────────────────── /// /// One-time detection of an already-installed mode when none is selected yet. Hashes the on-disk - /// DLLs and matches them against published release asset digests: - /// 1. OpenSteamTools — dwmapi.dll AND xinput1_4.dll vs mendy-tools tag "ost-" (loose-DLL mirror; + /// DLLs against published digests, in priority order: + /// 1. Bst: OpenSteamTool.dll vs the BST update manifest's sha256. + /// 2. Ost (nightly): OpenSteamTool.dll vs any madoiscool/OST-Nightly release asset. + /// 3. Ost (stable): dwmapi.dll AND xinput1_4.dll vs mendy-tools tag "ost-" (loose-DLL mirror; /// OST ships a zip whose API digest isn't per-DLL, so we mirror the DLLs for hash-matching). - /// 2. SteamTools — same two DLLs vs mendy-tools tag "st" (ST/st-*). - /// 3. CloudRedirect — cloud_redirect.dll vs Selectively11/CloudRedirect (only if 1+2 miss). - /// Both DLLs must be present and each must hash-match SOME release with the right tag prefix — the - /// two DLLs ship in SEPARATE releases (they aren't published together), so each is matched - /// independently across all releases, not against one single release. + /// Still OST, just the other channel: GetStateAsync will offer the move to nightly. + /// + /// EVERY branch requires an EXACT hash match. Do not relax this to "the file exists": SteamTools + /// shipped the same dwmapi.dll / xinput1_4.dll filenames, so a presence check would silently claim + /// ex-SteamTools users as OST. Exactly the users ModeMigration deliberately routes to onboarding. + /// Never auto-selects ; that's an explicit user choice. + /// /// Persists the match as the active mode. Returns the detected mode, or null if nothing matched. - /// 1 guaranteed API call (one repo serves both ost-/st), plus 1 conditional (CloudRedirect). /// public async Task DetectActiveModeAsync(CancellationToken ct = default) { string? root = steam.EffectivePath; if (root is null || !steam.IsValid) return null; - string dwmapi = Path.Combine(root, "dwmapi.dll"); - string xinput = Path.Combine(root, "xinput1_4.dll"); - - // Both DLLs must exist and each must hash-match an asset in SOME release whose tag starts with the - // given prefix. The two DLLs are released separately, so we match each one across all releases. - bool BothDllsMatchPrefix(IReadOnlyList releases, string tagPrefix) - { - if (!File.Exists(dwmapi) || !File.Exists(xinput)) return false; - - var tagged = releases - .Where(r => r.TagName.StartsWith(tagPrefix, StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (tagged.Count == 0) return false; - - string dwmHash = Sha256OfFile(dwmapi); - string xinHash = Sha256OfFile(xinput); - bool dwmOk = tagged.Any(r => AssetDigest(r, "dwmapi.dll") == dwmHash); - bool xinOk = tagged.Any(r => AssetDigest(r, "xinput1_4.dll") == xinHash); - return dwmOk && xinOk; - } - UnlockerMode? detected = null; - // Nightly BST first: the loader DLLs (dwmapi/xinput) can be byte-identical to stable BST, so - // match on the payload OpenSteamTool.dll (which differs per nightly build) against our OST-Nightly - // releases. Checked before the stable ost-/st match so a real nightly install wins. + // The loader DLLs (dwmapi/xinput) are often byte-identical across builds, so the payload + // OpenSteamTool.dll is what actually distinguishes BST from OST-nightly. string ostDll = Path.Combine(root, "OpenSteamTool.dll"); if (File.Exists(ostDll)) { - var nightly = await FetchAllReleasesAsync("madoiscool", "OST-Nightly", null, ct); - if (nightly is not null) - { - string ostHash = Sha256OfFile(ostDll); - if (nightly.Any(r => AssetDigest(r, "OpenSteamTool.dll") == ostHash)) - detected = UnlockerMode.OpenSteamToolsNightly; - } - } + string ostHash = AssetHash.OfFile(ostDll); - const string repo = "verynotsusdllsthataredefnotstrelated"; + var bstManifest = await FetchUpdateManifestAsync(Def(UnlockerMode.Bst), forceRefresh: false, ct); + if (bstManifest is not null + && bstManifest.File.Equals("OpenSteamTool.dll", StringComparison.OrdinalIgnoreCase) + && ostHash.Equals(bstManifest.Sha256, StringComparison.OrdinalIgnoreCase)) + detected = UnlockerMode.Bst; - // Single fetch — the same mendy-tools repo serves both the "ost-" mirror and the "st" releases. - if (detected is null) - { - var stRepoReleases = await FetchAllReleasesAsync("mendy-tools", repo, null, ct); - if (stRepoReleases is not null) + if (detected is null) { - if (BothDllsMatchPrefix(stRepoReleases, "ost-")) - detected = UnlockerMode.OpenSteamTools; - else if (BothDllsMatchPrefix(stRepoReleases, "st")) // matches "ST" and "st-*" - detected = UnlockerMode.SteamTools; + var nightly = await FetchAllReleasesAsync("madoiscool", "OST-Nightly", null, ct); + if (nightly is not null && nightly.Any(r => AssetDigest(r, "OpenSteamTool.dll") == ostHash)) + detected = UnlockerMode.Ost; } } + // Stable OST via the loose-DLL mirror. Both DLLs must be present and each must hash-match SOME + // ost- release. The two ship in SEPARATE releases, so they're matched independently. if (detected is null) { - string crDll = Path.Combine(root, "cloud_redirect.dll"); - if (File.Exists(crDll)) + string dwmapi = Path.Combine(root, "dwmapi.dll"); + string xinput = Path.Combine(root, "xinput1_4.dll"); + if (File.Exists(dwmapi) && File.Exists(xinput)) { - var releases = await FetchAllReleasesAsync("Selectively11", "CloudRedirect", null, ct); - string crHash = Sha256OfFile(crDll); - if (releases is not null && releases.Any(r => AssetDigest(r, "cloud_redirect.dll") == crHash)) - detected = UnlockerMode.CloudRedirect; + var mirror = await FetchAllReleasesAsync(MirrorRepoOwner, MirrorRepo, null, ct); + var tagged = mirror? + .Where(r => r.TagName.StartsWith("ost-", StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (tagged is { Count: > 0 }) + { + 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; + } } } @@ -481,24 +409,12 @@ bool BothDllsMatchPrefix(IReadOnlyList releases, string tagPrefix /// 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) => r.Assets.FirstOrDefault(a => a.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase)); - /// - /// SteamTools ships each DLL on its own per-timestamp release (tag "st-…"), so each DLL has its - /// own independent latest. Given the pre-fetched releases, return the asset for - /// from the NEWEST st* release that contains it (by published_at). Null if none carry that file. - /// - private static GithubAsset? LatestAssetFor(IReadOnlyList releases, string file) => - releases - .Where(r => r.TagName.StartsWith("st", StringComparison.OrdinalIgnoreCase)) // "ST" and "st-*" - .OrderByDescending(r => r.PublishedAt ?? DateTimeOffset.MinValue) - .Select(r => FindAsset(r, file)) - .FirstOrDefault(a => a is not null); - /// Fetch every release for a repo (per_page=100). If is set, only /// that one release (wrapped in a list). Null on failure/offline. private async Task?> FetchAllReleasesAsync(string owner, string repo, string? tag, CancellationToken ct) @@ -583,7 +499,7 @@ private static void EnsureOpenSteamToolLuaPath(string steamRoot) // Find where the array closes (']'), scanning from pathsStart (handles multi-line arrays). int pathsEnd = pathsStart; while (pathsEnd < sectionEnd && !lines[pathsEnd].Contains(']')) pathsEnd++; - if (pathsEnd >= sectionEnd) pathsEnd = sectionEnd - 1; // malformed/unclosed — best effort + if (pathsEnd >= sectionEnd) pathsEnd = sectionEnd - 1; // malformed/unclosed. Best effort string block = string.Join("\n", lines.GetRange(pathsStart, pathsEnd - pathsStart + 1)); @@ -670,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; } } @@ -683,7 +599,7 @@ public async Task EnableCloudRedirectAsync(IProgress { string? root = steam.EffectivePath; if (root is null || !steam.IsValid) - return ModeInstallResult.Fail("Steam location not found — set it in Settings."); + return ModeInstallResult.Fail(Resources.Strings.Err_SteamNotFound); if (!File.Exists(Path.Combine(root, CloudRedirectDll))) { @@ -700,7 +616,7 @@ public ModeInstallResult DisableCloudRedirect() { string? root = steam.EffectivePath; if (root is null || !steam.IsValid) - return ModeInstallResult.Fail("Steam location not found — set it in Settings."); + return ModeInstallResult.Fail(Resources.Strings.Err_SteamNotFound); try { SetOpenSteamToolCloudEnabled(root, false); return ModeInstallResult.Ok(); } catch (Exception ex) { return ModeInstallResult.Fail(ex.Message); } } @@ -711,16 +627,16 @@ public async Task UpdateCloudRedirectAsync(IProgress { string? root = steam.EffectivePath; if (root is null || !steam.IsValid) - return ModeInstallResult.Fail("Steam location not found — set it in Settings."); + return ModeInstallResult.Fail(Resources.Strings.Err_SteamNotFound); return await DownloadCloudRedirectDllAsync(root, progress, ct); } private async Task DownloadCloudRedirectDllAsync(string root, IProgress? progress, CancellationToken ct) { var release = await FetchCloudRedirectReleaseAsync(forceRefresh: true, ct); - if (release is null) return ModeInstallResult.Fail("Couldn't reach GitHub — check your connection and try again."); + if (release is null) return ModeInstallResult.Fail(Resources.Strings.Err_GithubUnreachable); var asset = FindAsset(release, CloudRedirectDll); - if (asset is null) return ModeInstallResult.Fail($"Release is missing {CloudRedirectDll}."); + if (asset is null) return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_ReleaseMissingFile, CloudRedirectDll)); string staging = Path.Combine(Path.GetTempPath(), "LuaToolsGui", "cloud", Guid.NewGuid().ToString("N")); try @@ -728,8 +644,8 @@ 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)) - return ModeInstallResult.Fail($"{CloudRedirectDll} failed verification (sha256 mismatch)."); + 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 { @@ -739,12 +655,12 @@ private async Task DownloadCloudRedirectDllAsync(string root, } catch { - // Steam has the loaded dll locked — surface a close-Steam message (same as mode install). - return ModeInstallResult.Fail("Couldn't write cloud_redirect.dll — close Steam and try again."); + // Steam has the loaded dll locked: surface a close-Steam message (same as mode install). + return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_WriteFailedFile, CloudRedirectDll)); } return ModeInstallResult.Ok(); } - catch (OperationCanceledException) { return ModeInstallResult.Fail("Cancelled."); } + catch (OperationCanceledException) { return ModeInstallResult.Fail(Resources.Strings.Err_Cancelled); } catch (Exception ex) { return ModeInstallResult.Fail(ex.Message); } finally { try { Directory.Delete(staging, recursive: true); } catch { /* best effort */ } } } @@ -845,6 +761,70 @@ private static bool ReadOpenSteamToolCloudEnabled(string steamRoot) } } + /// + /// Fetch a mode's latest.toml update manifest. Version + payload filename + sha256. + /// + /// This is deliberately NOT an api.github.com call: it's a raw-hosted file, so a manifest-backed + /// mode never spends any of the 60 req/hr unauthenticated GitHub API budget. It also gives a real + /// per-file hash, which is the problem the "ost-" mirror repo exists to work around for the + /// release-API modes. Routed through GithubProxy all the same. IsGithub covers + /// raw.githubusercontent.com, so blocked regions still fall through to the mirrors. + /// + /// Shares the release cache's TTL, keyed by mode. + /// + private async Task FetchUpdateManifestAsync(ModeDefinition def, bool forceRefresh, CancellationToken ct) + { + if (def.UpdateManifestUrl is null) return null; + if (!forceRefresh + && _manifestCache.TryGetValue(def.Mode, out var cached) + && DateTime.UtcNow - cached.fetchedAt < CacheTtl) + return cached.manifest; + + try + { + using var res = await gh.SendAsync(def.UpdateManifestUrl, ct); + if (res is null || !res.IsSuccessStatusCode) return null; + var manifest = ParseUpdateManifest(await res.Content.ReadAsStringAsync(ct)); + if (manifest is not null) _manifestCache[def.Mode] = (manifest, DateTime.UtcNow); + return manifest; + } + catch + { + return null; // offline / parse error → caller maps to Unknown + } + } + + /// + /// Read the three keys we care about out of a flat key = "value" TOML. Hand-rolled on + /// purpose. The file has no tables, arrays or nesting, so a TOML package would be a dependency + /// bought for three lines of parsing. + /// + private static UpdateManifest? ParseUpdateManifest(string toml) + { + string? version = null, path = null, sha = null; + foreach (string raw in toml.Split('\n')) + { + string line = raw.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + int eq = line.IndexOf('='); + if (eq < 0) continue; + + string key = line[..eq].Trim(); + string value = line[(eq + 1)..].Trim().Trim('"'); + switch (key) + { + case "version": version = value; break; + case "path": path = value; break; + case "sha256": sha = value; break; + } + } + + if (version is null or "" || path is null or "" || sha is null or "") return null; + // `path` is repo-relative ("opensteamtool/v1.0.0/OpenSteamTool.dll"); only the filename matters + // to us, since that's what gets compared in the Steam root. + return new UpdateManifest(version, Path.GetFileName(path), sha.ToLowerInvariant()); + } + /// Find the small Release zip (matches the pattern, excludes any Debug build). private static GithubAsset? FindZipAsset(ModeDefinition def, GithubRelease release) { @@ -879,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 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..b55f7ac 100644 --- a/src/LuaToolsGui/ViewModels/BuildsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs @@ -1,9 +1,12 @@ -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; using LuaToolsGui.Services; +using LuaToolsGui.Services.Downloads; +using Microsoft.Win32; namespace LuaToolsGui.ViewModels; @@ -22,11 +25,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; @@ -36,6 +39,30 @@ public record DepotRow( /// public long ToggleId { get; init; } + /// Depot size in bytes (0 for DLC entitlements with no depot of their own). Kept as a + /// number, not just baked into , so a download selection can be totalled. + public long Size { get; init; } + + /// Raw oslist from Steam ("windows", "macos", "linux"), or null when undeclared. Kept + /// 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, @@ -72,8 +99,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 +110,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 +121,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 +158,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 @@ -160,10 +187,18 @@ public partial class BuildsViewModel : PagedListViewModel /// Guards against a slow depot fetch for a game the user has since navigated away from. private long _depotLoadToken; + private readonly DepotDownloaderService _depotTool; + private readonly DownloadQueue _queue; + private readonly ManifestJobFactory _jobs; + public BuildsViewModel(SteamService steam, LuaVault vault, SteamAppListCache appList, SteamAppInfoCache appInfo, CoverCache covers, SteamDepotInfo depotInfo, ToastService toast, - SettingsService settings) + SettingsService settings, DepotDownloaderService depotTool, DownloadQueue queue, + ManifestJobFactory jobs) { + _depotTool = depotTool; + _queue = queue; + _jobs = jobs; _steam = steam; _vault = vault; _appList = appList; @@ -185,8 +220,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 +229,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 +239,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 +258,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; } @@ -276,14 +311,21 @@ 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 + // A game is listed while there is something here to act on: + // 1. installed: a live .lua Steam reads + // 2. loose: _.lua sitting in stplug-in, inert until applied + // + // Deliberately NOT a third "every app with vault variants" source. Every install captures + // a Default variant (LuaInstaller.CaptureInstalled -> SyncDefaultFromLive), so every game + // has a vault entry — and deleting its lua from Manage doesn't touch the vault. Including + // vaulted apps therefore kept deleted games on this page forever, offering a Default that + // mirrored a file Steam no longer had. + // + // Nothing is deleted to achieve this: the variants stay on disk untouched and the game + // reappears with them intact once its lua is added back. Hidden, not discarded. 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); - foreach (long appId in _vault.AppsWithVariants()) appIds.Add(appId); return appIds .Select(appId => @@ -317,7 +359,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 +369,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 +379,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 +422,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 +435,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 +451,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 +460,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 +471,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 +479,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 +493,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 +515,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 +565,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 +580,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; } @@ -558,7 +600,6 @@ private void Apply() RefreshVariants(); _toast.Show(Resources.Strings.Builds_Title, string.Format(Resources.Strings.Builds_Apply_Done, variant.DisplayLabel)); - OfferRestartSteam(); } [RelayCommand] @@ -588,7 +629,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 +648,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 +665,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] @@ -641,12 +682,11 @@ private void SaveEdit() IsEditing = false; SaveInPlace(); - OfferRestartSteam(); } /// /// 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 +698,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 +713,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 +728,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; @@ -715,18 +755,391 @@ private void SaveAsPreset() _toast.Show(Resources.Strings.Builds_Title, Resources.Strings.Builds_Preset_Saved); } + // ── Depot download (select mode) ───────────────────────────────── + + /// + /// One tickable depot in the download picker. A separate type from on purpose: + /// DepotRow is an immutable record shared by the browse table, and selection is transient state that + /// belongs only to this mode. + /// + public partial class DepotPickRow : ObservableObject + { + public required long DepotId { get; init; } + public required string Title { get; init; } + public required string Meta { get; init; } + public required long Size { get; init; } + public string? ManifestId { get; init; } + + /// Path to the depot's manifest in Steam's depotcache, or null when Steam doesn't have + /// it yet — which is no longer a blocker: the run loop fetches it from the API. + public string? ManifestPath { get; init; } + + /// True when the manifest isn't on disk and will have to be fetched during the run. + public bool NeedsFetch => ManifestPath is null; + + /// Set when this depot can't be downloaded, saying why. Null means it can. + public string? BlockReason { get; init; } + + public bool CanDownload => BlockReason is null; + + 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 + /// pulled down. A depot with no declared OS is shared content and counts as ours. + /// + public bool IsOtherPlatform => + Os is { Length: > 0 } && !Os.Contains("windows", StringComparison.OrdinalIgnoreCase); + + /// + /// A shared redistributable (VC++/DirectX runtimes) owned by another app. Selectable, but not + /// ticked by default: it's usually already installed system-wide, and its size is unknown until + /// download time, so select-all would otherwise add an unknowable amount. + /// + 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; + } + + + /// True while the depot table is in "pick what to download" mode. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBrowseMode))] + private bool _isSelectMode; + + /// Inverse of , for collapsing the normal table. + public bool IsBrowseMode => !IsSelectMode; + + [ObservableProperty] private IReadOnlyList _depotPicks = []; + + /// "Download 4 depots (8.2 GB)" — recomputed as boxes are ticked. + public string DownloadConfirmLabel => string.Format( + Resources.Strings.Builds_Select_Confirm, + DepotPicks.Count(p => p.IsSelected), + Services.Downloads.ByteFormat.Size(DepotPicks.Where(p => p.IsSelected).Sum(p => p.Size))); + + public bool HasDepotSelection => DepotPicks.Any(p => p.IsSelected); + + /// Where the selected depots will be written. Chosen per download, before committing, so + /// the space warning below can report against the drive that will actually receive the files. + [ObservableProperty] private string _depotOutDir = ""; + + /// + /// Free bytes on 's volume. Cached rather than read from a computed + /// property: AvailableFreeSpace is a syscall and the label re-evaluates on every checkbox tick, + /// which cannot change free space. Refreshed when the folder changes or select mode opens. + /// + [ObservableProperty] private long? _freeBytes; + + partial void OnDepotOutDirChanged(string value) + { + FreeBytes = DepotDownloaderService.FreeSpaceFor(value); + RaiseSpaceProps(); + } + + /// Total bytes the ticked (and downloadable) depots will need. + public long RequiredBytes => + DepotPicks.Where(p => p is { IsSelected: true, CanDownload: true }).Sum(p => p.Size); + + /// False only when we KNOW the drive is short. An unreadable drive is not a warning. + public bool HasEnoughSpace => FreeBytes is not { } free || free >= RequiredBytes; + + /// "Needs 110 GB · 4.3 GB free on C:\" — turns red via the view when short. + public string SpaceLabel => FreeBytes is not { } free + ? "" + : string.Format(Resources.Strings.Builds_Select_Space, + Services.Downloads.ByteFormat.Size(RequiredBytes), + Services.Downloads.ByteFormat.Size(free), + DepotDownloaderService.DriveOf(DepotOutDir)); + + private void RaiseSpaceProps() + { + OnPropertyChanged(nameof(RequiredBytes)); + OnPropertyChanged(nameof(HasEnoughSpace)); + OnPropertyChanged(nameof(SpaceLabel)); + } + + /// Pick a different destination without leaving select mode or losing the ticks. + [RelayCommand] + private void ChangeDepotFolder() + { + var dialog = new OpenFolderDialog + { + Title = Resources.Strings.Builds_Select_ChooseFolder, + InitialDirectory = DepotOutDir, + }; + if (dialog.ShowDialog() == true) DepotOutDir = dialog.FolderName; + } + + private void OnPickChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(DepotPickRow.IsSelected)) return; + OnPropertyChanged(nameof(DownloadConfirmLabel)); + OnPropertyChanged(nameof(HasDepotSelection)); + RaiseSpaceProps(); + } + + /// + /// Enter select mode. Every content depot the lua declares becomes a row, ticked by default; a depot + /// whose manifest isn't in Steam's depotcache is listed but unticked and disabled, because the + /// downloader cannot run without one. + /// + [RelayCommand] + private void StartDepotDownload() + { + // The header button is already gated on HasSelection; this is the belt-and-braces read, and it + // gives us the appid the default destination is built from. + if (ActiveGame is not { } game) return; + + 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 || r.Size > 0) + .Select(r => + { + // An ACTIVE pin means the user deliberately locked this build, so it wins outright and + // must never be silently upgraded. Otherwise take the build Steam ships today: a + // commented-out pin means "Auto Update Apps" is on, i.e. the user wants to track latest, + // so downloading the build the lua originally shipped with would be the wrong version. + // The commented pin is a last resort only, for depots with no public manifest at all + // (beta-branch-only content), where it's the sole version we know of. + // + // Safe because depot decryption keys are per-DEPOT and stable across manifest versions: + // the key already in the lua decrypts the current manifest just as well as the old one. + 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; + + var pick = new DepotPickRow + { + DepotId = r.Id, + Title = r.Title, + Meta = r.Meta, + 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 + && 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( + DownloadsFolder(), "LuaTools Depots", game.AppId.ToString()); + try { Directory.CreateDirectory(defaultRoot); } catch { /* the Change picker still opens */ } + DepotOutDir = defaultRoot; + + IsSelectMode = true; + OnPropertyChanged(nameof(DownloadConfirmLabel)); + OnPropertyChanged(nameof(HasDepotSelection)); + RaiseSpaceProps(); + } + + [RelayCommand] + private void CancelDepotDownload() + { + foreach (var p in DepotPicks) p.PropertyChanged -= OnPickChanged; + DepotPicks = []; + 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() + { + if (ActiveGame is not { } game) return; + + var selections = DepotPicks + .Where(p => p is { IsSelected: true, CanDownload: true }) + .Select(p => new DepotSelection(p.DepotId, p.ManifestId, p.ManifestPath, p.Size) + { + FromAppId = p.FromAppId, + }) + .ToList(); + if (selections.Count == 0) return; + + // Destination was chosen in select mode (so the space warning could report against it). Used + // verbatim and captured by the job closure, so Pause/Resume and Retry reuse the same folder. + string outDir = DepotOutDir; + + string name = string.IsNullOrWhiteSpace(game.Name) ? game.AppId.ToString() : game.Name; + _queue.Enqueue(_jobs.CreateDepotJob(game.AppId, name, selections, outDir)); + CancelDepotDownload(); + RequestShowDownloads?.Invoke(); + } + + /// Set by App: navigate to the Downloads page once a depot job is queued. + public Action? RequestShowDownloads { get; set; } + // ── Depot / DLC breakdown (moved here from the Manage flyout) ─── [ObservableProperty] private bool _isLoadingDepots; [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 +1157,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 +1210,26 @@ 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. + /// + /// 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) { 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,33 +1243,33 @@ 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 /// copy, so flipping a switch rewrites the live lua and replaces the stored Default, discarding what /// was there. Keeping a state before experimenting is what "Save as preset" is for. - /// No OfferRestartSteam. and both - /// prompt; this must not, because a modal dialog on every switch flip would be unusable. The live lua - /// changes right away but Steam won't read it until it restarts. + /// No restart prompt anywhere on this page. OST/BST watch config/stplug-in, so + /// rewriting the live lua applies it immediately. and used + /// to prompt; none of them do now, and a modal on every switch flip would have been unusable anyway. /// /// private void EditLive(DepotRow row, Func edit) @@ -860,10 +1285,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 +1327,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 +1350,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 +1358,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 +1381,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,8 +1428,90 @@ 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. /// + /// + /// 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(); @@ -1030,7 +1537,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) { @@ -1048,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); @@ -1071,7 +1580,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; @@ -1082,11 +1591,14 @@ 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 }; + { + 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 - // 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. @@ -1118,17 +1630,6 @@ private static string FormatSize(long bytes) return $"{mb:0.#} MB"; } - private void OfferRestartSteam() - { - var r = MessageBox.Show( - Resources.Strings.Manage_RestartSteam_Ask, - Resources.Strings.Manage_RestartSteam_Title, - MessageBoxButton.YesNo, MessageBoxImage.Question); - if (r == MessageBoxResult.Yes && !_steam.RestartSteam()) - MessageBox.Show(Resources.Strings.Manage_RestartSteam_Failed, - Resources.Strings.Manage_RestartSteam_Title, MessageBoxButton.OK, MessageBoxImage.Warning); - } - private static void OnUi(Action action) { var dispatcher = Application.Current?.Dispatcher; diff --git a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs index c077d11..88199f2 100644 --- a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.IO.Compression; @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.Input; using LuaToolsGui.Models; using LuaToolsGui.Services; +using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.ViewModels; @@ -23,36 +24,45 @@ 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; [ObservableProperty] private string? _statsText; [ObservableProperty] private bool _isSupporter; - [ObservableProperty] private bool _isDownloading; - [ObservableProperty] private double _progress; - [ObservableProperty] private bool _isProgressIndeterminate; - public bool CanDownload => IsAvailable && !IsLocked; + /// The queue item for this row's in-flight download, if any. The row's progress bar binds + /// straight through to it, so the queue stays the only owner of download state. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDownload))] + private DownloadItem? _queueItem; - public SourceRowViewModel(DownloadViewModel parent, string name, string status) + // Guard against re-queuing from a double-click while the item is still live. The queue's DedupeKey + // would collapse it anyway; this just keeps the button from looking clickable. + public bool CanDownload => IsAvailable && !IsLocked && QueueItem?.IsActive != true; + + /// + /// 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; } [RelayCommand] - private Task DownloadAsync() => _parent.DownloadFromSourceAsync(this); + private async Task DownloadAsync() => await _parent.DownloadFromSourceAsync(this); [RelayCommand] private void OpenDiscord() @@ -81,6 +91,10 @@ public partial class DownloadViewModel : ObservableObject private readonly SteamAppInfoCache _appInfo; private readonly SteamDepotInfo _depotInfo; 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; @@ -105,7 +119,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) @@ -149,8 +163,8 @@ public partial class DownloadViewModel : ObservableObject // haveCount > 0 → keys exist; missingCount == 0 → addappid alone suffices (mirrors the website rule) public bool CanGenerateDlc => DlcInfo is not null && (DlcInfo.HaveCount > 0 || DlcInfo.MissingCount == 0); - [ObservableProperty] private bool _isGenerating; - [ObservableProperty] private double _generateProgress; + /// True while the DLC job is queued or running (drives the button's spinner/disabled state). + public bool IsGenerating => DlcQueueItem?.IsActive == true; [ObservableProperty] private string? _error; [ObservableProperty] @@ -175,6 +189,9 @@ public partial class DownloadViewModel : ObservableObject public async Task ProtocolInstall(long appId, Action? onComplete = null) { _silentInstall = onComplete is not null; + // Cleared so the completion await below can't latch onto a PREVIOUS protocol install's item and + // report its stale outcome when this one enqueues nothing (no sources, or the fetch failed). + _lastEnqueued = null; FastFetch = true; _suppressSearch = true; SearchText = appId.ToString(); @@ -197,11 +214,14 @@ public async Task ProtocolInstall(long appId, Action? onComplete = if (HasDetails) 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 - // InstallStatus on success/handled-failure, else whatever Error the chain left behind. + // The download is no longer inline: FetchAsync only ENQUEUES it. Wait for the queue item to + // reach a terminal state before reporting, otherwise the caller's tray balloon fires early and + // (on a cold silent launch) App's post-balloon shutdown timer can kill the app mid-download. if (onComplete is not null) { + var item = _lastEnqueued; + if (item is not null) await item.Completion; + if (InstallStatus is not null) onComplete(InstallStatus, InstallFailed); else @@ -210,12 +230,16 @@ public async Task ProtocolInstall(long appId, Action? onComplete = } } + // The most recent item this view model queued. ProtocolInstall awaits it so the silent-install + // callback reflects the real outcome rather than "the download started". + private DownloadItem? _lastEnqueued; + // ── Steam-plugin headless add (reflected over HTTP; no window) ──── /// Headless add driven by the Steam store plugin. Seeds the appid and runs the SAME /// 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 +263,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; @@ -279,8 +303,18 @@ public Task DownloadSourceByNameAsync(string name) public bool HasDiffAdded => DiffAdded.Count > 0; public bool HasDiffRemoved => DiffRemoved.Count > 0; - // Set while a confirm is pending so the confirm/cancel commands know what to install. - private (string zipPath, long appId)? _pendingInstall; + // Set while an overwrite overlay is open; the confirm/cancel commands complete it, which unblocks + // the queue job waiting in ConfirmOverwriteAsync. + private TaskCompletionSource? _pendingConfirm; + + // Only one overlay can be shown at a time (there is a single set of overlay properties), so + // simultaneous confirmations serialize here rather than overwriting each other's diff. + private readonly SemaphoreSlim _confirmGate = new(1, 1); + + /// The in-flight DLC generate, so the DLC button can disable itself while it runs. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsGenerating))] + private DownloadItem? _dlcQueueItem; private bool _suppressSearch; private string? _fastFetchSource; @@ -288,15 +322,19 @@ 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; public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsService settings, AuthService auth, ToastService toast, LuaInstaller installer, SteamAppListCache appList, SteamAppInfoCache appInfo, SteamDepotInfo depotInfo, - HardwareAppIdService hardware, DropInstallViewModel drop) + HardwareAppIdService hardware, DropInstallViewModel drop, + DownloadQueue queue, ManifestJobFactory jobs, + Services.Sources.SourcePackRegistry packs, Services.Sources.PackSourceService packSources) { + _packs = packs; + _packSources = packSources; _api = api; _hubcap = hubcap; _settings = settings; @@ -307,13 +345,15 @@ public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsSe _appInfo = appInfo; _depotInfo = depotInfo; _hardware = hardware; + _queue = queue; + _jobs = jobs; Drop = drop; _fastFetch = settings.FastFetch; } /// /// 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 +428,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 +536,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 +544,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()); @@ -512,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) @@ -620,220 +662,243 @@ public async Task RefreshStandardUsageAsync() // ── Downloads ─────────────────────────────────────────────────── - /// Base-game manifest zip: download, then install (confirming first if a lua already exists). - public async Task DownloadFromSourceAsync(SourceRowViewModel source) + /// + /// Base-game manifest zip. Builds a job and hands it to the shared queue; the download, the + /// overwrite confirmation and the install all happen there. + /// + /// + /// Returns as soon as the item is queued, so several games can be added back to back. The old + /// "one at a time" behaviour came from a Sources.Any(s => s.IsDownloading) gate; duplicate + /// suppression is now the queue's DedupeKey, and the queue's concurrency cap (default 1) decides + /// how many actually run at once. + /// + public async Task DownloadFromSourceAsync(SourceRowViewModel source) { - if (Details is null || Sources.Any(s => s.IsDownloading)) return; + if (Details is null) return null; // 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; + if (!hubcapWithKey && packSource is null + && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; + Error = null; LastDownload = null; InstallStatus = null; + + // Captured now, not read from Details later: once the download is backgrounded the user can load + // a different game before the confirmation appears, and the dialog must still name THIS one. long appId = Details.AppId; - source.IsDownloading = true; - source.IsProgressIndeterminate = true; - try - { - var progress = new Progress(p => - { - source.IsProgressIndeterminate = p is null; - if (p is not null) source.Progress = p.Value * 100; - }); - // Key-gated (Hubcap) sources download DIRECTLY from hubcapmanifest.com with the user's key; - // everything else goes through lua.tools and counts toward the 25/day limit. - DownloadedFile download; - if (source.NeedsKey) - { - download = await _hubcap.DownloadManifestAsync( - appId.ToString(), _settings.HubcapApiKey ?? "", progress); - } - else - { - download = await _api.DownloadManifestAsync( - appId.ToString(), source.Name, Details.Name, progress); - } - LastDownload = download; + string gameName = Details.Name; + bool needsKey = source.NeedsKey; + + // 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; + _lastEnqueued = queued; + return queued; + } - 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) + /// + /// 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()); - // 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)) - return; // waiting on the user; confirm/cancel command finishes the install + var sources = _packs.Sources; + if (sources.Count == 0) return; - InstallZipAndReport(download.FilePath, appId); - } - catch (ApiException ex) - { - Error = ex.Message; - } - catch (Exception) - { - Error = Resources.Strings.Add_Err_Download; - } - finally + 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) { - source.IsDownloading = false; - source.Progress = 0; - source.IsProgressIndeterminate = false; + if (!has.Result) continue; + Sources.Add(new SourceRowViewModel(this, src.Name, "available", src.DisplayName) + { + StatsText = src.Badge, + }); } } - /// DLC lua: download and install silently (it's just an unlock — no confirm). + /// 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() { - if (Details?.BaseAppId is null || IsGenerating) return; + if (Details?.BaseAppId is null) return; if (await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return; + Error = null; LastDownload = null; InstallStatus = null; + long appId = Details.AppId; - IsGenerating = true; - try - { - var download = await _api.GenerateDlcAsync(appId.ToString(), Details.BaseAppId, Details.Name, null); - LastDownload = download; + var job = _jobs.CreateDlcJob( + appId, Details.BaseAppId, Details.Name, + onFinished: (item, result) => OnManifestFinished(item, result, needsKey: false), + onReveal: () => NavigateToGame?.Invoke(appId)); - var result = _installer.InstallLua(download.FilePath, appId); - ReportInstall(result); - DeleteStaged(download.FilePath); // installed — drop the temp staging copy - await RefreshStandardUsageAsync(); // DLC generate counts toward 25/day - } - catch (ApiException ex) - { - Error = ex.Message; - } - catch (Exception) + DlcQueueItem = _lastEnqueued = _queue.Enqueue(job); + } + + /// + /// Terminal callback for a manifest/DLC job: drive the install banner and refresh the usage badge. + /// Runs on the dispatcher. + /// + private void OnManifestFinished(DownloadItem item, JobResult? result, bool needsKey) + { + _installedAppId = item.AppId; // the banner's "Reveal" target, even after the search is cleared + + if (result is null) { - Error = Resources.Strings.Add_Err_Generate; + // Cancelled, or failed before the install phase. item.Message already holds the reason. + if (item.Status == DownloadStatus.Failed) Error = item.Message; + else { InstallStatus = item.Message; InstallFailed = false; } } - finally + else { - IsGenerating = false; + InstallFailed = !result.Ok; + InstallStatus = result.Message; + if (result.Ok && _fastFetchSource is not null) + { + InstallStatus += " " + string.Format(Resources.Strings.Add_FastFetch_Via, _fastFetchSource); + _fastFetchSource = null; + } } + + // Usage just changed: Hubcap against the key's own quota, everything else against the 25/day. + _ = needsKey ? ApplyHubcapStateAsync() : RefreshStandardUsageAsync(); } // ── Install + overwrite confirm ───────────────────────────────── - /// Build the before/after diff and open the confirm overlay. Returns true if shown. - private async Task ShowOverwriteConfirmAsync(string oldLuaPath, string newZipPath, long appId) - { - 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 - - var diff = LuaFileParser.Diff(oldLua, newLua); - - // Names from caches + one steamcmd call (cached per app) → real names, sizes, OS, language. - await _appList.EnsureLoadedAsync(); - _depotsById = await BuildDepotLookupAsync(appId); - DiffAdded = diff.Added.Select(ToDiffRow).ToList(); - DiffRemoved = diff.Removed.Select(ToDiffRow).ToList(); - ConfirmTitle = string.Format(Resources.Strings.Add_Confirm_Replace, Details?.Name ?? appId.ToString()); - ConfirmNoChanges = diff.HasChanges ? null : Resources.Strings.Add_Confirm_NoChanges; - _pendingInstall = (newZipPath, appId); - IsConfirmingOverwrite = true; - - // Lazily resolve any DLC rows still showing a bare id via appdetails, then rebuild. - var unnamed = diff.Added.Concat(diff.Removed) - .Where(e => DiffDisplayName(e) is null) - .Select(e => e.Id).Distinct().ToList(); - if (unnamed.Count > 0) + /// + /// The queue's confirmation gate for a manifest whose lua is already installed: show the + /// before/after diff overlay and block until the user answers. True installs, false discards. + /// + /// + /// Called by from a background thread, so everything here marshals + /// to the dispatcher. While this is awaited the item has already released its concurrency slot, so + /// an overlay the user ignores delays only its own install. + /// + /// and are passed in rather than read + /// from Details: the user can load a different game while this download is in flight, and the + /// overlay must describe the game that was actually downloaded. + /// + /// Only one overlay can be open at a time (there is a single set of overlay properties), so + /// concurrent confirmations queue behind _confirmGate. + /// + private async Task ConfirmOverwriteAsync( + DownloadedFile file, long appId, string gameName, CancellationToken ct) + { + // No existing lua → nothing to confirm against, install straight away. + string? existing = _installer.ReadInstalledLua(appId); + if (existing is null) return true; + + await _confirmGate.WaitAsync(ct); + try { - await Parallel.ForEachAsync(unnamed, new ParallelOptions { MaxDegreeOfParallelism = 4 }, - async (id, _) => await _appInfo.ResolveAsync(id)); - if (IsConfirmingOverwrite && _pendingInstall is { } p && p.appId == appId) + var oldLua = LuaFileParser.Parse(existing, appId); + var newLua = ExtractLuaFromZip(file.FilePath, appId); + if (newLua is null) return true; // can't diff (bare lua / unreadable zip) → just install + + var diff = LuaFileParser.Diff(oldLua, newLua); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await App.Current.Dispatcher.InvokeAsync(async () => { + // Names from caches + one steamcmd call (cached per app) → real names, sizes, OS, language. + await _appList.EnsureLoadedAsync(); + _depotsById = await BuildDepotLookupAsync(appId); DiffAdded = diff.Added.Select(ToDiffRow).ToList(); DiffRemoved = diff.Removed.Select(ToDiffRow).ToList(); - } + ConfirmTitle = string.Format(Resources.Strings.Add_Confirm_Replace, gameName); + ConfirmNoChanges = diff.HasChanges ? null : Resources.Strings.Add_Confirm_NoChanges; + _pendingConfirm = tcs; + IsConfirmingOverwrite = true; + + // Lazily resolve any DLC rows still showing a bare id via appdetails, then rebuild. + var unnamed = diff.Added.Concat(diff.Removed) + .Where(e => DiffDisplayName(e) is null) + .Select(e => e.Id).Distinct().ToList(); + if (unnamed.Count > 0) + { + await Parallel.ForEachAsync(unnamed, new ParallelOptions { MaxDegreeOfParallelism = 4 }, + async (id, _) => await _appInfo.ResolveAsync(id)); + if (IsConfirmingOverwrite && ReferenceEquals(_pendingConfirm, tcs)) + { + DiffAdded = diff.Added.Select(ToDiffRow).ToList(); + DiffRemoved = diff.Removed.Select(ToDiffRow).ToList(); + } + } + }); + + // Cancelling the download while the overlay is open closes it and declines. + await using var reg = ct.Register(() => tcs.TrySetResult(false)); + bool answer = await tcs.Task; + + await App.Current.Dispatcher.InvokeAsync(() => + { + if (ReferenceEquals(_pendingConfirm, tcs)) + { + _pendingConfirm = null; + IsConfirmingOverwrite = false; + } + }); + return answer; + } + finally + { + _confirmGate.Release(); } - return true; } [RelayCommand] private void ConfirmOverwrite() { IsConfirmingOverwrite = false; - if (_pendingInstall is { } p) InstallZipAndReport(p.zipPath, p.appId); - _pendingInstall = null; + _pendingConfirm?.TrySetResult(true); } [RelayCommand] private void CancelOverwrite() { IsConfirmingOverwrite = false; - 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; - } - - 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 - // 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 - } - - /// Best-effort delete of a staged download after it's been installed, so nothing piles - /// up in the temp staging folder. - private static void DeleteStaged(string path) - { - try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } - } - - /// True if the file begins with the ZIP local-file-header magic (PK\x03\x04). A bare .lua - /// (or any non-zip the server returned under a .zip name) returns false → install it as a loose lua. - private static bool IsZip(string path) - { - try - { - using var fs = File.OpenRead(path); - Span sig = stackalloc byte[4]; - return fs.Read(sig) == 4 && sig[0] == 0x50 && sig[1] == 0x4B && sig[2] == 0x03 && sig[3] == 0x04; - } - catch { return false; } - } - - /// Turn an InstallResult into the result banner text/state. - private void ReportInstall(InstallResult result) - { - if (result.Error is not null) - { - InstallFailed = true; - InstallStatus = result.Error; - return; - } - - if (result.AnyFailed) - { - InstallFailed = true; - InstallStatus = string.Format(Resources.Strings.Add_Status_InstallFailed, result.Failed.Count); - return; - } - - InstallFailed = false; - string name = Details?.Name ?? "lua"; - InstallStatus = result.ManifestCount > 0 - ? string.Format(Resources.Strings.Add_Status_AddedManifests, name, result.ManifestCount) - : string.Format(Resources.Strings.Add_Status_AddedFetch, name); - if (_fastFetchSource is not null) - { - InstallStatus += " " + string.Format(Resources.Strings.Add_FastFetch_Via, _fastFetchSource); - _fastFetchSource = null; - } + _pendingConfirm?.TrySetResult(false); } /// Fetch the app's depots from steamcmd (cached) and index by depot id + dlcappid. @@ -912,7 +977,8 @@ private static string FormatSize(long bytes) { try { - if (!IsZip(zipPath)) return LuaFileParser.Parse(zipPath, appId); // bare .lua → parse as-is + // bare .lua → parse as-is. Same byte sniff the install path uses. + if (!ManifestJobFactory.IsZip(zipPath)) return LuaFileParser.Parse(zipPath, appId); using var archive = System.IO.Compression.ZipFile.OpenRead(zipPath); var luaEntry = archive.Entries.FirstOrDefault(e => @@ -949,7 +1015,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/DownloadsViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs new file mode 100644 index 0000000..6adb933 --- /dev/null +++ b/src/LuaToolsGui/ViewModels/DownloadsViewModel.cs @@ -0,0 +1,216 @@ +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LuaToolsGui.Services; +using LuaToolsGui.Services.Downloads; + +namespace LuaToolsGui.ViewModels; + +/// +/// The Downloads page. A thin projection over : it holds no download state +/// of its own, so the queue stays the single source of truth for every entry point (Add, Fixes, the +/// store plugin and the protocol handler). +/// +public partial class DownloadsViewModel : ObservableObject +{ + private readonly 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(); + _queue.StateChanged += RaiseCounts; + } + + /// Bound directly by the view: Queue.Items and Queue.History. + public DownloadQueue Queue => _queue; + + /// Set by App: jump to the page that owns an item's pending confirmation. + public Action? RevealItem { get; set; } + + public bool HasItems => _queue.Items.Count > 0; + public bool HasHistory => _queue.History.Count > 0; + public bool IsEmpty => !HasItems; + + private void RaiseCounts() + { + OnPropertyChanged(nameof(HasItems)); + OnPropertyChanged(nameof(HasHistory)); + OnPropertyChanged(nameof(IsEmpty)); + } + + // ── 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 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); + + /// Depot downloads only — see . + [RelayCommand] + private void Pause(DownloadItem item) => _queue.Pause(item); + + [RelayCommand] + private void Resume(DownloadItem item) => _queue.Resume(item); + + [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); + + [RelayCommand] + private void MoveDown(DownloadItem item) => _queue.Move(item, +1); + + [RelayCommand] + 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] + private void Review(DownloadItem item) + { + item.Job.OnReveal?.Invoke(); + RevealItem?.Invoke(item); + } +} diff --git a/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs b/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs index 3648b02..6b3de4a 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(); @@ -241,7 +241,8 @@ private void FinishBatch() string ok = parts.Count > 0 ? string.Format(Resources.Strings.Drop_Result_Installed, string.Join(" + ", parts)) : ""; string bad = t.Failed > 0 ? string.Format(Resources.Strings.Drop_Result_Failed, t.Failed) : ""; string err = t.Errors.Count > 0 ? $" {t.Errors[0]}" : ""; - ResultText = $"{ok}{bad}{err}".Trim() + (parts.Count > 0 ? Resources.Strings.Drop_Result_RestartApply : ""); + // No "restart to apply" suffix: OST/BST hot-reload luas written into config/stplug-in. + ResultText = $"{ok}{bad}{err}".Trim(); } if (t.Luas > 0 || t.Manifests > 0) Installed?.Invoke(); diff --git a/src/LuaToolsGui/ViewModels/FixesViewModel.cs b/src/LuaToolsGui/ViewModels/FixesViewModel.cs index 97891a4..6decfa3 100644 --- a/src/LuaToolsGui/ViewModels/FixesViewModel.cs +++ b/src/LuaToolsGui/ViewModels/FixesViewModel.cs @@ -1,10 +1,11 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.IO; using System.IO.Compression; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LuaToolsGui.Models; using LuaToolsGui.Services; +using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.ViewModels; @@ -61,6 +62,43 @@ public partial class FixItemVm(DenuvoFix f) : ObservableObject public string? FixFilename { get; } = f.FixFilename; public string DateLabel { get; } = FormatDate(f.CreatedAt); + /// In-flight queue items for this fix's two slots. The buttons and their progress bars bind + /// straight through, so the shared queue stays the only owner of download state. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDownloadManifest))] + private DownloadItem? _manifestItem; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDownloadFix))] + private DownloadItem? _fixItem; + + /// + /// Whether the game is installed on disk. Only the FIX slot cares: it extracts a zip into the game + /// folder, so with no folder there is nothing to apply. The MANIFEST slot installs a lua and works + /// whether or not the game is installed. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDownloadFix), nameof(FixHint))] + + private bool _gameInstalled; + + /// 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; + + public bool HasApplied => IsApplied; + + public bool CanDownloadManifest => HasManifest && ManifestItem?.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 => + 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") : ""; } @@ -74,25 +112,28 @@ public partial class FixesViewModel : PagedListViewModel { private readonly LuaToolsApiClient api; private readonly AuthService auth; - private readonly LuaInstaller installer; - private readonly SteamService steam; - private readonly SteamLibraryService library; private readonly CoverCache covers; private readonly ToastService toast; private readonly SettingsService settings; + private readonly DownloadQueue queue; + private readonly ManifestJobFactory jobs; + private readonly SteamLibraryService library; + private readonly SteamService steam; public FixesViewModel( - LuaToolsApiClient api, AuthService auth, LuaInstaller installer, SteamService steam, - SteamLibraryService library, CoverCache covers, ToastService toast, SettingsService settings) + LuaToolsApiClient api, AuthService auth, CoverCache covers, ToastService toast, + SettingsService settings, DownloadQueue queue, ManifestJobFactory jobs, + SteamLibraryService library, SteamService steam) { this.api = api; this.auth = auth; - this.installer = installer; - this.steam = steam; - this.library = library; this.covers = covers; this.toast = toast; this.settings = settings; + this.queue = queue; + this.jobs = jobs; + this.library = library; + this.steam = steam; InitPageSize(settings.FixesPageSize); } @@ -119,6 +160,54 @@ 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; + + /// + /// 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) + { + 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))] @@ -136,13 +225,8 @@ protected override void OnPageSliced(IReadOnlyList slice) private string? _selectedFixTagId; public bool HasFixTags => FixTags.Count > 0; - // ── Download state (one at a time) ─────────────────────────────── - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(NotBusy))] - private bool _isBusy; - public bool NotBusy => !IsBusy; - [ObservableProperty] private double _progress; - [ObservableProperty] private bool _isProgressIndeterminate; + // Downloads are owned by the shared DownloadQueue; per-fix progress lives on FixItemVm. The page no + // longer has an IsBusy gate, so several fixes can be queued without waiting for each other. // ── Load ───────────────────────────────────────────────────────── @@ -164,6 +248,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; } @@ -174,6 +266,7 @@ public async Task LoadAsync(bool force = false) finally { IsLoading = false; + Loaded = true; // gates the filter pills: they appear only once the listing settled } } @@ -182,6 +275,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)); @@ -191,6 +285,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(); } @@ -200,9 +295,10 @@ 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 + // Hand the filtered list to the base: it slices the visible page and (via OnPageSliced) warms // that page's covers. SetFiltered(shown); } @@ -229,6 +325,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) { @@ -246,8 +368,21 @@ 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). + // 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. + 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 (its revert record on disk). + f.IsApplied = installDir is not null + && File.Exists(ManifestJobFactory.GetFixRecordPath(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). var distinct = _allFixes.SelectMany(f => f.Tags) .GroupBy(t => t.Id).Select(g => g.First()) .OrderBy(t => t.Name).ToList(); @@ -258,7 +393,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; } } @@ -289,120 +424,89 @@ private void ApplyFixFilter() [RelayCommand] private Task DownloadFix(FixItemVm fix) => RunDownload(fix, "fix"); - private async Task RunDownload(FixItemVm fix, string slot) + /// Confirm, then revert an applied fix back to its original files. + [RelayCommand] + private void RevertFix(FixItemVm fix) { - if (IsBusy) return; - if (await PromptSignInIfGuestAsync(Resources.Strings.Fixes_SignIn)) return; if (SelectedGame is not { } game) return; - if (!long.TryParse(game.AppId, out long appId)) return; - - IsBusy = true; - IsProgressIndeterminate = true; - Progress = 0; - try - { - string fallback = slot == "manifest" - ? fix.ManifestFilename ?? $"{game.AppId}.zip" - : fix.FixFilename ?? $"{game.AppId}_fix.zip"; - - var prog = new Progress(p => - { - IsProgressIndeterminate = p is null; - if (p is not null) Progress = p.Value * 100; - }); - - var file = await api.DownloadDenuvoAsync(fix.Id, slot, fallback, prog); + _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; + } - if (slot == "manifest") - InstallManifest(file, appId, game.Name); - else - ApplyFix(file, appId, game.Name); - } - catch (ApiException ex) - { - toast.Show(Resources.Strings.Fixes_Toast_DownloadFailed, ex.Message, error: true); - } - catch (Exception) - { - toast.Show(Resources.Strings.Fixes_Toast_DownloadFailed, Resources.Strings.Fixes_Toast_DownloadFailed_Body, error: true); - } - finally - { - IsBusy = false; - IsProgressIndeterminate = false; - } + [RelayCommand] + private void CancelRevertConfirm() + { + IsConfirmingRevert = false; + _pendingRevert = null; } - /// Manifest slot: install into Steam force-LOCKED (Denuvo fixes must stay version-pinned). - private void InstallManifest(DownloadedFile file, long appId, string gameName) + [RelayCommand] + private async Task ConfirmRevert() { - bool isZip = file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); - 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 + 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; - if (result.AnyFailed) - { - toast.Show(Resources.Strings.Fixes_Toast_InstallFailed, - result.Error ?? Resources.Strings.Fixes_Toast_InstallFailed_Body, - error: true); - return; - } + var result = await Task.Run(() => jobs.RevertDenuvoFix(appId, fix.Id, game.Name)); - bool restarted = steam.RestartSteam(); - toast.Show(Resources.Strings.Fixes_Toast_FixInstalled, restarted - ? string.Format(Resources.Strings.Fixes_Toast_FixInstalled_Restarting, gameName) - : string.Format(Resources.Strings.Fixes_Toast_FixInstalled_Restart, gameName)); + // 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; } - /// Fix slot: only applicable if the game is installed — extract the zip into its folder. - private void ApplyFix(DownloadedFile file, long appId, string gameName) + 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. + /// + private async Task RunDownload(FixItemVm fix, string slot) { - string? installDir = library.GetInstallDir(appId); - if (installDir is null) + if (await PromptSignInIfGuestAsync(Resources.Strings.Fixes_SignIn)) return; + if (SelectedGame is not { } game) return; + if (!long.TryParse(game.AppId, out long appId)) return; + + // The Fix button is disabled for uninstalled games, but the flyout's snapshot can be stale by + // now (and nothing stops a programmatic caller). Cheap local check, so do it before queueing + // rather than after paying for a download. + if (slot == "fix" && library.GetInstallDir(appId) is null) { - toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName), error: true); + toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, + string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, game.Name), error: true); return; } - try - { - // Extract into the game folder (overwrite existing files). Best-effort per entry. - using var archive = ZipFile.OpenRead(file.FilePath); - int failed = 0; - foreach (var entry in archive.Entries) + string fallback = slot == "manifest" + ? fix.ManifestFilename ?? $"{game.AppId}.zip" + : fix.FixFilename ?? $"{game.AppId}_fix.zip"; + + var job = jobs.CreateDenuvoJob(fix.Id, slot, fallback, appId, game.Name, fix.Title, + onFinished: (item, result) => { - if (string.IsNullOrEmpty(entry.Name)) continue; // directory entry - string dest = Path.Combine(installDir, entry.FullName); - try + // 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) { - Directory.CreateDirectory(Path.GetDirectoryName(dest)!); - entry.ExtractToFile(dest, overwrite: true); + toast.Show(Resources.Strings.Fixes_Toast_DownloadFailed, + item.Message ?? Resources.Strings.Fixes_Toast_DownloadFailed_Body, error: true); + return; } - catch { failed++; } - } - if (failed > 0) - toast.Show(Resources.Strings.Fixes_Toast_PartiallyApplied, - string.Format(Resources.Strings.Fixes_Toast_PartiallyApplied_Body, failed), error: true); - else - toast.Show(Resources.Strings.Fixes_Toast_FixApplied, string.Format(Resources.Strings.Fixes_Toast_FixApplied_Body, gameName)); - } - catch (Exception ex) - { - toast.Show(Resources.Strings.Fixes_Toast_CouldntApply, ex.Message, error: true); - } - finally - { - DeleteStaged(file.FilePath); // archive is disposed by now — drop the temp staging copy - } - } + // 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; + }); - /// Best-effort delete of a staged download after it's been consumed by an install. - private static void DeleteStaged(string path) - { - try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } + var item = queue.Enqueue(job); + if (slot == "manifest") fix.ManifestItem = item; + else fix.FixItem = item; } private async Task PromptSignInIfGuestAsync(string message) 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..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; @@ -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). @@ -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); @@ -493,7 +496,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); @@ -502,19 +505,7 @@ private void DeleteSelected() MessageBox.Show(string.Format(Resources.Strings.Manage_RemoveFailed_Count, failed), Resources.Strings.Manage_RemoveFailed_Title, MessageBoxButton.OK, MessageBoxImage.Warning); - OfferRestartSteam(); - } - - private void OfferRestartSteam() - { - var r = MessageBox.Show( - Resources.Strings.Manage_RestartSteam_Ask, - Resources.Strings.Manage_RestartSteam_Title, - MessageBoxButton.YesNo, - MessageBoxImage.Question); - if (r == MessageBoxResult.Yes && !_steam.RestartSteam()) - MessageBox.Show(Resources.Strings.Manage_RestartSteam_Failed, - Resources.Strings.Manage_RestartSteam_Title, MessageBoxButton.OK, MessageBoxImage.Warning); + // No restart prompt: OST/BST watch config/stplug-in, so deleting a lua un-applies it live. } /// Delete one lua file; returns false (and warns, unless silent) on failure. @@ -540,7 +531,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 +559,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 +569,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 +592,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 +657,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 +670,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 +687,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 +772,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 +807,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/ModeViewModel.cs b/src/LuaToolsGui/ViewModels/ModeViewModel.cs index 8f5069f..e480668 100644 --- a/src/LuaToolsGui/ViewModels/ModeViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ModeViewModel.cs @@ -7,7 +7,7 @@ namespace LuaToolsGui.ViewModels; -/// One card on the Mode page — a single unlocker backend's name, status, and action button. +/// One card on the Mode page. A single unlocker backend's name, status, and action button. public partial class ModeCardViewModel(UnlockerMode mode, string title, string description) : ObservableObject { public UnlockerMode Mode { get; } = mode; @@ -18,23 +18,28 @@ public partial class ModeCardViewModel(UnlockerMode mode, string title, string d [ObservableProperty] private string _buttonText = Resources.Strings.Mode_Btn_Install; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowManage))] + [NotifyPropertyChangedFor(nameof(ShowActionButton))] private bool _isActive; - /// The CloudRedirect "Manage" button shows only on the CloudRedirect card while it's active. - public bool IsCloudRedirect => Mode == UnlockerMode.CloudRedirect; - public bool ShowManage => IsCloudRedirect && IsActive; + /// + /// Custom has nothing to install, update or reinstall, so once it's the active mode there is no + /// action left to offer and the button is hidden. The ACTIVE badge already says it's selected. + /// Every other card always has something to do (install, update, or switch to it). + /// + public bool ShowActionButton => !(Mode == UnlockerMode.Custom && IsActive); + + /// Our own fork is the steer-here option. + public bool IsRecommended => Mode == UnlockerMode.Bst; - /// BetterSteamTools (enum still OpenSteamTools internally) gets a "Recommended" badge. - public bool IsRecommended => Mode == UnlockerMode.OpenSteamTools; + /// OST is the nightly channel, so it carries the amber warning. + public bool IsExperimental => Mode == UnlockerMode.Ost; - /// The nightly build gets "Experimental" + "CloudRedirect Support" badges. - public bool IsExperimental => Mode == UnlockerMode.OpenSteamToolsNightly; - public bool SupportsCloudRedirect => Mode == UnlockerMode.OpenSteamToolsNightly; + /// Both OpenSteamTool-derived builds carry native CloudRedirect support. + public bool SupportsCloudRedirect => Mode is UnlockerMode.Ost or UnlockerMode.Bst; } /// -/// "Mode" page: SteamTools / OpenSteamTools / CloudRedirect — mutually exclusive, one active at a time. +/// "Mode" page: OpenSteamTools / BetterSteamTools / Custom. Mutually exclusive, one active at a time. /// Checks status on page open; each card installs/switches after a Steam-shutdown confirmation, then /// relaunches Steam so the new mode takes effect. /// @@ -70,7 +75,7 @@ public ModeViewModel(UnlockerService unlocker, ToastService toast, SteamService _cloudRedirect = cloudRedirect; } - /// CloudRedirect "Manage": download (cache) the CloudRedirect GUI and launch it. + /// CloudRedirect "Manage" (add-on panel): download (cache) the CloudRedirect GUI and launch it. [RelayCommand] private async Task ManageCloudRedirect() { @@ -98,8 +103,8 @@ private async Task ManageCloudRedirect() } } - // ── CloudRedirect add-on (bottom panel; usable only when Nightly BST is active) ─── - private const string CloudRedirectTitle = "CloudRedirect"; // product name — not localized + // ── CloudRedirect add-on (bottom panel; usable when either OST or BST is active) ─── + private const string CloudRedirectTitle = "CloudRedirect"; // product name, not localized [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowCloudRedirectManage))] @@ -133,7 +138,7 @@ private async Task ManageCloudRedirect() /// update only when unlocked (Nightly active), respecting forceRefresh. private async Task RefreshCloudRedirectAsync(bool forceRefresh) { - CloudRedirectUnlocked = _unlocker.SelectedMode == UnlockerMode.OpenSteamToolsNightly; + CloudRedirectUnlocked = _unlocker.SelectedMode is UnlockerMode.Ost or UnlockerMode.Bst; var s = await _unlocker.GetCloudRedirectStateAsync(checkUpdate: CloudRedirectUnlocked, forceRefresh); CloudRedirectInstalled = s.Installed; CloudRedirectEnabled = s.Enabled; @@ -147,7 +152,7 @@ private async Task RefreshCloudRedirectAsync(bool forceRefresh) } /// Enable/disable the add-on (edits opensteamtool.toml; first enable also downloads the dll). - /// Lightweight — no Steam close; the change applies on the next Steam launch. + /// Lightweight, no Steam close; the change applies on the next Steam launch. [RelayCommand] private async Task ToggleCloudRedirect() { @@ -228,11 +233,7 @@ private bool IsModeVisible(ModeDefinition def) /// existing cards so their bound state isn't reset; adds/removes as visibility changes. private void SyncCards() { - // CloudRedirect is temporarily hidden (mode currently broken). The enum member, its - // ModeDefinition, and install/auto-detect logic stay intact so persisted CloudRedirect - // settings still resolve — remove this filter to re-enable the card. var visible = _unlocker.Modes - .Where(d => d.Mode != UnlockerMode.CloudRedirect) .Where(IsModeVisible) .ToList(); @@ -253,7 +254,7 @@ private void SyncCards() /// /// Page open / refresh. Only the ACTIVE mode is checked against GitHub (inactive cards just show - /// "Switch to this" — switching re-fetches anyway, so pinging for them is wasted, and their hash + /// "Switch to this": switching re-fetches anyway, so pinging for them is wasted, and their hash /// check would be misleading since modes share filenames). Active check is cached briefly. /// private bool _detectionAttempted; @@ -304,9 +305,13 @@ private async Task CheckForUpdates() private void Apply(ModeCardViewModel card, ModeState s) { // Only the ACTIVE mode shows real install/update status. Inactive modes share filenames with - // the active one (different contents), so their hash check is meaningless — show them simply + // the active one (different contents), so their hash check is meaningless. Show them simply // as the switch target instead of a misleading "Update available". - if (s.Status == ModeStatus.Unknown) + if (s.Status == ModeStatus.UserManaged) + // Custom: there's nothing to be up to date WITH, so the status never varies and the only + // action is switching to it. Checked before IsActive so it never reads "Not active". + card.StatusText = Resources.Strings.Mode_UserManaged; + else if (s.Status == ModeStatus.Unknown) card.StatusText = Resources.Strings.Mode_StatusUnavailable; else if (!s.IsActive) card.StatusText = Resources.Strings.Mode_NotActive; @@ -321,6 +326,10 @@ private void Apply(ModeCardViewModel card, ModeState s) card.ButtonText = (s.IsActive, s.Status) switch { + // Never rendered: ShowActionButton hides the button in this exact case. Mapped anyway so + // a regression in that binding surfaces a harmless "Switch to this" rather than falling + // through to "Install" on a mode that installs nothing. + (true, ModeStatus.UserManaged) => Resources.Strings.Mode_Btn_Switch, (true, ModeStatus.UpToDate) => Resources.Strings.Mode_Btn_Reinstall, (true, ModeStatus.UpdateAvailable) => Resources.Strings.Mode_Btn_Update, (true, _) => Resources.Strings.Mode_Btn_Install, @@ -391,7 +400,7 @@ private async Task RunInstall(UnlockerMode mode) } else { - // Install failed — bring Steam back up anyway so the user isn't left without it. + // Install failed: bring Steam back up anyway so the user isn't left without it. await Task.Run(_steam.StartSteam); _toast.Show(Resources.Strings.Mode_Toast_InstallFailed, result.Error ?? Resources.Strings.Mode_Toast_InstallFailed_Body, error: true); } diff --git a/src/LuaToolsGui/ViewModels/OnboardingViewModel.cs b/src/LuaToolsGui/ViewModels/OnboardingViewModel.cs index 510028a..882a476 100644 --- a/src/LuaToolsGui/ViewModels/OnboardingViewModel.cs +++ b/src/LuaToolsGui/ViewModels/OnboardingViewModel.cs @@ -48,7 +48,7 @@ public OnboardingViewModel(AuthService auth, CacheService cache, SettingsService [ObservableProperty] private bool _isSigningIn; - // The two yes/no choices — both default to "yes". + // The two yes/no choices: both default to "yes". [ObservableProperty] private bool _applyRecommended = true; [ObservableProperty] private bool _installPlugin = true; @@ -77,7 +77,7 @@ private async Task SignIn() finally { IsSigningIn = false; } } - /// "Let's go!" — mark onboarding done, close the overlay immediately, then apply the chosen + /// "Let's go!". Mark onboarding done, close the overlay immediately, then apply the chosen /// actions in the background. Closing first means a long-running (Steam-restarting) install can never /// leave the welcome dialog stuck. [RelayCommand] @@ -87,7 +87,7 @@ private void Finish() bool applyRecommended = ApplyRecommended; bool installPlugin = InstallPlugin; - _cache.OnboardingComplete = true; // seen — never show again + _cache.OnboardingComplete = true; // seen, never show again IsOpen = false; // dismiss now, before any slow work if (applyRecommended || installPlugin) @@ -103,7 +103,7 @@ private async Task ApplyChoicesAsync(bool applyRecommended, bool installPlugin) try { // Close Steam ONCE up front so both installs run against a stopped Steam, then relaunch it once - // at the end — avoids the double restart of letting each installer manage Steam separately. + // at the end. Avoids the double restart of letting each installer manage Steam separately. // (The plugin installer only relaunches Steam if it was up when it ran; since we pre-stopped it, // it won't, and our StartSteam below is the single relaunch.) await Task.Run(_steam.StopSteam); @@ -111,7 +111,7 @@ private async Task ApplyChoicesAsync(bool applyRecommended, bool installPlugin) if (applyRecommended) { _settings.FastFetch = true; - var result = await _unlocker.InstallAsync(UnlockerMode.OpenSteamTools); + var result = await _unlocker.InstallAsync(UnlockerMode.Bst); // the Recommended mode if (!result.Success) _toast.Show(Resources.Strings.Onboarding_Title, result.Error ?? "", error: true); } 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..e02b511 100644 --- a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -12,12 +12,17 @@ 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; 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; @@ -42,7 +47,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 +98,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. @@ -148,6 +153,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) @@ -159,7 +182,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 +224,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,12 +240,14 @@ 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; } - public SettingsViewModel(SettingsService settings, AuthService auth, SteamService steam, HubcapService hubcap) + public SettingsViewModel(SettingsService settings, AuthService auth, SteamService steam, + HubcapService hubcap, Services.Sources.SourcePackRegistry packs) { + _packs = packs; _settings = settings; _auth = auth; _steam = steam; @@ -233,7 +258,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); @@ -241,6 +266,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() @@ -279,7 +308,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 +385,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) @@ -365,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. @@ -407,7 +440,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 +474,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); @@ -450,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/BuildsView.xaml b/src/LuaToolsGui/Views/BuildsView.xaml index d3c5d96..6899018 100644 --- a/src/LuaToolsGui/Views/BuildsView.xaml +++ b/src/LuaToolsGui/Views/BuildsView.xaml @@ -1,4 +1,4 @@ - - + + + + + + + + + + + + @@ -158,7 +172,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. --> + Text="{Binding DepotSearchText, UpdateSourceTrigger=PropertyChanged}" + Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVis}}" /> + VerticalScrollBarVisibility="Auto" + Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVis}}"> @@ -757,6 +781,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LuaToolsGui/Views/BuildsView.xaml.cs b/src/LuaToolsGui/Views/BuildsView.xaml.cs index c6a58c7..b836186 100644 --- a/src/LuaToolsGui/Views/BuildsView.xaml.cs +++ b/src/LuaToolsGui/Views/BuildsView.xaml.cs @@ -37,7 +37,7 @@ private void ScrollListToTop() => 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..3a9bfc6 100644 --- a/src/LuaToolsGui/Views/ConfirmOverlay.xaml +++ b/src/LuaToolsGui/Views/ConfirmOverlay.xaml @@ -1,4 +1,4 @@ - + NOT a Popup. A Popup gets a system drop-shadow around its rectangular surface ("boxed glow"). --> @@ -128,7 +128,8 @@ Grid.Row="2" Margin="0,12,0,0" VerticalScrollBarVisibility="Auto"> - + + - + + @@ -252,7 +256,11 @@ HorizontalScrollBarVisibility="Auto" PreviewMouseWheel="FeaturedStrip_PreviewMouseWheel" VerticalScrollBarVisibility="Disabled"> - + + @@ -548,12 +556,41 @@ Visibility="{Binding IsLocked, Converter={StaticResource BoolToVis}}" /> + + Visibility="{Binding QueueItem.ShowProgress, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}" + Value="{Binding QueueItem.Percent, Mode=OneWay, FallbackValue=0}" /> + + + + + + + + + @@ -634,7 +671,8 @@ - + + @@ -760,7 +798,8 @@ Grid.Row="2" Margin="0,14,0,0" VerticalScrollBarVisibility="Auto"> - + + 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/DownloadsView.xaml b/src/LuaToolsGui/Views/DownloadsView.xaml new file mode 100644 index 0000000..f72cfa7 --- /dev/null +++ b/src/LuaToolsGui/Views/DownloadsView.xaml @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LuaToolsGui/Views/DownloadsView.xaml.cs b/src/LuaToolsGui/Views/DownloadsView.xaml.cs new file mode 100644 index 0000000..20c4256 --- /dev/null +++ b/src/LuaToolsGui/Views/DownloadsView.xaml.cs @@ -0,0 +1,13 @@ +using System.Windows.Controls; +using LuaToolsGui.ViewModels; + +namespace LuaToolsGui.Views; + +public partial class DownloadsView : UserControl +{ + public DownloadsView(DownloadsViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } +} 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..c7da032 100644 --- a/src/LuaToolsGui/Views/FixesView.xaml +++ b/src/LuaToolsGui/Views/FixesView.xaml @@ -1,4 +1,4 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + @@ -338,7 +398,8 @@ - + + -