diff --git a/README.md b/README.md index cef0e15..d0e709a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ You can find release builds on the [luatools website](https://lua.tools/app) or - [Millennium](https://steambrew.app/): the Steam plugin framework whose injection API this app polyfills when Millennium isn't installed - [Velopack](https://velopack.io/): installer and auto-update framework +- [DepotDownloaderMod](https://github.com/SteamAutoCracks/DepotDownloaderMod): downloads depot content + from Steam's CDN, powering the Depots page's Download action. A fork of + [DepotDownloader](https://github.com/SteamRE/DepotDownloader), fetched and run as a standalone tool +- [SteamAutoCrack](https://github.com/SteamAutoCracks/Steam-auto-crack): fetched and launched from the + Downloads page +- [Steamless](https://github.com/atom0s/Steamless): removes SteamStub DRM from game executables +- [CloudRedirect](https://github.com/Selectively11/CloudRedirect): Steam Cloud redirection, used by the + mode install flow ## Licence diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index aa7f5ec..41374c1 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; @@ -40,7 +40,9 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); // one per page (Home, Add) @@ -48,6 +50,12 @@ 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(); @@ -64,12 +72,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(); @@ -215,6 +225,8 @@ protected override async void OnStartup(StartupEventArgs e) // 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 @@ -224,6 +236,8 @@ protected override async void OnStartup(StartupEventArgs e) if (System.IO.Directory.Exists(legacy)) System.IO.Directory.Delete(legacy, recursive: true); } catch { /* best effort, never block startup on cleanup */ } + + Services.Downloads.HttpFileDownloader.SweepStale(); }); await _host.StartAsync(); @@ -237,6 +251,12 @@ protected override async void OnStartup(StartupEventArgs e) 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(); @@ -344,8 +364,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" + // 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 @@ -514,7 +546,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 68d06f0..0a65eb0 100644 --- a/src/LuaToolsGui/AppConfig.cs +++ b/src/LuaToolsGui/AppConfig.cs @@ -48,6 +48,35 @@ public static class AppConfig // 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"; 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 2adf975..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; @@ -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)); diff --git a/src/LuaToolsGui/Models/ModeModels.cs b/src/LuaToolsGui/Models/ModeModels.cs index eeefd97..308f58a 100644 --- a/src/LuaToolsGui/Models/ModeModels.cs +++ b/src/LuaToolsGui/Models/ModeModels.cs @@ -77,6 +77,10 @@ public sealed class GithubAsset [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = ""; [JsonPropertyName("digest")] public string? Digest { get; set; } // "sha256:" + + /// Asset size in bytes. Lets a tool download report real byte counts instead of a bare + /// fraction, since GithubProxy.DownloadAsync only reports 0..1. + [JsonPropertyName("size")] public long Size { get; set; } } /// Queried state for one mode. What a Mode-page card binds to. diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 226b9b5..199186b 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. @@ -187,7 +187,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)); @@ -271,7 +270,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 ── @@ -290,8 +288,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)); @@ -333,7 +330,6 @@ public static class Strings public static string Add_Err_BaseGame => Get(nameof(Add_Err_BaseGame)); public static string Add_Err_Generic => Get(nameof(Add_Err_Generic)); public static string Add_Err_Download => Get(nameof(Add_Err_Download)); - public static string Add_Err_Generate => Get(nameof(Add_Err_Generate)); public static string Add_Confirm_Replace => Get(nameof(Add_Confirm_Replace)); public static string Add_Confirm_NoChanges => Get(nameof(Add_Confirm_NoChanges)); public static string Add_Status_Cancelled => Get(nameof(Add_Status_Cancelled)); @@ -508,4 +504,83 @@ public static class Strings 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)); } diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 3af59fe..8bb97b8 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -220,7 +220,6 @@ {0} ملف manifest تم تثبيت {0} في Steam. فشل {0} من الملفات — أغلِق Steam وأعِد المحاولة. - أعد تشغيل Steam للتطبيق. ابحث بالاسم أو App ID... @@ -294,13 +293,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} @@ -308,7 +307,6 @@ {1} تعذّر حذف {0} من الملفات. إعادة تشغيل Steam - إعادة تشغيل Steam الآن ليسري مفعول التغييرات؟ تعذّر العثور على Steam أو تشغيله. حدّد موقعه في الإعدادات. @@ -328,8 +326,7 @@ فشل التثبيت تعذّر التثبيت — أغلِق Steam (أو أعد تشغيله) وحاول مجددًا. تم تثبيت الإصلاح - تم تثبيت manifest الخاص بـ {0} — يُعاد تشغيل Steam. - تم تثبيت manifest الخاص بـ {0}. أعد تشغيل Steam للتطبيق. + تم تثبيت ملف بيان {0}. اللعبة غير موجودة ثبّت {0} في Steam أولًا، ثم طبّق الإصلاح. طُبِّق الإصلاح جزئيًا @@ -372,13 +369,12 @@ تعذّر تحديد اللعبة الأساسية لمحتوى DLC هذا حدث خطأ ما — تحقق من اتصالك وحاول مجددًا. فشل التنزيل — تحقق من اتصالك وحاول مجددًا. - فشل الإنشاء — تحقق من اتصالك وحاول مجددًا. استبدال "{0}"؟ لا تغييرات في المستودع/DLC — المحتوى نفسه. أُلغي التثبيت — تُركت الملفات الحالية دون تغيير. تعذّر تثبيت {0} من الملفات — أغلِق Steam (أو استخدم إعادة تشغيل Steam) وحاول مجددًا. - تمت إضافة {0} — ملف lua + {1} ملف manifest. أعد تشغيل Steam للتطبيق. - تمت إضافة {0} — سيجلب Steam ملفات manifest. أعد تشغيل Steam للتطبيق. + تمت إضافة {0}: lua + {1} ملف بيان. + تمت إضافة {0}. سيجلب Steam ملفات البيان. فتح في SteamDB @@ -477,9 +473,9 @@ حذف هذا الإعداد المسبق؟ ستتم إزالة «{0}» من إعداداتك المسبقة المحفوظة. هذا لا يؤثر على اللعبة نفسها. لا يمكن حذف الإعداد المسبق المستخدم حاليًا. - يُستخدم الآن «{0}». أعد تشغيل Steam ليسري المفعول. + يتم الآن استخدام ‏«{0}». تعذّر تبديل الإعداد المسبق — قد يكون ملف lua قيد الاستخدام. - تم الحفظ. أعد تشغيل Steam ليسري المفعول. + تم الحفظ. تعذّر الحفظ — قد يكون ملف lua قيد الاستخدام. تم الحفظ كإعداد مسبق. حفظ في «{0}» @@ -553,4 +549,84 @@ لقد بلغت حدّك اليومي في 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}) من سجل التنزيلات؟ لن تتأثر الملفات التي تم تنزيلها. diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 8beb744..bf12f47 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -208,7 +208,6 @@ {0} манифест(а) {0} е инсталиран(и) в Steam. {0} файл(а) са неуспешни — затворете Steam и опитайте отново. - Рестартирайте Steam, за да приложите. Търсене по име или App ID... Зареждане… App ID: {0} @@ -276,13 +275,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} @@ -290,7 +289,6 @@ {1} {0} файла не можаха да бъдат изтрити. Рестартиране на Steam - Рестартиране на Steam сега, за да влязат в сила промените? Steam не може да бъде намерен или стартиран. Задайте местоположението му в Настройки. Корекции Зареждане на корекции… @@ -307,8 +305,7 @@ Инсталирането е неуспешно Не може да се инсталира — затворете Steam (или го рестартирайте) и опитайте отново. Корекцията е инсталирана - Манифестът на {0} е инсталиран — Steam се рестартира. - Манифестът на {0} е инсталиран. Рестартирайте Steam, за да приложите. + Манифестът на {0} е инсталиран. Играта не е намерена Първо инсталирайте {0} в Steam, след което приложете корекцията. Корекцията е приложена частично @@ -348,13 +345,12 @@ Не може да се определи основната игра за това DLC Нещо се обърка — проверете връзката си и опитайте отново. Изтеглянето е неуспешно — проверете връзката си и опитайте отново. - Генерирането е неуспешно — проверете връзката си и опитайте отново. Замяна на „{0}“? Няма промени в депо/DLC — същото съдържание. Инсталирането е отказано — съществуващите файлове са непроменени. Не можаха да се инсталират {0} файл(а) — затворете Steam (или използвайте Рестартиране на Steam) и опитайте отново. - {0} е добавен — lua + {1} манифест(а). Рестартирайте Steam, за да приложите. - {0} е добавен — Steam ще извлече манифестите. Рестартирайте Steam, за да приложите. + Добавено {0}: lua + {1} манифест(а). + Добавено {0}. Steam ще изтегли манифестите. Отваряне в SteamDB СПОДЕЛЕНО Това замества инсталирания lua. Прегледайте какво се променя: @@ -451,9 +447,9 @@ Да се изтрие ли тази настройка? „{0}“ ще бъде премахната от запазените ви настройки. Това не засяга самата игра. Настройката, която се използва в момента, не може да бъде изтрита. - Сега се използва „{0}“. Рестартирайте Steam, за да влезе в сила. + Сега се използва „{0}“. Настройката не можа да бъде сменена — lua файлът може да е зает. - Запазено. Рестартирайте Steam, за да влезе в сила. + Запазено. Запазването е неуспешно — lua файлът може да е зает. Запазено като предварителна настройка. Запази в „{0}“ @@ -527,4 +523,84 @@ Достигна дневния си лимит за 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} записа от историята на изтеглянията? Изтеглените файлове не се засягат. diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 02628a1..1163975 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -208,7 +208,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} @@ -278,11 +277,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} @@ -290,7 +289,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… @@ -307,8 +305,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ě @@ -348,13 +345,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í: @@ -451,9 +447,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}“ @@ -527,4 +523,84 @@ Tím se smažou soubory .lua ze Steam\config\stplug-in. Poté restartujte Steam, 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. diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index dd41f7e..57129a4 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Dette sletter .lua-filerne fra Steam\config\stplug-in. Genstart Steam bagefter, 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! + + 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. diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index d973386..3054c0d 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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 @@ -527,4 +523,84 @@ Dadurch werden die .lua-Dateien aus Steam\config\stplug-in gelöscht. Starte Ste 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. diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index b22e08e..4850027 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -208,7 +208,6 @@ {0} manifest Το {0} εγκαταστάθηκε στο Steam. {0} αρχείο(α) απέτυχαν — κλείστε το Steam και δοκιμάστε ξανά. - Επανεκκινήστε το Steam για εφαρμογή. Αναζήτηση με όνομα ή App ID... Φόρτωση… App ID: {0} @@ -278,11 +277,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} @@ -290,7 +289,6 @@ {1} Δεν ήταν δυνατή η διαγραφή {0} αρχείων. Επανεκκίνηση Steam - Επανεκκίνηση του Steam τώρα ώστε να τεθούν σε ισχύ οι αλλαγές; Δεν ήταν δυνατή η εύρεση ή εκκίνηση του Steam. Ορίστε την τοποθεσία του στις Ρυθμίσεις. Διορθώσεις Φόρτωση διορθώσεων… @@ -307,8 +305,7 @@ Η εγκατάσταση απέτυχε Δεν ήταν δυνατή η εγκατάσταση — κλείστε το Steam (ή επανεκκινήστε το) και δοκιμάστε ξανά. Η διόρθωση εγκαταστάθηκε - Το manifest του {0} εγκαταστάθηκε — το Steam επανεκκινείται. - Το manifest του {0} εγκαταστάθηκε. Επανεκκινήστε το Steam για εφαρμογή. + Το μανιφέστο του {0} εγκαταστάθηκε. Το παιχνίδι δεν βρέθηκε Εγκαταστήστε πρώτα το {0} στο Steam και έπειτα εφαρμόστε τη διόρθωση. Η διόρθωση εφαρμόστηκε εν μέρει @@ -348,13 +345,12 @@ Δεν ήταν δυνατός ο προσδιορισμός του βασικού παιχνιδιού για αυτό το DLC Κάτι πήγε στραβά — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Η λήψη απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. - Η δημιουργία απέτυχε — ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Αντικατάσταση του «{0}»; Καμία αλλαγή depot/DLC — ίδιο περιεχόμενο. Η εγκατάσταση ακυρώθηκε — τα υπάρχοντα αρχεία παρέμειναν αμετάβλητα. Δεν ήταν δυνατή η εγκατάσταση {0} αρχείου(ων) — κλείστε το Steam (ή χρησιμοποιήστε την Επανεκκίνηση Steam) και δοκιμάστε ξανά. - Προστέθηκε το {0} — lua + {1} manifest. Επανεκκινήστε το Steam για εφαρμογή. - Προστέθηκε το {0} — το Steam θα ανακτήσει τα manifest. Επανεκκινήστε το Steam για εφαρμογή. + Προστέθηκε {0}: lua + {1} μανιφέστα. + Προστέθηκε {0}. Το Steam θα λάβει τα μανιφέστα. Άνοιγμα στο SteamDB ΚΟΙΝΟΧΡΗΣΤΟ Αυτό αντικαθιστά το εγκατεστημένο lua. Ελέγξτε τι αλλάζει: @@ -451,9 +447,9 @@ Διαγραφή αυτής της προρύθμισης; Η «{0}» θα αφαιρεθεί από τις αποθηκευμένες προρυθμίσεις σας. Αυτό δεν επηρεάζει το ίδιο το παιχνίδι. Δεν μπορείτε να διαγράψετε την προρύθμιση που χρησιμοποιείται. - Χρησιμοποιείται πλέον η «{0}». Επανεκκινήστε το Steam για να τεθεί σε ισχύ. + Χρησιμοποιείται τώρα «{0}». Δεν ήταν δυνατή η αλλαγή προρύθμισης — το αρχείο lua μπορεί να χρησιμοποιείται. - Αποθηκεύτηκε. Επανεκκινήστε το Steam για να τεθεί σε ισχύ. + Αποθηκεύτηκε. Δεν ήταν δυνατή η αποθήκευση — το αρχείο lua μπορεί να χρησιμοποιείται. Αποθηκεύτηκε ως προρύθμιση. Αποθήκευση στην «{0}» @@ -527,4 +523,84 @@ Έφτασες το ημερήσιο όριο του 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} καταχωρίσεις από το ιστορικό λήψεων; Τα ληφθέντα αρχεία δεν επηρεάζονται. diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index e4c9cdd..9f24190 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}» @@ -527,4 +523,84 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después 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. diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index fa80ede..f49178d 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}» @@ -527,4 +523,84 @@ Esto borra los archivos .lua de Steam\config\stplug-in. Reinicia Steam después 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. diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index 5b55eee..98f667a 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Tämä poistaa .lua-tiedostot sijainnista Steam\config\stplug-in. Käynnistä St 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. diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 4ac684c..9bd3003 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -208,7 +208,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} @@ -278,11 +277,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} @@ -290,7 +289,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… @@ -307,8 +305,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é @@ -348,13 +345,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 : @@ -451,9 +447,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} » @@ -527,4 +523,84 @@ Cela supprime les fichiers .lua de Steam\config\stplug-in. Redémarrez Steam ens 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. diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index d49b066..184a58e 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Ez törli a .lua fájlokat a Steam\config\stplug-in mappából. Ezután indítsd 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. diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index a54a53a..2bff851 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Ini menghapus file .lua dari Steam\config\stplug-in. Mulai ulang Steam setelahny 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. diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index a3fd938..3d89b23 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -208,7 +208,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} @@ -278,11 +277,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,12 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a Impossibile determinare il gioco base di questo DLC Qualcosa è andato storto — controlla la tua connessione e riprova. Download non riuscito — controlla la tua connessione e riprova. - Generazione non riuscita — controlla la tua connessione e riprova. Sostituire «{0}»? Nessuna modifica a depot/DLC — stesso contenuto. Installazione annullata — i file esistenti rimangono invariati. 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: @@ -451,9 +447,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}» @@ -527,4 +523,84 @@ Questo elimina i file .lua da Steam\config\stplug-in. Riavvia Steam in seguito a 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. diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index d33ec9e..f59f34d 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -208,7 +208,6 @@ {0} 個のマニフェスト {0} をSteamにインストールしました。 {0} 個のファイルが失敗しました — Steamを閉じて再試行してください。 - 適用するにはSteamを再起動してください。 名前またはApp IDで検索... 読み込み中… App ID: {0} @@ -278,11 +277,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} @@ -290,7 +289,6 @@ {1} {0} 個のファイルを削除できませんでした。 Steamを再起動 - 変更を反映するために今すぐSteamを再起動しますか? Steamが見つからないか起動できませんでした。設定で場所を指定してください。 修正 修正を読み込み中… @@ -307,8 +305,7 @@ インストール失敗 インストールできませんでした — Steamを閉じて(またはSteamを再起動して)もう一度お試しください。 修正をインストールしました - {0} のマニフェストをインストールしました — Steamを再起動しています。 - {0} のマニフェストをインストールしました。適用するにはSteamを再起動してください。 + {0} のマニフェストをインストールしました。 ゲームが見つかりません 先に {0} をSteamにインストールしてから、修正を適用してください。 修正が部分的に適用されました @@ -348,13 +345,12 @@ このDLCのベースゲームを特定できませんでした 問題が発生しました — 接続を確認してもう一度お試しください。 ダウンロードに失敗しました — 接続を確認してもう一度お試しください。 - 生成に失敗しました — 接続を確認してもう一度お試しください。 「{0}」を置き換えますか? デポ/DLCの変更なし — 同じ内容です。 インストールをキャンセルしました — 既存のファイルは変更されていません。 {0} 個のファイルをインストールできませんでした — Steamを閉じて(または「Steamを再起動」を使って)もう一度お試しください。 - {0} を追加しました — lua + {1} 個のマニフェスト。適用するにはSteamを再起動してください。 - {0} を追加しました — Steamがマニフェストを取得します。適用するにはSteamを再起動してください。 + {0} を追加しました:lua + マニフェスト {1} 件。 + {0} を追加しました。Steam がマニフェストを取得します。 SteamDBで開く 共有 これは、インストール済みのluaを置き換えます。変更内容を確認してください: @@ -451,9 +447,9 @@ このプリセットを削除しますか? 「{0}」を保存済みのプリセットから削除します。ゲーム自体には影響しません。 使用中のプリセットは削除できません。 - 「{0}」に切り替えました。反映するには Steam を再起動してください。 + 「{0}」を使用中です。 プリセットを切り替えられませんでした — lua ファイルが使用中の可能性があります。 - 保存しました。反映するには Steam を再起動してください。 + 保存しました。 保存できませんでした — lua ファイルが使用中の可能性があります。 プリセットとして保存しました。 「{0}」に保存 @@ -527,4 +523,84 @@ 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} 件すべてを削除しますか?ダウンロード済みのファイルには影響しません。 diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index 52d6586..a2294a5 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -208,7 +208,6 @@ 매니페스트 {0}개 {0} 을(를) Steam에 설치했습니다. {0}개 파일 실패 — Steam을 닫고 다시 시도하세요. - 적용하려면 Steam을 재시작하세요. 이름 또는 App ID로 검색... 로드 중… App ID: {0} @@ -276,13 +275,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} @@ -290,7 +289,6 @@ {1} {0}개의 파일을 삭제할 수 없습니다. Steam 재시작 - 변경 사항을 적용하기 위해 지금 Steam을 재시작할까요? Steam을 찾거나 시작할 수 없습니다. 설정에서 위치를 지정하세요. 수정 수정 로드 중… @@ -307,8 +305,7 @@ 설치 실패 설치할 수 없습니다 — Steam을 닫고(또는 Steam을 재시작하고) 다시 시도하세요. 수정 설치됨 - {0} 매니페스트가 설치되었습니다 — Steam을 재시작하는 중입니다. - {0} 매니페스트가 설치되었습니다. 적용하려면 Steam을 재시작하세요. + {0} 매니페스트가 설치되었습니다. 게임을 찾을 수 없음 먼저 Steam에 {0}을(를) 설치한 후 수정을 적용하세요. 수정이 부분적으로 적용됨 @@ -348,13 +345,12 @@ 이 DLC의 본편 게임을 확인할 수 없습니다 문제가 발생했습니다 — 연결을 확인하고 다시 시도하세요. 다운로드 실패 — 연결을 확인하고 다시 시도하세요. - 생성 실패 — 연결을 확인하고 다시 시도하세요. "{0}"을(를) 교체할까요? 디포/DLC 변경 없음 — 동일한 콘텐츠입니다. 설치가 취소되었습니다 — 기존 파일은 변경되지 않았습니다. {0}개의 파일을 설치할 수 없습니다 — Steam을 닫고(또는 'Steam 재시작'을 사용하고) 다시 시도하세요. - {0} 추가됨 — lua + 매니페스트 {1}개. 적용하려면 Steam을 재시작하세요. - {0} 추가됨 — Steam이 매니페스트를 가져옵니다. 적용하려면 Steam을 재시작하세요. + {0} 추가됨: lua + 매니페스트 {1}개. + {0} 추가됨. Steam이 매니페스트를 가져옵니다. SteamDB에서 열기 공유됨 설치된 lua를 교체합니다. 변경 사항을 검토하세요: @@ -451,9 +447,9 @@ 이 프리셋을 삭제할까요? '{0}'이(가) 저장된 프리셋에서 제거됩니다. 게임 자체에는 영향이 없습니다. 현재 사용 중인 프리셋은 삭제할 수 없습니다. - 이제 '{0}'을(를) 사용합니다. 적용하려면 Steam을 다시 시작하세요. + 이제 “{0}”을(를) 사용합니다. 프리셋을 전환하지 못했습니다 — lua 파일이 사용 중일 수 있습니다. - 저장했습니다. 적용하려면 Steam을 다시 시작하세요. + 저장되었습니다. 저장하지 못했습니다 — lua 파일이 사용 중일 수 있습니다. 프리셋으로 저장했습니다. '{0}'에 저장 @@ -527,4 +523,84 @@ 일일 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}개 항목을 모두 제거할까요? 내려받은 파일은 영향을 받지 않습니다. diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 069929f..5c61f94 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}» @@ -527,4 +523,84 @@ Dette sletter .lua-filene fra Steam\config\stplug-in. Start Steam på nytt etter 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! + + 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. diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index f62ec69..1821279 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}’ @@ -527,4 +523,84 @@ Dit verwijdert de .lua-bestanden uit Steam\config\stplug-in. Herstart Steam daar 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. diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index 3d3bfef..cfd84fa 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -208,7 +208,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} @@ -278,11 +277,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Spowoduje to usunięcie plików .lua z Steam\config\stplug-in. Następnie urucho 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. diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index 6d9ba99..c5801cd 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Isso exclui os arquivos .lua de Steam\config\stplug-in. Reinicie o Steam depois 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. diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index b2c0e88..0835d98 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Isto elimina os ficheiros .lua de Steam\config\stplug-in. Reinicie o Steam depoi 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. diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 453cf27..340b033 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -229,7 +229,6 @@ {0} manifest(s) Installed {0} to Steam. {0} file(s) failed. Close Steam and retry. - Restart Steam to apply. Search by name or App ID... @@ -309,11 +308,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} @@ -321,7 +320,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. @@ -341,8 +339,7 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Install failed 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 @@ -385,13 +382,12 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward Could not resolve the base game for this DLC Something went wrong. Check your connection and try again. Download failed. Check your connection and try again. - Generation failed. Check your connection and try again. Replace "{0}"? No depot/DLC changes. Same content. Install cancelled. Existing files left unchanged. 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. + 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 @@ -482,9 +478,9 @@ 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. + Now using “{0}”. Couldn't switch presets. The lua file may be in use. - Saved. Restart Steam for it to take effect. + Saved. Couldn't save. The lua file may be in use. Saved as a preset. Save to “{0}” @@ -562,4 +558,84 @@ This deletes the .lua files from Steam\config\stplug-in. Restart Steam afterward 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. diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index a5b93c4..5c8d6c8 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -208,7 +208,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} @@ -278,11 +277,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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ă: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Aceasta șterge fișierele .lua din Steam\config\stplug-in. Repornește Steam du 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. diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index 9c2909d..e43eb9c 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -208,7 +208,6 @@ {0} манифест(ов) {0} установлено в Steam. Не удалось обработать {0} файл(ов) — закройте Steam и повторите. - Перезапустите Steam, чтобы применить. Поиск по имени или App ID... Загрузка… App ID: {0} @@ -278,11 +277,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} @@ -290,7 +289,6 @@ {1} Не удалось удалить {0} файлов. Перезапустить Steam - Перезапустить Steam сейчас, чтобы изменения вступили в силу? Не удалось найти или запустить Steam. Укажите его расположение в настройках. Фиксы Загрузка фиксов… @@ -307,8 +305,7 @@ Ошибка установки Не удалось установить — закройте Steam (или перезапустите Steam) и попробуйте снова. Фикс установлен - Манифест {0} установлен — Steam перезапускается. - Манифест {0} установлен. Перезапустите Steam, чтобы применить. + Манифест {0} установлен. Игра не найдена Сначала установите {0} в Steam, затем примените фикс. Фикс применён частично @@ -348,13 +345,12 @@ Не удалось определить базовую игру для этого DLC Что-то пошло не так — проверьте подключение и попробуйте снова. Ошибка загрузки — проверьте подключение и попробуйте снова. - Ошибка генерации — проверьте подключение и попробуйте снова. Заменить «{0}»? Нет изменений депо/DLC — то же содержимое. Установка отменена — существующие файлы не изменены. Не удалось установить {0} файл(ов) — закройте Steam (или используйте «Перезапустить Steam») и попробуйте снова. - {0} добавлено — lua + {1} манифест(ов). Перезапустите Steam, чтобы применить. - {0} добавлено — Steam загрузит манифесты. Перезапустите Steam, чтобы применить. + Добавлено {0}: lua + манифестов: {1}. + Добавлено {0}. Steam загрузит манифесты. Открыть в SteamDB ОБЩИЙ Это заменит установленный lua. Просмотрите, что изменится: @@ -451,9 +447,9 @@ Удалить этот пресет? «{0}» будет удалён из сохранённых пресетов. На саму игру это не влияет. Нельзя удалить пресет, который используется сейчас. - Теперь используется «{0}». Перезапустите Steam, чтобы изменения вступили в силу. + Теперь используется «{0}». Не удалось переключить пресет — файл lua может быть занят. - Сохранено. Перезапустите Steam, чтобы изменения вступили в силу. + Сохранено. Не удалось сохранить — файл lua может быть занят. Сохранено как пресет. Сохранить в «{0}» @@ -527,4 +523,84 @@ Достигнут дневной лимит 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}) из истории загрузок? Загруженные файлы не затрагиваются. diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index b60dc7d..4ea7e47 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Detta tar bort .lua-filerna från Steam\config\stplug-in. Starta om Steam efter 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! + + 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. diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index 62e27cd..f45496a 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -208,7 +208,6 @@ {0} manifest ติดตั้ง {0} ลงใน Steam แล้ว {0} ไฟล์ล้มเหลว — ปิด Steam แล้วลองอีกครั้ง - รีสตาร์ท Steam เพื่อนำไปใช้ ค้นหาด้วยชื่อหรือ App ID... กำลังโหลด… App ID: {0} @@ -276,13 +275,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} @@ -290,7 +289,6 @@ {1} ไม่สามารถลบ {0} ไฟล์ได้ รีสตาร์ท Steam - รีสตาร์ท Steam ตอนนี้เพื่อให้การเปลี่ยนแปลงมีผลหรือไม่? ไม่พบหรือไม่สามารถเปิด Steam ได้ ตั้งค่าตำแหน่งในการตั้งค่า การแก้ไข กำลังโหลดการแก้ไข… @@ -307,8 +305,7 @@ การติดตั้งล้มเหลว ไม่สามารถติดตั้งได้ — ปิด Steam (หรือรีสตาร์ท Steam) แล้วลองอีกครั้ง ติดตั้งการแก้ไขแล้ว - ติดตั้ง manifest ของ {0} แล้ว — Steam กำลังรีสตาร์ท - ติดตั้ง manifest ของ {0} แล้ว รีสตาร์ท Steam เพื่อนำไปใช้ + ติดตั้งแมนิเฟสต์ของ {0} แล้ว ไม่พบเกม ติดตั้ง {0} ใน Steam ก่อน จากนั้นจึงใช้การแก้ไข ใช้การแก้ไขบางส่วนแล้ว @@ -348,13 +345,12 @@ ไม่สามารถระบุเกมหลักของ DLC นี้ได้ มีบางอย่างผิดพลาด — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง การดาวน์โหลดล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง - การสร้างล้มเหลว — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง แทนที่ "{0}" หรือไม่? ไม่มีการเปลี่ยนแปลง depot/DLC — เนื้อหาเหมือนเดิม ยกเลิกการติดตั้งแล้ว — ไฟล์ที่มีอยู่ไม่เปลี่ยนแปลง ไม่สามารถติดตั้ง {0} ไฟล์ได้ — ปิด Steam (หรือใช้ รีสตาร์ท Steam) แล้วลองอีกครั้ง - เพิ่ม {0} แล้ว — lua + {1} manifest รีสตาร์ท Steam เพื่อนำไปใช้ - เพิ่ม {0} แล้ว — Steam จะดึง manifest รีสตาร์ท Steam เพื่อนำไปใช้ + เพิ่ม {0} แล้ว: lua + แมนิเฟสต์ {1} รายการ + เพิ่ม {0} แล้ว Steam จะดึงแมนิเฟสต์ให้ เปิดใน SteamDB ใช้ร่วมกัน การดำเนินการนี้จะแทนที่ lua ที่ติดตั้งไว้ ตรวจสอบสิ่งที่เปลี่ยนแปลง: @@ -451,9 +447,9 @@ ลบพรีเซ็ตนี้หรือไม่? “{0}” จะถูกลบออกจากพรีเซ็ตที่คุณบันทึกไว้ ซึ่งไม่มีผลต่อตัวเกม ไม่สามารถลบพรีเซ็ตที่กำลังใช้งานอยู่ - ตอนนี้ใช้ “{0}” แล้ว รีสตาร์ท Steam เพื่อให้มีผล + กำลังใช้ “{0}” แล้ว สลับพรีเซ็ตไม่สำเร็จ — ไฟล์ lua อาจกำลังถูกใช้งานอยู่ - บันทึกแล้ว รีสตาร์ท Steam เพื่อให้มีผล + บันทึกแล้ว บันทึกไม่สำเร็จ — ไฟล์ lua อาจกำลังถูกใช้งานอยู่ บันทึกเป็นพรีเซ็ตแล้ว บันทึกลงใน “{0}” @@ -527,4 +523,84 @@ คุณใช้ 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} รายการออกจากประวัติการดาวน์โหลดหรือไม่ ไฟล์ที่ดาวน์โหลดแล้วจะไม่ได้รับผลกระทบ diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index dc12e31..bfeb407 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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ı @@ -348,13 +345,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: @@ -451,9 +447,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 @@ -527,4 +523,84 @@ Bu işlem, .lua dosyalarını Steam\config\stplug-in konumundan siler. Değişik 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. diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 74fe43c..8e9a528 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -208,7 +208,6 @@ {0} маніфест(ів) {0} встановлено в Steam. Не вдалося обробити {0} файл(ів) — закрийте Steam і повторіть спробу. - Перезапустіть Steam, щоб застосувати. Пошук за назвою або App ID... Завантаження… App ID: {0} @@ -276,13 +275,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} @@ -290,7 +289,6 @@ {1} Не вдалося видалити {0} файлів. Перезапустити Steam - Перезапустити Steam зараз, щоб зміни набули чинності? Не вдалося знайти або запустити Steam. Укажіть його розташування в Налаштуваннях. Фікси Завантаження фіксів… @@ -307,8 +305,7 @@ Помилка встановлення Не вдалося встановити — закрийте Steam (або перезапустіть Steam) і спробуйте ще раз. Фікс встановлено - Маніфест {0} встановлено — Steam перезапускається. - Маніфест {0} встановлено. Перезапустіть Steam, щоб застосувати. + Маніфест {0} встановлено. Гру не знайдено Спочатку встановіть {0} у Steam, а потім застосуйте фікс. Фікс застосовано частково @@ -348,13 +345,12 @@ Не вдалося визначити базову гру для цього DLC Щось пішло не так — перевірте з'єднання і спробуйте ще раз. Помилка завантаження — перевірте з'єднання і спробуйте ще раз. - Помилка генерування — перевірте з'єднання і спробуйте ще раз. Замінити «{0}»? Немає змін депо/DLC — той самий вміст. Встановлення скасовано — наявні файли не змінено. Не вдалося встановити {0} файл(ів) — закрийте Steam (або скористайтеся «Перезапустити Steam») і спробуйте ще раз. - {0} додано — lua + {1} маніфест(ів). Перезапустіть Steam, щоб застосувати. - {0} додано — Steam завантажить маніфести. Перезапустіть Steam, щоб застосувати. + Додано {0}: lua + маніфестів: {1}. + Додано {0}. Steam завантажить маніфести. Відкрити в SteamDB СПІЛЬНИЙ Це замінить встановлений lua. Перегляньте, що зміниться: @@ -451,9 +447,9 @@ Видалити цей пресет? «{0}» буде вилучено зі збережених пресетів. На саму гру це не впливає. Не можна видалити пресет, який зараз використовується. - Тепер використовується «{0}». Перезапустіть Steam, щоб зміни набули чинності. + Тепер використовується «{0}». Не вдалося перемкнути пресет — файл lua може бути зайнятий. - Збережено. Перезапустіть Steam, щоб зміни набули чинності. + Збережено. Не вдалося зберегти — файл lua може бути зайнятий. Збережено як пресет. Зберегти в «{0}» @@ -527,4 +523,84 @@ Досягнуто денний ліміт 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}) з історії завантажень? Завантажені файли не зачіпаються. diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index 5ddb4cc..e8ce5f5 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -208,7 +208,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} @@ -276,13 +275,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} @@ -290,7 +289,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… @@ -307,8 +305,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 @@ -348,13 +345,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: @@ -451,9 +447,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}” @@ -527,4 +523,84 @@ Thao tác này sẽ xóa các tệp .lua khỏi Steam\config\stplug-in. Sau đó 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. diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index d1c6601..39890c0 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -218,7 +218,6 @@ {0} 个清单 已安装 {0} 到 Steam。 {0} 个文件失败——请关闭 Steam 后重试。 - 重启 Steam 以生效。 按名称或 App ID 搜索… @@ -290,13 +289,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} @@ -304,7 +303,6 @@ {1} 有 {0} 个文件无法删除。 重启 Steam - 立即重启 Steam 以使更改生效? 无法找到或启动 Steam。请在设置中设置其位置。 @@ -323,8 +321,7 @@ 安装失败 无法安装——请关闭 Steam(或使用"重启 Steam")后重试。 修复已安装 - {0} 清单已安装——Steam 正在重启。 - {0} 清单已安装。重启 Steam 以生效。 + 已安装 {0} 的清单。 未找到游戏 请先在 Steam 中安装 {0},然后再应用修复。 修复部分应用 @@ -366,13 +363,12 @@ 无法确定此 DLC 的本体游戏 出了点问题——请检查你的网络连接后重试。 下载失败——请检查你的网络连接后重试。 - 生成失败——请检查你的网络连接后重试。 替换"{0}"? 仓库/DLC 无变化——内容相同。 安装已取消——现有文件保持不变。 无法安装 {0} 个文件——请关闭 Steam(或使用"重启 Steam")后重试。 - 已添加 {0}——lua + {1} 个清单。重启 Steam 以生效。 - 已添加 {0}——Steam 将自动获取清单。重启 Steam 以生效。 + 已添加 {0}:lua + {1} 个清单。 + 已添加 {0}。Steam 将获取清单。 在 SteamDB 中打开 @@ -471,9 +467,9 @@ 删除这个预设? “{0}”将从你保存的预设中移除。这不会影响游戏本身。 无法删除正在使用的预设。 - 已切换到“{0}”。重启 Steam 后生效。 + 现在使用“{0}”。 切换预设失败 — lua 文件可能正被占用。 - 已保存。重启 Steam 后生效。 + 已保存。 保存失败 — lua 文件可能正被占用。 已另存为预设。 保存到“{0}” @@ -547,4 +543,84 @@ 已达到每日 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} 条记录吗?已下载的文件不会受影响。 diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index d8d3234..0d70526 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -208,7 +208,6 @@ {0} 個資訊清單 已安裝 {0} 到 Steam。 {0} 個檔案失敗——請關閉 Steam 後重試。 - 重新啟動 Steam 以套用。 依名稱或 App ID 搜尋... 載入中… App ID:{0} @@ -276,13 +275,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} @@ -290,7 +289,6 @@ {1} 有 {0} 個檔案無法刪除。 重新啟動 Steam - 立即重新啟動 Steam 以使變更生效? 無法找到或啟動 Steam。請在設定中設定其位置。 修正 正在載入修正… @@ -307,8 +305,7 @@ 安裝失敗 無法安裝——請關閉 Steam(或使用「重新啟動 Steam」)後重試。 修正已安裝 - {0} 資訊清單已安裝——Steam 正在重新啟動。 - {0} 資訊清單已安裝。重新啟動 Steam 以套用。 + 已安裝 {0} 的資訊清單。 找不到遊戲 請先在 Steam 中安裝 {0},然後再套用修正。 修正部分套用 @@ -348,13 +345,12 @@ 無法確定此 DLC 的本體遊戲 發生錯誤——請檢查你的連線後重試。 下載失敗——請檢查你的連線後重試。 - 產生失敗——請檢查你的連線後重試。 取代「{0}」? 倉庫/DLC 無變更——內容相同。 安裝已取消——現有檔案保持不變。 無法安裝 {0} 個檔案——請關閉 Steam(或使用「重新啟動 Steam」)後重試。 - 已新增 {0}——lua + {1} 個資訊清單。重新啟動 Steam 以套用。 - 已新增 {0}——Steam 將自動擷取資訊清單。重新啟動 Steam 以套用。 + 已新增 {0}:lua + {1} 個資訊清單。 + 已新增 {0}。Steam 將擷取資訊清單。 在 SteamDB 中開啟 共享 這將取代已安裝的 lua。請檢視變更內容: @@ -451,9 +447,9 @@ 刪除這個預設集? 「{0}」將從你儲存的預設集中移除。這不會影響遊戲本身。 無法刪除正在使用的預設集。 - 已切換到「{0}」。重新啟動 Steam 後生效。 + 現在使用「{0}」。 切換預設集失敗 — lua 檔案可能正被占用。 - 已儲存。重新啟動 Steam 後生效。 + 已儲存。 儲存失敗 — lua 檔案可能正被占用。 已另存為預設集。 儲存到「{0}」 @@ -527,4 +523,84 @@ 已達到每日 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} 筆記錄嗎?已下載的檔案不會受影響。 diff --git a/src/LuaToolsGui/Services/AssetHash.cs b/src/LuaToolsGui/Services/AssetHash.cs new file mode 100644 index 0000000..40be929 --- /dev/null +++ b/src/LuaToolsGui/Services/AssetHash.cs @@ -0,0 +1,40 @@ +using System.IO; +using System.Security.Cryptography; + +namespace LuaToolsGui.Services; + +/// +/// SHA-256 verification of downloaded GitHub release assets. +/// +/// +/// These two helpers existed as byte-identical private statics in both UnlockerService and +/// PluginInstallerService. Every service that downloads an executable and then runs it needs +/// them, so they live in one place rather than being copied a third and fourth time. +/// +internal static class AssetHash +{ + /// Lowercase hex SHA-256 of a file's contents. + public static string OfFile(string path) + { + using var s = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(s)).ToLowerInvariant(); + } + + /// Strip the "sha256:" prefix GitHub puts on asset digests; null if absent. + public static string? ParseDigest(string? digest) + { + if (string.IsNullOrWhiteSpace(digest)) return null; + int colon = digest.IndexOf(':'); + return (colon >= 0 ? digest[(colon + 1)..] : digest).Trim().ToLowerInvariant(); + } + + /// + /// True when the file matches the asset's advertised digest. Also true when the asset advertises no + /// digest at all, so an older release without one is not treated as corrupt. + /// + public static bool Matches(string path, string? assetDigest) + { + if (ParseDigest(assetDigest) is not { } want) return true; // nothing to check against + return OfFile(path).Equals(want, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/LuaToolsGui/Services/CacheService.cs b/src/LuaToolsGui/Services/CacheService.cs index 3fbc686..0b81128 100644 --- a/src/LuaToolsGui/Services/CacheService.cs +++ b/src/LuaToolsGui/Services/CacheService.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Text.Json; namespace LuaToolsGui.Services; @@ -34,6 +34,22 @@ public class CacheData // 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. 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; } } /// @@ -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/DepotDownloaderService.cs b/src/LuaToolsGui/Services/DepotDownloaderService.cs new file mode 100644 index 0000000..4995b3a --- /dev/null +++ b/src/LuaToolsGui/Services/DepotDownloaderService.cs @@ -0,0 +1,637 @@ +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. + private string? CachedManifestPath(long depotId, string manifestId) => + steam.DepotCacheDir is { } dir + ? Path.Combine(dir, $"{depotId}_{manifestId}.manifest") + : null; + + /// + /// Delete a cached manifest that failed validation, so a re-fetch can actually replace it. + /// + /// + /// Mandatory before re-fetching, not a tidy-up: LuaInstaller.InstallManifestFile SKIPS a + /// destination that already exists (deliberately, since the name is content-addressed and Steam may + /// hold the file open). So a corrupt entry would survive the fetch, be handed back, and fail again + /// on every attempt — the download could never recover on its own. + /// + public bool DiscardCachedManifest(long depotId, string manifestId) + { + if (CachedManifestPath(depotId, manifestId) is not { } path) return false; + try + { + if (!File.Exists(path)) return false; + File.Delete(path); + log.LogDebug("Discarded unreadable cached manifest {Path}", path); + return true; + } + catch (Exception ex) + { + log.LogDebug(ex, "Could not discard cached manifest {Path}", path); + return false; // Steam holding it open; the fetch below will fail loudly rather than silently + } + } + + /// Free bytes on the volume that will hold , or null if unknown. + /// + /// 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/DonateKeysService.cs b/src/LuaToolsGui/Services/DonateKeysService.cs index af9c6db..e47bf13 100644 --- a/src/LuaToolsGui/Services/DonateKeysService.cs +++ b/src/LuaToolsGui/Services/DonateKeysService.cs @@ -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)>(); 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..df1ca2d --- /dev/null +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -0,0 +1,653 @@ +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) +{ + // ── 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); + } + + /// 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, 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. + private JobResult ApplyDenuvoFix(DownloadedFile file, long appId, 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); + } + + // Extract into the game folder, overwriting. Best-effort per entry so one locked file + // doesn't abandon the rest of the fix. + using var archive = ZipFile.OpenRead(file.FilePath); + int failed = 0; + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // directory entry + string dest = Path.Combine(installDir, entry.FullName); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + entry.ExtractToFile(dest, overwrite: true); + } + catch { failed++; } + } + + 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); // archive is disposed by now + } + } + + // ── 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/HttpServerService.cs b/src/LuaToolsGui/Services/HttpServerService.cs index e307c0e..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; @@ -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 = Resources.Strings.Err_CancelledByUser; - _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) @@ -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 = Resources.Strings.Err_CancelledByUser; - } - 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 d318360..97d25de 100644 --- a/src/LuaToolsGui/Services/HubcapService.cs +++ b/src/LuaToolsGui/Services/HubcapService.cs @@ -1,10 +1,10 @@ -using System.IO; 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; @@ -25,11 +25,6 @@ public partial class HubcapService 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(); @@ -65,7 +60,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); @@ -80,37 +75,11 @@ public async Task DownloadManifestAsync( }; 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/LuaFileParser.cs b/src/LuaToolsGui/Services/LuaFileParser.cs index 291e6c3..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; @@ -12,7 +12,22 @@ namespace LuaToolsGui.Services; /// 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. /// @@ -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. @@ -90,12 +107,13 @@ 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')) @@ -109,14 +127,23 @@ public static partial class LuaFileParser // so a lua whose pins were commented out by "Auto Update Apps" still looked pinned. var pin = SetManifestRegex().Match(line); if (pin.Success && long.TryParse(pin.Groups[1].Value, out long depot)) + { (commented ? commentedManifests : manifests)[depot] = pin.Groups[2].Value; + // Taken from a commented pin too: the line is disabled as a version LOCK, but the + // size it records is still this depot's size and is otherwise unobtainable offline. + if (pin.Groups[3].Success && long.TryParse(pin.Groups[3].Value, out long sz) && sz > 0) + sizes.TryAdd(depot, sz); + } + // A commented-out addappid is a DISABLED declaration, not an active one. Matched against // the line with its "--" stripped so it can still be recognised and reported separately. var m = AddAppIdRegex().Match(commented ? line.TrimStart('-', ' ') : line); 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 // ('addappid(2784471, …) -- Depot 2784471'). Captured for commented-out lines too, and @@ -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). diff --git a/src/LuaToolsGui/Services/LuaToolsApiClient.cs b/src/LuaToolsGui/Services/LuaToolsApiClient.cs index dc9e31d..68e2b2e 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.Http; using System.Net.Http.Headers; using System.Text.Json; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.Services; @@ -17,11 +17,6 @@ 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), @@ -155,15 +150,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)}"; @@ -193,7 +210,8 @@ public Task GenerateDlcAsync( /// 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, @@ -238,48 +256,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(string.Format(Resources.Strings.Api_Err_DownloadFailed, (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); + return await HttpFileDownloader.SaveResponseAsync(res, fallbackName, progress, ct); } } diff --git a/src/LuaToolsGui/Services/LuaVault.cs b/src/LuaToolsGui/Services/LuaVault.cs index 34937bf..b70bfae 100644 --- a/src/LuaToolsGui/Services/LuaVault.cs +++ b/src/LuaToolsGui/Services/LuaVault.cs @@ -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) @@ -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 []; } diff --git a/src/LuaToolsGui/Services/ManifestFile.cs b/src/LuaToolsGui/Services/ManifestFile.cs new file mode 100644 index 0000000..1bbc3a0 --- /dev/null +++ b/src/LuaToolsGui/Services/ManifestFile.cs @@ -0,0 +1,309 @@ +using System.Buffers.Binary; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace LuaToolsGui.Services; + +/// +/// What a Steam .manifest tells us without contacting Steam: the depot it belongs to, its true +/// uncompressed size, and whether its filenames are encrypted. +/// +/// +/// cb_disk_original — the size the depot occupies once installed. This is the authoritative +/// number: app info's size can be absent entirely (a token-gated app returns no depot list at all), and +/// the manifest is already on disk by the time a download is budgeted. +/// +/// +/// Whether the payload's filenames are still encrypted with the depot key. Usually FALSE — Steam stores +/// them decrypted in config\depotcache — which is exactly why key checking cannot rely on this. +/// +/// +/// The manifest's own id. Together with this is the file's self-declared +/// identity, which lets a cached <depot>_<gid>.manifest be checked against its name +/// rather than trusted because it exists. +/// +public readonly record struct ManifestInfo( + long DepotId, bool FilenamesEncrypted, long SizeOnDisk, ulong GidManifest); + +/// +/// Minimal reader for Steam's depot manifest format. Local, allocation-light, no network and no +/// dependency beyond the BCL. +/// +/// +/// The file is a flat run of [magic:uint32][length:uint32][bytes] sections, optionally +/// wrapped in a zip. Only the metadata section is parsed here, and only three of its fields — this is +/// deliberately not a general protobuf decoder, just enough to answer "how big is this depot" and +/// "can the key be checked against it". +/// +/// Everything fails soft: a malformed or truncated file yields null rather than throwing. Of the +/// 2,298 manifests in one real depotcache, one does not parse, and a single bad file must never take +/// down a download that would otherwise work. +/// +public static class ManifestFile +{ + private const uint PayloadMagic = 0x71F617D0; + private const uint MetadataMagic = 0x1F4812BE; + private const uint EofMagic = 0x32C415AB; + + // ContentManifestMetadata field numbers (see DepotDownloader's manifest.proto). + private const int FieldDepotId = 1; + private const int FieldGidManifest = 2; + private const int FieldFilenamesEncrypted = 4; + private const int FieldSizeOnDisk = 5; + + /// Read a manifest's metadata, or null if the file is missing or unparseable. + /// + /// Seeks over the payload rather than loading the file. The payload holds every file entry and runs + /// to megabytes (3 MB is common), while the metadata this returns is a few dozen bytes — and this is + /// now called once per depot when a picker opens, so reading whole files would be felt. + /// + public static ManifestInfo? TryRead(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; + + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + // A zipped manifest has to be inflated whole; it cannot be seeked through. + Span peek = stackalloc byte[2]; + if (fs.Read(peek) == 2 && peek[0] == 'P' && peek[1] == 'K') + return ParseMetadata(FindSection(Unwrap(File.ReadAllBytes(path)), MetadataMagic)); + fs.Position = 0; + + byte[] header = new byte[8]; + while (fs.Position + 8 <= fs.Length) + { + if (fs.Read(header, 0, 8) != 8) return null; + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header); + uint len = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(4)); + + if (magic == EofMagic) break; + if (len > int.MaxValue || fs.Position + len > fs.Length) return null; // truncated + + if (magic != MetadataMagic) { fs.Position += len; continue; } + + byte[] meta = new byte[len]; + return fs.ReadAtLeast(meta, (int)len, throwOnEndOfStream: false) == (int)len + ? ParseMetadata(meta) + : null; + } + return null; // no metadata section + } + catch { return null; } // unreadable, truncated, or not a manifest at all + } + + private static ManifestInfo? ParseMetadata(byte[]? meta) + { + if (meta is null) return null; + + long depotId = 0, size = 0; + ulong gid = 0; + bool encrypted = false; + + int o = 0; + while (o < meta.Length) + { + if (!ReadTag(meta, ref o, out int field, out int wire)) return null; + if (wire == 0) + { + if (!ReadVarint(meta, ref o, out ulong v)) return null; + switch (field) + { + case FieldDepotId: depotId = (long)v; break; + case FieldGidManifest: gid = v; break; + case FieldFilenamesEncrypted: encrypted = v != 0; break; + case FieldSizeOnDisk: size = (long)v; break; + } + } + else if (!SkipField(meta, ref o, wire)) return null; + } + + return new ManifestInfo(depotId, encrypted, size, gid); + } + + /// + /// True when this file really is the manifest its name claims — it parses, and its own depot id and + /// gid match. Guards against a truncated or half-written cache entry being trusted because it exists. + /// + public static bool Matches(string? path, long depotId, string manifestId) => + TryRead(path) is { } info + && info.DepotId == depotId + && ulong.TryParse(manifestId, out ulong gid) + && info.GidManifest == gid; + + /// + /// Prove a depot key is the right one, when the manifest allows it. + /// + /// + /// True if a filename decrypted cleanly. Also true when the manifest's filenames are not + /// encrypted — there is nothing to test, so this reports "no objection", not "verified". + /// + /// + /// Only ~1.5% of cached manifests still carry encrypted filenames, so this is an opportunistic + /// extra check on top of "is a key present at all", never a replacement for it. Treating a + /// not-encrypted manifest as a pass is the only honest option: reporting failure there would reject + /// every depot, and claiming verification would be a lie. + /// + public static bool KeyLooksValid(string? path, byte[] key) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path) || key.Length != 32) return true; + + // Cheap metadata pass first. There is nothing to test unless the filenames are still encrypted, + // which is the small minority — this keeps the multi-MB payload read off the other ~98%. + if (TryRead(path) is not { FilenamesEncrypted: true }) return true; + + try + { + byte[] data = Unwrap(File.ReadAllBytes(path)); + if (FindSection(data, PayloadMagic) is not { } payload) return true; + if (FirstFilename(payload) is not { } name) return true; + + // Base64 only while encrypted; a decrypted name is raw UTF-8 and won't round-trip. + byte[] cipher; + try { cipher = Convert.FromBase64String(name); } + catch (FormatException) { return true; } + if (cipher.Length <= 16 || cipher.Length % 16 != 0) return true; + + return TryDecryptName(cipher, key); + } + catch { return true; } // never block a download on this check failing to run + } + + /// + /// Steam's filename cipher: the leading 16 bytes are an IV encrypted with AES-ECB under the depot + /// key, and the remainder is AES-CBC under that IV. A wrong key fails the PKCS7 unpad. + /// + private static bool TryDecryptName(byte[] cipher, byte[] key) + { + using var ecb = Aes.Create(); + ecb.Key = key; + ecb.Mode = CipherMode.ECB; + ecb.Padding = PaddingMode.None; + byte[] iv = ecb.CreateDecryptor().TransformFinalBlock(cipher, 0, 16); + + using var cbc = Aes.Create(); + cbc.Key = key; + cbc.IV = iv; + cbc.Mode = CipherMode.CBC; + cbc.Padding = PaddingMode.PKCS7; + + try + { + byte[] plain = cbc.CreateDecryptor().TransformFinalBlock(cipher, 16, cipher.Length - 16); + // A correct key yields a printable path; a wrong one that happens to unpad yields control bytes. + foreach (byte b in plain) + if (b < 0x20 && b != 0) return false; + return true; + } + catch (CryptographicException) { return false; } // bad padding = wrong key + } + + /// A manifest may be zipped; if so the single entry inside is the real thing. + private static byte[] Unwrap(byte[] data) + { + if (data.Length < 2 || data[0] != 'P' || data[1] != 'K') return data; + + using var zip = new ZipArchive(new MemoryStream(data), ZipArchiveMode.Read); + var entry = zip.Entries.FirstOrDefault(); + if (entry is null) return data; + + using var s = entry.Open(); + using var buf = new MemoryStream(); + s.CopyTo(buf); + return buf.ToArray(); + } + + /// Walk the section table and return the first section with this magic. + private static byte[]? FindSection(byte[] data, uint magic) + { + int o = 0; + while (o + 8 <= data.Length) + { + uint m = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(o)); + int len = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(o + 4)); + o += 8; + + if (m == EofMagic) break; + if (len < 0 || o + len > data.Length) break; // truncated + + if (m == magic) return data[o..(o + len)]; + o += len; + } + return null; + } + + /// The first FileMapping's filename, as stored (base64 while encrypted). + private static string? FirstFilename(byte[] payload) + { + int o = 0; + while (o < payload.Length) + { + if (!ReadTag(payload, ref o, out int field, out int wire)) return null; + + if (field == 1 && wire == 2) // repeated FileMapping + { + if (!ReadVarint(payload, ref o, out ulong len)) return null; + int end = o + (int)len; + if (end > payload.Length) return null; + + int inner = o; + while (inner < end) + { + if (!ReadTag(payload, ref inner, out int f2, out int w2)) return null; + if (f2 == 1 && w2 == 2) // filename + { + if (!ReadVarint(payload, ref inner, out ulong n)) return null; + if (inner + (int)n > payload.Length) return null; + return Encoding.UTF8.GetString(payload, inner, (int)n); + } + if (!SkipField(payload, ref inner, w2)) return null; + } + o = end; + } + else if (!SkipField(payload, ref o, wire)) return null; + } + return null; + } + + private static bool ReadTag(byte[] d, ref int o, out int field, out int wire) + { + field = wire = 0; + if (!ReadVarint(d, ref o, out ulong tag)) return false; + field = (int)(tag >> 3); + wire = (int)(tag & 0x07); + return true; + } + + private static bool ReadVarint(byte[] d, ref int o, out ulong value) + { + value = 0; + int shift = 0; + while (o < d.Length) + { + byte b = d[o++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) return true; + shift += 7; + if (shift > 63) return false; // malformed + } + return false; // ran off the end + } + + private static bool SkipField(byte[] d, ref int o, int wire) + { + switch (wire) + { + case 0: return ReadVarint(d, ref o, out _); + case 1: o += 8; return o <= d.Length; + case 5: o += 4; return o <= d.Length; + case 2: + if (!ReadVarint(d, ref o, out ulong len)) return false; + o += (int)len; + return o <= d.Length; + default: return false; // groups: not used by this format + } + } +} diff --git a/src/LuaToolsGui/Services/PluginAddService.cs b/src/LuaToolsGui/Services/PluginAddService.cs index 817fcd7..e19a04a 100644 --- a/src/LuaToolsGui/Services/PluginAddService.cs +++ b/src/LuaToolsGui/Services/PluginAddService.cs @@ -1,6 +1,7 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.IO; using LuaToolsGui.Models; +using LuaToolsGui.Services.Downloads; namespace LuaToolsGui.Services; @@ -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 @@ -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 4d3d6fc..944fcde 100644 --- a/src/LuaToolsGui/Services/PluginInstallerService.cs +++ b/src/LuaToolsGui/Services/PluginInstallerService.cs @@ -1,8 +1,7 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net.Http; -using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; @@ -287,7 +286,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT bool dllMatches = Slots.All(slot => SlotPath(slot) is { } p && File.Exists(p) && AssetDigest(latest, slot.DllAsset) is { } digest && - Sha256OfFile(p) == digest); + AssetHash.OfFile(p) == digest); bool installed = frontend && loader; // `|| legacy` keeps a leftover/locked legacy dll getting swept on subsequent auto-updates until gone. bool updateAvailable = installed && (manifest?.Tag != latest.TagName || !dllMatches || legacy); @@ -331,13 +330,13 @@ public async Task GetStatusAsync(bool force = false, CancellationT } // Verify each against its release asset digest before touching anything on disk. - string zipSha = Sha256OfFile(zipPath); + string zipSha = AssetHash.OfFile(zipPath); if (AssetDigest(latest, PluginZipAsset) is { } zd && zipSha != zd) return (false, string.Format(Resources.Strings.Plugin_Err_VerifyFailed, PluginZipAsset)); var slotShas = new Dictionary(); foreach (var (slot, p) in slotDlPaths) { - string sha = Sha256OfFile(p); + string sha = AssetHash.OfFile(p); slotShas[slot] = sha; if (AssetDigest(latest, slot.DllAsset) is { } dd && sha != dd) return (false, string.Format(Resources.Strings.Plugin_Err_VerifyFailed, slot.DllAsset)); @@ -366,7 +365,7 @@ public async Task GetStatusAsync(bool force = false, CancellationT // (so hand-placed test builds aren't clobbered), and thus never stop/restart Steam for it either. bool legacyPresent = LegacyDllPaths.Any(File.Exists); bool anySlotNeedsUpdate = Slots.Any(slot => - SlotPath(slot) is not { } cur || !File.Exists(cur) || Sha256OfFile(cur) != slotShas[slot]); + SlotPath(slot) is not { } cur || !File.Exists(cur) || AssetHash.OfFile(cur) != slotShas[slot]); bool dllNeedsUpdate = !DllUpdateDisabled && (anySlotNeedsUpdate || legacyPresent); if (dllNeedsUpdate) { @@ -623,21 +622,9 @@ private static void NormalizeFrontendLayout() // ── Helpers (same shape as UnlockerService's) ── private static string? AssetDigest(GithubRelease r, string name) => - ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Digest); + AssetHash.ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Digest); private static GithubAsset? FindAsset(GithubRelease r, string name) => r.Assets.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - private static string Sha256OfFile(string path) - { - using var s = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(s)).ToLowerInvariant(); - } - - private static string? ParseDigest(string? digest) - { - if (string.IsNullOrWhiteSpace(digest)) return null; - int colon = digest.IndexOf(':'); - return (colon >= 0 ? digest[(colon + 1)..] : digest).Trim().ToLowerInvariant(); - } } diff --git a/src/LuaToolsGui/Services/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 50f67e4..3b06336 100644 --- a/src/LuaToolsGui/Services/SteamDepotInfo.cs +++ b/src/LuaToolsGui/Services/SteamDepotInfo.cs @@ -14,6 +14,18 @@ public record ContentDepot(long Id, long Size, long? DlcAppId, bool IsShared, st 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; } } /// @@ -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 }); } } diff --git a/src/LuaToolsGui/Services/SteamService.cs b/src/LuaToolsGui/Services/SteamService.cs index 58fbec7..fcd0ba5 100644 --- a/src/LuaToolsGui/Services/SteamService.cs +++ b/src/LuaToolsGui/Services/SteamService.cs @@ -33,6 +33,38 @@ public string? EffectivePath public bool IsOverridden => !string.IsNullOrWhiteSpace(settings.SteamPathOverride); + /// + /// The Steam client's UI language ("english", "schinese", "brazilian", ...), or null if unreadable. + /// + /// + /// This is the same vocabulary a depot's config.language uses — verified against steamcmd's + /// app info, where the registry's "english" matches the depot value verbatim, and against the + /// baselanguages list ("english,german,french,..."). So it can be compared to depot languages + /// directly, with no mapping table. + /// + /// Read fresh each time rather than cached: a user can change Steam's language without restarting + /// this app, and the read is a single registry lookup. + /// + /// Lowercased on the way out — the depot values are lowercase, and relying on every future call site + /// to remember OrdinalIgnoreCase is the kind of thing that breaks once and is never noticed. + /// + public static string? SteamLanguage + { + get + { + try + { + using var key = RegistryKey + .OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) + .OpenSubKey(@"SOFTWARE\Valve\Steam"); + return key?.GetValue("Language") is string s && s.Length > 0 + ? s.Trim().ToLowerInvariant() + : null; + } + catch { return null; } // no Steam, or the value is missing/unreadable + } + } + /// True when the effective path exists and contains steam.exe. public bool IsValid => EffectivePath is not null && File.Exists(SteamExePathFor(EffectivePath)); @@ -54,6 +86,45 @@ public static void OpenUrl(string url) => public static void RevealInExplorer(string filePath) => Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{filePath}\"") { UseShellExecute = true }); + /// + /// Show a path in Explorer, picking the right gesture for what it is: a file gets selected inside + /// its folder, a folder is opened. Returns false when the path is missing or Explorer refuses. + /// + /// + /// always passes /select, which for a directory highlights it + /// in its PARENT rather than opening it — wrong for the depot output folder and a game's install + /// directory, which are the two most common targets. Callers hand over whichever they have and let + /// this sort it out, so no call site has to probe the filesystem itself. + /// + public static bool ShowInExplorer(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + try + { + if (File.Exists(path)) { RevealInExplorer(path); return true; } + if (Directory.Exists(path)) + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + return true; + } + return false; // deleted since the row was created + } + catch { return false; } // no shell association, or Explorer is wedged + } + + /// Put text on the clipboard. Returns false instead of throwing when it can't. + /// + /// Clipboard.SetText throws CLIPBRD_E_CANT_OPEN when another process is holding the + /// clipboard open — common with remote-desktop and clipboard-manager tools, and entirely outside our + /// control. Copying an app id is never worth an unhandled exception, so failure is reported, not thrown. + /// + public static bool CopyToClipboard(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + try { System.Windows.Clipboard.SetText(text); return true; } + catch { return false; } + } + /// Kill any running steam.exe (and its tree) and wait for it to exit. Safe to call when /// Steam isn't running. Use before changing Steam's files so they aren't locked. /// True while a Steam client process is running. Appinfo.vdf can't be edited under it. diff --git a/src/LuaToolsGui/Services/SteamlessService.cs b/src/LuaToolsGui/Services/SteamlessService.cs index eb6bbf5..90a81c1 100644 --- a/src/LuaToolsGui/Services/SteamlessService.cs +++ b/src/LuaToolsGui/Services/SteamlessService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Text.Json; @@ -20,7 +20,7 @@ public record SteamlessResult(int Patched, int Unchanged, int Total, string? Err /// filtered recursive scan of the install folder. Each patched exe is backed up to <exe>.bak /// first. Steamless silently no-ops on exes without DRM, so over-selection is harmless. /// -public class SteamlessService(GithubProxy gh, SteamLibraryService library, SteamDepotInfo depots) +public class SteamlessService(GithubProxy gh, SteamLibraryService library, SteamDepotInfo depots, CacheService cache) { private static readonly string ToolDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "steamless"); @@ -38,38 +38,83 @@ public class SteamlessService(GithubProxy gh, SteamLibraryService library, Steam private readonly SemaphoreSlim _toolGate = new(1, 1); - /// Ensure Steamless.CLI.exe is on disk (downloads + extracts once). Returns its path, or null - /// if the tool couldn't be obtained. + /// How long an up-to-date check is trusted before we ask GitHub again. + private static readonly TimeSpan ToolCheckInterval = TimeSpan.FromHours(6); + + private static bool CheckedRecently(long lastMs) => + lastMs > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastMs < (long)ToolCheckInterval.TotalMilliseconds; + + /// + /// Ensure Steamless.CLI.exe is on disk and reasonably current. Null only if no usable tool exists. + /// + /// + /// Was a bare File.Exists, which pinned the first download forever. Now the installed release + /// tag is recorded and re-checked at most every . + /// + /// A failed check never disables a working tool — every failure path falls back to an + /// existing CliPath, so being offline can't break DRM removal for someone who already has it. + /// public async Task EnsureToolAsync(IProgress? progress, CancellationToken ct = default) { - if (File.Exists(CliPath)) return CliPath; + if (File.Exists(CliPath) && CheckedRecently(cache.SteamlessCheckedAtMs)) return CliPath; await _toolGate.WaitAsync(ct); + bool have = false; try { - if (File.Exists(CliPath)) return CliPath; // won the race elsewhere + have = File.Exists(CliPath); + if (have && CheckedRecently(cache.SteamlessCheckedAtMs)) return CliPath; // won the race + + // A failed lookup still counts as "we looked", so an offline run backs off instead of + // retrying the whole GithubProxy mirror chain on the next call. + void RecordAttempt() => + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Latest release → the single distributable .zip asset. string url = $"https://api.github.com/repos/{AppConfig.SteamlessRepo}/releases/latest"; using var res = await gh.SendAsync(url, ct); - if (res is null || !res.IsSuccessStatusCode) return null; + if (res is null || !res.IsSuccessStatusCode) { if (have) RecordAttempt(); return have ? CliPath : null; } var release = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync(ct), JsonOpts); var asset = release?.Assets.FirstOrDefault(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); - if (asset is null) return null; + if (asset is null) { if (have) RecordAttempt(); return have ? CliPath : null; } + + // Already on the published build: record that we looked and skip the download. + if (have && !string.IsNullOrEmpty(release!.TagName) + && string.Equals(release.TagName, cache.SteamlessVersion, StringComparison.Ordinal)) + { + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return CliPath; + } Directory.CreateDirectory(ToolDir); string zipPath = Path.Combine(ToolDir, "steamless.zip"); await gh.DownloadAsync(asset.DownloadUrl, zipPath, progress, ct); + // Verify before overwriting a working install: this is an executable we then run. + if (!AssetHash.Matches(zipPath, asset.Digest)) + { + try { File.Delete(zipPath); } catch { } + if (have) RecordAttempt(); + return have ? CliPath : null; + } + // Extract the WHOLE zip: the CLI needs its plugin DLLs alongside it. ZipFile.ExtractToDirectory(zipPath, ToolDir, overwriteFiles: true); try { File.Delete(zipPath); } catch { /* leftover zip is harmless */ } - return File.Exists(CliPath) ? CliPath : null; + if (!File.Exists(CliPath)) { if (have) RecordAttempt(); return have ? CliPath : null; } + + cache.SteamlessVersion = release!.TagName; + cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return CliPath; } catch (OperationCanceledException) { throw; } - catch { return null; } + catch + { + if (have) cache.SteamlessCheckedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return have ? CliPath : null; + } finally { _toolGate.Release(); } } diff --git a/src/LuaToolsGui/Services/UnlockerService.cs b/src/LuaToolsGui/Services/UnlockerService.cs index 1c0b334..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; @@ -75,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. @@ -121,7 +141,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) { string local = Path.Combine(root, manifest.File); if (!File.Exists(local)) return ModeStatus.NotInstalled; - return Sha256OfFile(local).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase) + return AssetHash.OfFile(local).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase) ? ModeStatus.UpToDate : ModeStatus.UpdateAvailable; } @@ -138,7 +158,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) string ostDll = Path.Combine(root, "OpenSteamTool.dll"); if (nightly is not null && File.Exists(ostDll) - && AssetDigest(nightly, "OpenSteamTool.dll") == Sha256OfFile(ostDll)) + && AssetDigest(nightly, "OpenSteamTool.dll") == AssetHash.OfFile(ostDll)) return (ModeStatus.UpToDate, nightly.TagName); // Not the current nightly. Fall back to the stable mirror to tell "on stable OST" apart from @@ -175,7 +195,7 @@ private static ModeStatus ManifestStatus(UpdateManifest manifest, string root) if (ost.Count == 0) return (ModeStatus.Unknown, null); var latest = ost[0]; - string dwmHash = Sha256OfFile(dwmapi); + string dwmHash = AssetHash.OfFile(dwmapi); if (AssetDigest(latest, "dwmapi.dll") == dwmHash) return (ModeStatus.UpToDate, latest.TagName); // Matches an older ost- release, or is present but unrecognized → an update exists. @@ -248,13 +268,13 @@ public async Task InstallAsync( if (asset is null) return ModeInstallResult.Fail(Resources.Strings.Err_ReleaseMissingDownload); zipName = asset.Name; zipUrl = asset.DownloadUrl; - wantedZipDigest = ParseDigest(asset.Digest); + wantedZipDigest = AssetHash.ParseDigest(asset.Digest); } string zipPath = Path.Combine(staging, zipName); await DownloadToFileAsync(zipUrl, zipPath, progress, ct); - zipDigest = Sha256OfFile(zipPath); + zipDigest = AssetHash.OfFile(zipPath); if (wantedZipDigest is { } want && !zipDigest.Equals(want, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(Resources.Strings.Err_VerifyFailed); @@ -266,7 +286,7 @@ public async Task InstallAsync( // Manifest modes don't publish a zip digest, so verify the payload file the manifest // DOES vouch for, once it's out of the archive. if (manifest is not null && staged.TryGetValue(manifest.File, out string? payload) - && !Sha256OfFile(payload).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) + && !AssetHash.OfFile(payload).Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, manifest.File)); } @@ -344,7 +364,7 @@ public async Task InstallAsync( string ostDll = Path.Combine(root, "OpenSteamTool.dll"); if (File.Exists(ostDll)) { - string ostHash = Sha256OfFile(ostDll); + string ostHash = AssetHash.OfFile(ostDll); var bstManifest = await FetchUpdateManifestAsync(Def(UnlockerMode.Bst), forceRefresh: false, ct); if (bstManifest is not null @@ -374,8 +394,8 @@ public async Task InstallAsync( .ToList(); if (tagged is { Count: > 0 }) { - string dwmHash = Sha256OfFile(dwmapi); - string xinHash = Sha256OfFile(xinput); + string dwmHash = AssetHash.OfFile(dwmapi); + string xinHash = AssetHash.OfFile(xinput); if (tagged.Any(r => AssetDigest(r, "dwmapi.dll") == dwmHash) && tagged.Any(r => AssetDigest(r, "xinput1_4.dll") == xinHash)) detected = UnlockerMode.Ost; @@ -389,7 +409,7 @@ public async Task InstallAsync( /// Digest (hex, no prefix) of a release's same-named asset, or null if absent. private static string? AssetDigest(GithubRelease r, string assetName) => - ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase))?.Digest); + AssetHash.ParseDigest(r.Assets.FirstOrDefault(a => a.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase))?.Digest); /// The same-named asset, or null if this release doesn't have it. private static GithubAsset? FindAsset(GithubRelease r, string assetName) => @@ -566,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; } } @@ -624,7 +644,7 @@ private async Task DownloadCloudRedirectDllAsync(string root, Directory.CreateDirectory(staging); string tmp = Path.Combine(staging, CloudRedirectDll); await DownloadToFileAsync(asset.DownloadUrl, tmp, progress, ct); - if (ParseDigest(asset.Digest) is { } want && !Sha256OfFile(tmp).Equals(want, StringComparison.OrdinalIgnoreCase)) + if (AssetHash.ParseDigest(asset.Digest) is { } want && !AssetHash.OfFile(tmp).Equals(want, StringComparison.OrdinalIgnoreCase)) return ModeInstallResult.Fail(string.Format(Resources.Strings.Err_VerifyFailedFile, CloudRedirectDll)); try @@ -839,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/ViewModels/BuildsViewModel.cs b/src/LuaToolsGui/ViewModels/BuildsViewModel.cs index 0d86971..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; @@ -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, @@ -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; @@ -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: + // 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 - // 3. vaulted: stored builds whose live lua has since been deleted + // + // 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 => @@ -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] @@ -641,7 +682,6 @@ private void SaveEdit() IsEditing = false; SaveInPlace(); - OfferRestartSteam(); } /// @@ -715,6 +755,379 @@ 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; @@ -797,6 +1210,18 @@ partial void OnUnknownChanged(IReadOnlyList value) [RelayCommand] private static void OpenSteamDb(DepotRow row) => SteamService.OpenUrl(row.SteamDbUrl); + /// + /// Copy the GAME's app id. Takes no row: a carries a depot id, a DLC app id + /// and an owning-app id, but never the app id of the game whose page this is — that is page state. + /// + [RelayCommand] + private void CopyAppId() + { + if (ActiveGame is not { } game) return; + if (!SteamService.CopyToClipboard(game.AppId.ToString())) + _toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } + /// Pin/unpin one depot. Comments its setManifestid line in or out. [RelayCommand] private void ToggleLock(DepotRow row) @@ -842,9 +1267,9 @@ private void ToggleEnabled(DepotRow row) /// 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) @@ -1005,6 +1430,88 @@ private async Task LoadDepotsAsync(bool quiet = false) /// included or a depot the user just switched off would drop out of "In lua" and take its switch /// with it. The row has to stay put so it can be switched back on. /// + /// + /// Add depots the lua declares that Steam's app info never mentioned, so they still appear (and stay + /// downloadable) instead of silently vanishing. + /// + /// + /// Some apps require a Steam access token this app doesn't hold. steamcmd answers those with + /// "_missing_token": true and a null depots block — no depot list at all, not an empty + /// one. Risk of Rain 2, NBA 2K27, Touhou Luna Nights, Soundpad and Beam Eye Tracker all behave this + /// way. Without this the whole page came up blank with nothing explaining why, because "In lua" is + /// the intersection of the declarations and a depot list that was never delivered. + /// + /// Nothing is lost by falling back: the lua already carries every input a download needs — the + /// depot id, its decryption key, and a manifest id (usually a commented pin). Only size is + /// missing, so these rows show no MB and contribute nothing to the free-space estimate. + /// + /// Applied per id rather than only when the whole fetch came back empty, so it equally covers a + /// single depot the app info happens to omit. + /// + private static void AddDeclaredButUnlisted( + List items, Dictionary declared, long baseAppId, + HashSet depotDlcIds) + { + var known = items.Select(d => d.Id).ToHashSet(); + + foreach (var (id, entry) in declared) + { + // Already covered, either as a real depot or as the DLC app id behind one. + if (known.Contains(id) || depotDlcIds.Contains(id)) continue; + + // The base app's own addappid line is the app key, not a depot — downloading it is meaningless. + if (id == baseAppId) continue; + + // A key means content; without one it's a store entitlement, which is what DlcAppId marks. + bool isDepot = entry.HasKey; + + // Shared redistributables are only identifiable from the lua's comment here, and it matters: + // FromAppId is what keeps them off the default selection (they're usually already installed). + long? fromAppId = isDepot ? SharedOwnerFromComment(entry.Comment) : null; + + // The lua's own setManifestid size, when it has one: without it these rows report 0 bytes, + // which understates the download total and leaves the free-space warning blind. + items.Add(new ContentDepot(id, entry.SizeOnDisk ?? 0, isDepot ? null : id, + fromAppId is not null, + Os: null, Language: isDepot ? LanguageFromComment(entry.Comment) : null) + { FromAppId = fromAppId }); + } + } + + /// + /// Steam's language codes, as they appear in a depot's config.language. Used to recognise a + /// synthesized depot's language from its lua comment, which is the only clue available when Steam + /// withheld the app info — without it a "Schinese" depot reads as language-neutral and is selected + /// for everyone. + /// + private static readonly HashSet SteamLanguageCodes = new(StringComparer.OrdinalIgnoreCase) + { + "arabic", "bulgarian", "schinese", "tchinese", "czech", "danish", "dutch", "english", "finnish", + "french", "german", "greek", "hungarian", "indonesian", "italian", "japanese", "koreana", + "norwegian", "polish", "portuguese", "brazilian", "romanian", "russian", "spanish", "latam", + "swedish", "thai", "turkish", "ukrainian", "vietnamese", + }; + + /// The Steam language code a lua comment names, or null if it names something else. + private static string? LanguageFromComment(string? comment) + { + if (string.IsNullOrWhiteSpace(comment)) return null; + foreach (string word in comment.Split([' ', ' ', '(', ')', ',', '-', ':'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (SteamLanguageCodes.TryGetValue(word, out string? code)) return code.ToLowerInvariant(); + return null; + } + + /// Owning app id from a lua comment like "VC 2019 Redist (Shared from App 228980)". + private static long? SharedOwnerFromComment(string? comment) => + comment is not null && SharedFromAppRegex().Match(comment) is { Success: true } m + && long.TryParse(m.Groups[1].Value, out long owner) + ? owner + : null; + + [GeneratedRegex(@"Shared\s+from\s+App\s+(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex SharedFromAppRegex(); + private static Dictionary Declarations(LuaContents? lua) { var all = new Dictionary(); @@ -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); @@ -1082,7 +1591,10 @@ DepotRow Row(ContentDepot d) IsEnabled: active.Contains(declId), CanToggle: inLua, // anything the lua declares can be switched, in any variant IsBaseApp: declId == baseAppId) - { ToggleId = declId }; + { + 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 @@ -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 805f590..1e6e4ec 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; @@ -34,11 +35,16 @@ public partial class SourceRowViewModel : ObservableObject [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; + + // 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; public SourceRowViewModel(DownloadViewModel parent, string name, string status) { @@ -52,7 +58,7 @@ public SourceRowViewModel(DownloadViewModel parent, string name, string status) } [RelayCommand] - private Task DownloadAsync() => _parent.DownloadFromSourceAsync(this); + private async Task DownloadAsync() => await _parent.DownloadFromSourceAsync(this); [RelayCommand] private void OpenDiscord() @@ -81,6 +87,8 @@ 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 CancellationTokenSource? _searchCts; private CancellationTokenSource? _detailsCts; @@ -149,8 +157,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 +183,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 +208,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,6 +224,10 @@ 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 + @@ -279,8 +297,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; @@ -295,7 +323,8 @@ public Task DownloadSourceByNameAsync(string name) 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) { _api = api; _hubcap = hubcap; @@ -307,6 +336,8 @@ public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsSe _appInfo = appInfo; _depotInfo = depotInfo; _hardware = hardware; + _queue = queue; + _jobs = jobs; Drop = drop; _fastFetch = settings.FastFetch; } @@ -620,220 +651,192 @@ 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. bool hubcapWithKey = source.NeedsKey && !string.IsNullOrEmpty(_settings.HubcapApiKey); - if (!hubcapWithKey && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return; + if (!hubcapWithKey && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; + Error = null; LastDownload = null; InstallStatus = null; - 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; - 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) + // 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; + string gameName = Details.Name; + bool needsKey = source.NeedsKey; - // 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 job = _jobs.CreateManifestJob( + appId, gameName, source.Name, needsKey, + // Silent/headless installs have no surfaced window to confirm on, so they skip the gate. + confirm: _silentInstall ? null : (file, _, ct) => ConfirmOverwriteAsync(file, appId, gameName, ct), + onFinished: (item, result) => OnManifestFinished(item, result, needsKey), + onReveal: () => NavigateToGame?.Invoke(appId)); - InstallZipAndReport(download.FilePath, appId); - } - catch (ApiException ex) - { - Error = ex.Message; - } - catch (Exception) - { - Error = Resources.Strings.Add_Err_Download; - } - finally - { - source.IsDownloading = false; - source.Progress = 0; - source.IsProgressIndeterminate = false; - } + var queued = _queue.Enqueue(job); + source.QueueItem = queued; + _lastEnqueued = queued; + return queued; } /// 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 +915,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 => 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 e8e7537..6b3de4a 100644 --- a/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DropInstallViewModel.cs @@ -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 c16be97..47c6ee3 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,32 @@ 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; + + public bool CanDownloadManifest => HasManifest && ManifestItem?.IsActive != true; + public bool CanDownloadFix => HasFix && GameInstalled && FixItem?.IsActive != true; + + /// Why the Fix button is greyed out, or null when it isn't. A null ToolTip shows nothing, + /// so this doubles as the "should there be a tooltip at all" test. + public string? FixHint => GameInstalled ? null : Resources.Strings.Fixes_NotInstalled_Hint; + private static string FormatDate(string? iso) => DateTimeOffset.TryParse(iso, out var d) ? d.UtcDateTime.ToString("d MMM yyyy") : ""; } @@ -74,25 +101,26 @@ 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; 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) { 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; InitPageSize(settings.FixesPageSize); } @@ -136,13 +164,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 ───────────────────────────────────────────────────────── @@ -229,6 +252,32 @@ public async Task OpenForAppIdAsync(long appId) await OpenGame(game); } + [RelayCommand] + private void CopyAppId(FixGameCardVm game) + { + if (!SteamService.CopyToClipboard(game.AppId)) + toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } + + /// + /// Open the game's Steam install folder — where ApplyDenuvoFix extracts a fix to. + /// + /// + /// Resolved on click, not bound to a property: GetInstallDir walks libraryfolders.vdf and the + /// appmanifest files, which is far too much work to repeat for every card in a grid on every render. + /// The cost of that is the action being offered for games that aren't installed, so it reports the + /// same "game not found" toast the fix flow already uses rather than failing silently. + /// + [RelayCommand] + private void ShowInFolder(FixGameCardVm game) + { + string? dir = long.TryParse(game.AppId, out long appId) ? library.GetInstallDir(appId) : null; + if (SteamService.ShowInExplorer(dir)) return; + + toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, + string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, game.Name), error: true); + } + [RelayCommand] private async Task OpenGame(FixGameCardVm game) { @@ -246,6 +295,12 @@ private async Task OpenGame(FixGameCardVm game) { _allFixes = data.Fixes.Select(f => new FixItemVm(f)).ToList(); + // Is the game on disk? GetInstallDir walks libraryfolders.vdf + appmanifest_*.acf, so + // it's file I/O — off the UI thread. Resolved once here rather than per fix row. + bool installed = long.TryParse(game.AppId, out long gameAppId) + && await Task.Run(() => library.GetInstallDir(gameAppId) is not null); + foreach (var f in _allFixes) f.GameInstalled = installed; + // 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) @@ -289,120 +344,43 @@ private void ApplyFixFilter() [RelayCommand] private Task DownloadFix(FixItemVm fix) => RunDownload(fix, "fix"); + /// + /// 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) { - 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 + // 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) { - 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); - - 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; - } - } - - /// Manifest slot: install into Steam force-LOCKED (Denuvo fixes must stay version-pinned). - private void InstallManifest(DownloadedFile file, long appId, string gameName) - { - 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 - - if (result.AnyFailed) - { - toast.Show(Resources.Strings.Fixes_Toast_InstallFailed, - result.Error ?? Resources.Strings.Fixes_Toast_InstallFailed_Body, - error: true); + toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, + string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, game.Name), error: true); return; } - 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)); - } + string fallback = slot == "manifest" + ? fix.ManifestFilename ?? $"{game.AppId}.zip" + : fix.FixFilename ?? $"{game.AppId}_fix.zip"; - /// Fix slot: only applicable if the game is installed. Extract the zip into its folder. - private void ApplyFix(DownloadedFile file, long appId, string gameName) - { - string? installDir = library.GetInstallDir(appId); - if (installDir is null) - { - toast.Show(Resources.Strings.Fixes_Toast_GameNotFound, string.Format(Resources.Strings.Fixes_Toast_GameNotFound_Body, gameName), 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) + 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 - { - Directory.CreateDirectory(Path.GetDirectoryName(dest)!); - entry.ExtractToFile(dest, overwrite: true); - } - 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 - } - } + // The factory already toasts success and install failures. A download that never got + // that far (network, auth, daily limit) still needs to say something. + if (result is null && item.Status == DownloadStatus.Failed) + toast.Show(Resources.Strings.Fixes_Toast_DownloadFailed, + item.Message ?? Resources.Strings.Fixes_Toast_DownloadFailed_Body, error: true); + }); - /// 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/ManageViewModel.cs b/src/LuaToolsGui/ViewModels/ManageViewModel.cs index 3100f72..1e034cd 100644 --- a/src/LuaToolsGui/ViewModels/ManageViewModel.cs +++ b/src/LuaToolsGui/ViewModels/ManageViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.IO; using System.Windows; using System.Windows.Media; @@ -366,8 +366,11 @@ private static void RevealFile(LuaTileViewModel tile) => SteamService.RevealInExplorer(tile.FilePath); [RelayCommand] - private static void CopyAppId(LuaTileViewModel tile) => - Clipboard.SetText(tile.AppId.ToString()); + private void CopyAppId(LuaTileViewModel tile) + { + if (!SteamService.CopyToClipboard(tile.AppId.ToString())) + _toast.Show(Resources.Strings.Common_CopyAppId, Resources.Strings.Err_ClipboardBusy, error: true); + } [RelayCommand] private void Update(LuaTileViewModel tile) => NavigateToAdd?.Invoke(tile.AppId); @@ -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. diff --git a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs index 8d76948..cdc5d30 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; @@ -221,7 +221,8 @@ partial void OnSelectedLanguageChanged(LanguageOption value) /// 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) { _settings = settings; _auth = auth; diff --git a/src/LuaToolsGui/Views/BuildsView.xaml b/src/LuaToolsGui/Views/BuildsView.xaml index 2eabf58..6899018 100644 --- a/src/LuaToolsGui/Views/BuildsView.xaml +++ b/src/LuaToolsGui/Views/BuildsView.xaml @@ -1,4 +1,4 @@ - - + + + + + + + + + + + + @@ -289,6 +303,15 @@ Content="{x:Static res:Strings.Builds_Action_SaveAsNew}" Icon="{ui:SymbolIcon Bookmark24}" Visibility="{Binding HasEditBase, Converter={StaticResource BoolToVis}}" /> + + @@ -618,7 +641,8 @@ Margin="0,0,12,0" Icon="{ui:SymbolIcon Search24}" PlaceholderText="{x:Static res:Strings.Builds_Depot_Search}" - Text="{Binding DepotSearchText, UpdateSourceTrigger=PropertyChanged}" /> + Text="{Binding DepotSearchText, UpdateSourceTrigger=PropertyChanged}" + Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVis}}" /> + VerticalScrollBarVisibility="Auto" + Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVis}}"> @@ -756,6 +781,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LuaToolsGui/Views/ConfirmOverlay.xaml b/src/LuaToolsGui/Views/ConfirmOverlay.xaml index 5a5ec04..3a9bfc6 100644 --- a/src/LuaToolsGui/Views/ConfirmOverlay.xaml +++ b/src/LuaToolsGui/Views/ConfirmOverlay.xaml @@ -1,4 +1,4 @@ - - + + - + + @@ -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"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/FixesView.xaml b/src/LuaToolsGui/Views/FixesView.xaml index f00ad3f..51a0ac3 100644 --- a/src/LuaToolsGui/Views/FixesView.xaml +++ b/src/LuaToolsGui/Views/FixesView.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + - + + + + + + @@ -494,16 +530,6 @@ - - - diff --git a/src/LuaToolsGui/Views/ManageView.xaml b/src/LuaToolsGui/Views/ManageView.xaml index ac06535..38798bb 100644 --- a/src/LuaToolsGui/Views/ManageView.xaml +++ b/src/LuaToolsGui/Views/ManageView.xaml @@ -1,4 +1,4 @@ - +/// The speed/ETA readout on a download row. +/// +/// +/// These guard a beta report of "download speed is unstable". Depot progress is reported once per +/// COMPLETED FILE — DepotDownloaderMod only prints its percentage line when a file's last chunk lands, +/// and its finer per-chunk ANSI progress is disabled whenever stdout is redirected, which is how we +/// launch it. Real depots are lumpy enough for that to matter: American Truck Simulator's content depot +/// is 73 files, one of which is 8.3 GB — about 166 seconds of silence at 50 MB/s. +/// +/// So the window has to survive minute-long gaps AND bursts of files finishing together, which is what +/// each test below pins down. +/// +public class DownloadRateTests +{ + private const long MB = 1024 * 1024; + + /// An item whose clock the test drives, so a 166-second gap costs no real time. + private static (DownloadItem Item, Action Advance) Build() + { + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var job = new DownloadJob( + DownloadKind.Depot, "test:1", 1, "Test", "Depot files", null, + (_, _, _) => Task.FromResult(new DownloadedFile("x", "x")), + (_, _, _) => Task.FromResult(new JobResult(true, null))); + + var item = new DownloadItem(job) { UtcNow = () => now }; + return (item, seconds => now = now.AddSeconds(seconds)); + } + + /// + /// A single file so large it reports nothing for minutes must still yield its true average rate. + /// This is the original bug: the age trim stripped the window down to the newest sample, the + /// "fewer than two samples" guard returned early, and the speed stayed frozen at a stale value. + /// + [Fact] + public void LongGapBetweenFiles_ReportsTheRealAverage() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + + item.ApplySample(0, total); + advance(166); + item.ApplySample(8_315 * MB, total); // ATS's 8.3 GB file, start to finish + + Assert.InRange(item.BytesPerSecond, 45 * MB, 55 * MB); // 8.3 GB / 166 s ~= 50 MB/s + } + + /// + /// The other half of the same bug: the rate must not merely be non-zero, it must MOVE. A stale + /// reading looks plausible on screen, which is exactly why the report said "unstable" rather than + /// "stuck" — the number was a real speed, just one measured minutes earlier. + /// + [Fact] + public void AfterAFastStart_ALongSlowFileUpdatesTheReading() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + long read = 0; + + // Dense stream of small files at ~100 MB/s. + for (int i = 0; i < 30; i++) + { + advance(0.1); + read += 10 * MB; + item.ApplySample(read, total); + } + double fast = item.BytesPerSecond; + Assert.InRange(fast, 90 * MB, 110 * MB); + + // Then one huge file that averages half that. + advance(166); + read += 8_315 * MB; + item.ApplySample(read, total); + + Assert.NotEqual(fast, item.BytesPerSecond); + Assert.InRange(item.BytesPerSecond, 40 * MB, 60 * MB); + } + + /// + /// Several concurrent files completing milliseconds apart must not be divided by that interval. + /// Unguarded this produced multi-GB/s readings — the visible "spike" half of the report. + /// + [Fact] + public void BurstOfCompletions_DoesNotSpike() + { + var (item, advance) = Build(); + long total = 22_580 * MB; + long read = 0; + + item.ApplySample(read, total); + advance(166); + read += 8_315 * MB; + item.ApplySample(read, total); + + // Eight parallel downloads all landing at once, 10 ms apart. + for (int i = 0; i < 8; i++) + { + advance(0.01); + read += 1 * MB; + item.ApplySample(read, total); + } + + Assert.InRange(item.BytesPerSecond, 1 * MB, 200 * MB); // sane; pre-fix this read in GB/s + } + + /// A well-shaped depot (many small files) must still measure accurately. + [Fact] + public void DenseSteadyStream_MatchesTheActualRate() + { + var (item, advance) = Build(); + long total = 3_000 * MB; + long read = 0; + + for (int i = 0; i < 100; i++) // 10 s at 20 MB/s + { + advance(0.1); + read += 2 * MB; + item.ApplySample(read, total); + } + + Assert.InRange(item.BytesPerSecond, 18 * MB, 22 * MB); + } + + /// ETA follows the rate, so it must be sane once the rate is. + [Fact] + public void Eta_FollowsTheMeasuredRate() + { + var (item, advance) = Build(); + long total = 1_000 * MB; + + item.ApplySample(0, total); + advance(10); + item.ApplySample(500 * MB, total); // 50 MB/s, 500 MB left => ~10 s + + Assert.NotNull(item.Eta); + Assert.InRange(item.Eta!.Value.TotalSeconds, 8, 12); + } + + /// A retry must not inherit the previous attempt's window. + [Fact] + public void ResetMetrics_ClearsTheWindow() + { + var (item, advance) = Build(); + long total = 1_000 * MB; + + item.ApplySample(0, total); + advance(10); + item.ApplySample(500 * MB, total); + Assert.True(item.BytesPerSecond > 0); + + item.ResetMetrics(); + Assert.Equal(0, item.BytesPerSecond); + Assert.Equal(0, item.BytesRead); + Assert.Null(item.Eta); + + // And the cleared window must not resurrect the old samples as a bogus first reading. + advance(1); + item.ApplySample(0, total); + Assert.Equal(0, item.BytesPerSecond); + } +} diff --git a/tests/LuaToolsGui.Tests/LuaVaultTests.cs b/tests/LuaToolsGui.Tests/LuaVaultTests.cs index 5e54bee..f2b8e17 100644 --- a/tests/LuaToolsGui.Tests/LuaVaultTests.cs +++ b/tests/LuaToolsGui.Tests/LuaVaultTests.cs @@ -611,3 +611,4 @@ public void Variants_SurviveAcrossVaultInstances() Assert.Contains(reopened.AppsWithVariants(), id => id == AppId); } } + \ No newline at end of file