From 7f3a6a6aa04f884209ec22f10696b32f55a1b61e Mon Sep 17 00:00:00 2001 From: pointfeev Date: Sun, 6 Mar 2022 20:00:45 -0500 Subject: [PATCH] v3.2.0.0 - Converted steam store page parsing into actual steam store API calls - Substantially increased the speed of gathering app information, as DLC gathering will now attempt to use the steam store API system to gather information, only reverting back to SteamCMD if it was unsuccessful (if the DLC isn't on the steam store) - Program will now try to utilize cached Epic Store info if the web request fails - Reverted the maximum SteamCMD processes to 20 - Fixed a rare concurrency issue with the new SteamCMD method - Improved the used images and icons for the context menu - Added the "Open in SteamDB" context menu option for Steam DLC --- CreamInstaller/CreamInstaller.csproj | 2 +- CreamInstaller/DialogForm.cs | 2 + CreamInstaller/Epic/EpicStore.cs | 14 +- CreamInstaller/ProgramSelection.cs | 5 +- CreamInstaller/SelectForm.cs | 81 ++++++----- CreamInstaller/Steam/AppDetails.cs | 147 ++++++++++++++++++++ CreamInstaller/Steam/SteamCMD.cs | 9 +- CreamInstaller/Steam/SteamStore.cs | 61 ++++++-- CreamInstaller/Utility/HttpClientManager.cs | 22 +-- CreamInstaller/Utility/ProgramData.cs | 2 +- 10 files changed, 280 insertions(+), 65 deletions(-) create mode 100644 CreamInstaller/Steam/AppDetails.cs diff --git a/CreamInstaller/CreamInstaller.csproj b/CreamInstaller/CreamInstaller.csproj index 19033c3..d3f22da 100644 --- a/CreamInstaller/CreamInstaller.csproj +++ b/CreamInstaller/CreamInstaller.csproj @@ -5,7 +5,7 @@ True Resources\ini.ico true - 3.1.0.1 + 3.2.0.0 Resources\ini.ico diff --git a/CreamInstaller/DialogForm.cs b/CreamInstaller/DialogForm.cs index ab494ec..ef294e8 100644 --- a/CreamInstaller/DialogForm.cs +++ b/CreamInstaller/DialogForm.cs @@ -14,6 +14,8 @@ internal partial class DialogForm : CustomForm { if (customFormIcon is not null) Icon = customFormIcon; + if (descriptionIcon is null) + descriptionIcon = Icon; icon.Image = descriptionIcon.ToBitmap(); descriptionLabel.Text = descriptionText; acceptButton.Text = acceptButtonText; diff --git a/CreamInstaller/Epic/EpicStore.cs b/CreamInstaller/Epic/EpicStore.cs index 3466832..6d39929 100644 --- a/CreamInstaller/Epic/EpicStore.cs +++ b/CreamInstaller/Epic/EpicStore.cs @@ -24,8 +24,20 @@ internal static class EpicStore { List<(string id, string name, string product, string icon, string developer)> dlcIds = new(); Response response = await QueryGraphQL(categoryNamespace); + string cacheFile = ProgramData.AppInfoPath + @$"\{categoryNamespace}.json"; + if (response is null) + if (Directory.Exists(Directory.GetDirectoryRoot(cacheFile)) && File.Exists(cacheFile)) + try + { + response = JsonConvert.DeserializeObject(File.ReadAllText(cacheFile)); + } + catch { } if (response is null) return dlcIds; - try { File.WriteAllText(ProgramData.AppInfoPath + @$"\{categoryNamespace}.json", JsonConvert.SerializeObject(response, Formatting.Indented)); } catch { } + try + { + File.WriteAllText(cacheFile, JsonConvert.SerializeObject(response, Formatting.Indented)); + } + catch { } List searchStore = new(response.Data.Catalog.SearchStore.Elements); foreach (Element element in searchStore) { diff --git a/CreamInstaller/ProgramSelection.cs b/CreamInstaller/ProgramSelection.cs index 35c6e18..0c88f12 100644 --- a/CreamInstaller/ProgramSelection.cs +++ b/CreamInstaller/ProgramSelection.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Gameloop.Vdf.Linq; - namespace CreamInstaller; internal enum DlcType @@ -24,7 +22,7 @@ internal class ProgramSelection internal string ProductUrl = null; internal string IconUrl = null; - internal string ClientIconUrl = null; + internal string SubIconUrl = null; internal string Publisher = null; @@ -32,7 +30,6 @@ internal class ProgramSelection internal List DllDirectories = null; internal bool IsSteam = false; - internal VProperty AppInfo = null; internal readonly SortedList AllDlc = new(); internal readonly SortedList SelectedDlc = new(); diff --git a/CreamInstaller/SelectForm.cs b/CreamInstaller/SelectForm.cs index a2f2fb7..ce85588 100644 --- a/CreamInstaller/SelectForm.cs +++ b/CreamInstaller/SelectForm.cs @@ -142,8 +142,9 @@ internal partial class SelectForm : CustomForm RemoveFromRemainingGames(name); return; } + AppData appData = await SteamStore.QueryStoreAPI(appId); VProperty appInfo = appInfo = await SteamCMD.GetAppInfo(appId, branch, buildId); - if (appInfo is null) + if (appInfo is null || appData is null) { RemoveFromRemainingGames(name); return; @@ -151,8 +152,8 @@ internal partial class SelectForm : CustomForm if (Program.Canceled) return; ConcurrentDictionary dlc = new(); List dlcTasks = new(); - List dlcIds = await SteamCMD.ParseDlcAppIds(appInfo); - await SteamStore.ParseDlcAppIds(appId, dlcIds); + List dlcIds = await SteamStore.ParseDlcAppIds(appData); + dlcIds.AddRange(await SteamCMD.ParseDlcAppIds(appInfo)); if (dlcIds.Count > 0) { foreach (string dlcAppId in dlcIds) @@ -163,20 +164,30 @@ internal partial class SelectForm : CustomForm { if (Program.Canceled) return; string dlcName = null; - string dlcIconStaticId = null; - VProperty dlcAppInfo = await SteamCMD.GetAppInfo(dlcAppId); - if (dlcAppInfo is not null) + string dlcIcon = null; + AppData dlcAppData = await SteamStore.QueryStoreAPI(dlcAppId); + if (dlcAppData is not null) { - dlcName = dlcAppInfo.Value?.GetChild("common")?.GetChild("name")?.ToString(); - dlcIconStaticId = dlcAppInfo.Value?.GetChild("common")?.GetChild("icon")?.ToString(); - dlcIconStaticId ??= dlcAppInfo.Value?.GetChild("common")?.GetChild("logo_small")?.ToString(); - dlcIconStaticId ??= dlcAppInfo.Value?.GetChild("common")?.GetChild("logo")?.ToString(); - if (dlcIconStaticId is not null) - dlcIconStaticId = IconGrabber.SteamAppImagesPath + @$"\{dlcAppId}\{dlcIconStaticId}.jpg"; + dlcName = dlcAppData.name; + dlcIcon = dlcAppData.header_image; + } + else + { + VProperty dlcAppInfo = await SteamCMD.GetAppInfo(dlcAppId); + if (dlcAppInfo is not null) + { + dlcName = dlcAppInfo.Value?.GetChild("common")?.GetChild("name")?.ToString(); + string dlcIconStaticId = dlcAppInfo.Value?.GetChild("common")?.GetChild("icon")?.ToString(); + dlcIconStaticId ??= dlcAppInfo.Value?.GetChild("common")?.GetChild("logo_small")?.ToString(); + dlcIconStaticId ??= dlcAppInfo.Value?.GetChild("common")?.GetChild("logo")?.ToString(); + if (dlcIconStaticId is not null) + dlcIcon = IconGrabber.SteamAppImagesPath + @$"\{dlcAppId}\{dlcIconStaticId}.jpg"; + } } if (Program.Canceled) return; - if (!string.IsNullOrWhiteSpace(dlcName)) - dlc[dlcAppId] = (DlcType.Default, dlcName, dlcIconStaticId); + if (string.IsNullOrWhiteSpace(dlcName)) + return; //dlcName = "Unknown DLC"; + dlc[dlcAppId] = (DlcType.Default, dlcName, dlcIcon); RemoveFromRemainingDLCs(dlcAppId); }); dlcTasks.Add(task); @@ -195,6 +206,7 @@ internal partial class SelectForm : CustomForm await task; } + name = appData.name ?? name; selection ??= new(); selection.Enabled = allCheckBox.Checked || selection.SelectedDlc.Any() || selection.ExtraDlc.Any(); selection.Usable = true; @@ -203,11 +215,10 @@ internal partial class SelectForm : CustomForm selection.RootDirectory = directory; selection.DllDirectories = dllDirectories; selection.IsSteam = true; - selection.AppInfo = appInfo; selection.ProductUrl = "https://store.steampowered.com/app/" + appId; - selection.IconUrl = IconGrabber.SteamAppImagesPath + @$"\{appId}\{appInfo?.Value?.GetChild("common")?.GetChild("icon")?.ToString()}.jpg"; - selection.ClientIconUrl = IconGrabber.SteamAppImagesPath + @$"\{appId}\{appInfo?.Value?.GetChild("common")?.GetChild("clienticon")?.ToString()}.ico"; - selection.Publisher = appInfo?.Value?.GetChild("extended")?.GetChild("publisher")?.ToString(); + selection.IconUrl = IconGrabber.SteamAppImagesPath + @$"\{appId}\{appInfo?.Value?.GetChild("common")?.GetChild("clienticon")?.ToString()}.ico"; + selection.SubIconUrl = appData.header_image ?? IconGrabber.SteamAppImagesPath + @$"\{appId}\{appInfo?.Value?.GetChild("common")?.GetChild("icon")?.ToString()}.jpg"; + selection.Publisher = appData.publishers[0] ?? appInfo?.Value?.GetChild("extended")?.GetChild("publisher")?.ToString(); if (Program.Canceled) return; Program.Invoke(selectionTreeView, delegate @@ -600,30 +611,30 @@ internal partial class SelectForm : CustomForm } } nodeContextMenu.Items.Add(header); - string appInfo = $@"{SteamCMD.AppInfoPath}\{id}.vdf"; - string appInfoEpic = $@"{SteamCMD.AppInfoPath}\{id}.json"; - if (Directory.Exists(Directory.GetDirectoryRoot(appInfo)) && (File.Exists(appInfo) || File.Exists(appInfoEpic))) + string appInfoVDF = $@"{SteamCMD.AppInfoPath}\{id}.vdf"; + string appInfoJSON = $@"{SteamCMD.AppInfoPath}\{id}.json"; + if (Directory.Exists(Directory.GetDirectoryRoot(appInfoVDF)) && (File.Exists(appInfoVDF) || File.Exists(appInfoJSON))) { nodeContextMenu.Items.Add(new ToolStripSeparator()); nodeContextMenu.Items.Add(new ToolStripMenuItem("Open AppInfo", Image("Notepad"), new EventHandler((sender, e) => { - if (File.Exists(appInfo)) - Diagnostics.OpenFileInNotepad(appInfo); - else - Diagnostics.OpenFileInNotepad(appInfoEpic); + if (File.Exists(appInfoVDF)) + Diagnostics.OpenFileInNotepad(appInfoVDF); + else if (File.Exists(appInfoJSON)) + Diagnostics.OpenFileInNotepad(appInfoJSON); }))); nodeContextMenu.Items.Add(new ToolStripMenuItem("Refresh AppInfo", Image("Command Prompt"), new EventHandler((sender, e) => { try { - File.Delete(appInfo); + File.Delete(appInfoVDF); } catch { } try { - File.Delete(appInfoEpic); + File.Delete(appInfoJSON); } catch { } OnLoad(); @@ -713,7 +724,7 @@ internal partial class SelectForm : CustomForm } else new DialogForm(this).Show(SystemIcons.Error, "Paradox Launcher repair failed!" - + "\n\nAn original Steamworks API or Epic Online Services SDK file could not be found." + + "\n\nAn original Steamworks/Epic Online Services SDK file could not be found." + "\nYou must reinstall Paradox Launcher to fix this issue.", "OK"); }))); } @@ -723,17 +734,21 @@ internal partial class SelectForm : CustomForm for (int i = 0; i < selection.DllDirectories.Count; i++) { string directory = selection.DllDirectories[i]; - nodeContextMenu.Items.Add(new ToolStripMenuItem($"Open {(selection.IsSteam ? "Steamworks API" : "Epic Online Services SDK")} Directory ({i + 1})", Image("File Explorer"), + nodeContextMenu.Items.Add(new ToolStripMenuItem($"Open {(selection.IsSteam ? "Steamworks" : "Epic Online Services")} SDK Directory ({i + 1})", Image("File Explorer"), new EventHandler((sender, e) => Diagnostics.OpenDirectoryInFileExplorer(directory)))); } } + ProgramSelection dlcParentSelection = dlc.HasValue ? ProgramSelection.FromId(dlc.Value.gameAppId) : null; + if (selection is not null || dlcParentSelection is not null && dlcParentSelection.IsSteam) + { + nodeContextMenu.Items.Add(new ToolStripSeparator()); + nodeContextMenu.Items.Add(new ToolStripMenuItem("Open SteamDB", Image("SteamDB"), + new EventHandler((sender, e) => Diagnostics.OpenUrlInInternetBrowser("https://steamdb.info/app/" + id)))); + } if (id != "ParadoxLauncher" && selection is not null) { if (selection.IsSteam) { - nodeContextMenu.Items.Add(new ToolStripSeparator()); - nodeContextMenu.Items.Add(new ToolStripMenuItem("Open SteamDB", Image("SteamDB"), - new EventHandler((sender, e) => Diagnostics.OpenUrlInInternetBrowser("https://steamdb.info/app/" + id)))); nodeContextMenu.Items.Add(new ToolStripMenuItem("Open Steam Store", Image("Steam Store"), new EventHandler((sender, e) => Diagnostics.OpenUrlInInternetBrowser(selection.ProductUrl)))); ToolStripMenuItem steamCommunity = new("Open Steam Community", Image("ClientIcon_" + id), @@ -742,7 +757,7 @@ internal partial class SelectForm : CustomForm if (steamCommunity.Image is null) { steamCommunity.Image = Image("Steam Community"); - TrySetImageAsync(steamCommunity, id, selection.ClientIconUrl, true); + TrySetImageAsync(steamCommunity, id, selection.SubIconUrl, true); } } else diff --git a/CreamInstaller/Steam/AppDetails.cs b/CreamInstaller/Steam/AppDetails.cs new file mode 100644 index 0000000..5e0b568 --- /dev/null +++ b/CreamInstaller/Steam/AppDetails.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; + +namespace CreamInstaller.Steam; + +#pragma warning disable IDE1006 // Naming Styles +public class PriceOverview +{ + public string currency { get; set; } + public int initial { get; set; } + public int final { get; set; } + public int discount_percent { get; set; } + public string initial_formatted { get; set; } + public string final_formatted { get; set; } +} + +public class Sub +{ + public int packageid { get; set; } + public string percent_savings_text { get; set; } + public int percent_savings { get; set; } + public string option_text { get; set; } + public string option_description { get; set; } + public string can_get_free_license { get; set; } + public bool is_free_license { get; set; } + public int price_in_cents_with_discount { get; set; } +} + +public class PackageGroup +{ + public string name { get; set; } + public string title { get; set; } + public string description { get; set; } + public string selection_text { get; set; } + public string save_text { get; set; } + public object display_type { get; set; } + public string is_recurring_subscription { get; set; } + public List subs { get; set; } +} + +public class Platforms +{ + public bool windows { get; set; } + public bool mac { get; set; } + public bool linux { get; set; } +} + +public class Metacritic +{ + public int score { get; set; } + public string url { get; set; } +} + +public class Category +{ + public int id { get; set; } + public string description { get; set; } +} + +public class Genre +{ + public string id { get; set; } + public string description { get; set; } +} + +public class Screenshot +{ + public int id { get; set; } + public string path_thumbnail { get; set; } + public string path_full { get; set; } +} + +public class Recommendations +{ + public int total { get; set; } +} + +public class Highlighted +{ + public string name { get; set; } + public string path { get; set; } +} + +public class Achievements +{ + public int total { get; set; } + public List highlighted { get; set; } +} + +public class ReleaseDate +{ + public bool coming_soon { get; set; } + public string date { get; set; } +} + +public class SupportInfo +{ + public string url { get; set; } + public string email { get; set; } +} + +public class ContentDescriptors +{ + public List ids { get; set; } + public object notes { get; set; } +} + +public class AppData +{ + public string type { get; set; } + public string name { get; set; } + public int steam_appid { get; set; } + public int required_age { get; set; } + public bool is_free { get; set; } + public List dlc { get; set; } + public string detailed_description { get; set; } + public string about_the_game { get; set; } + public string short_description { get; set; } + public string supported_languages { get; set; } + public string reviews { get; set; } + public string header_image { get; set; } + public string website { get; set; } + public string legal_notice { get; set; } + public List developers { get; set; } + public List publishers { get; set; } + public PriceOverview price_overview { get; set; } + public List packages { get; set; } + public List package_groups { get; set; } + public Platforms platforms { get; set; } + public Metacritic metacritic { get; set; } + public List categories { get; set; } + public List genres { get; set; } + public List screenshots { get; set; } + public Recommendations recommendations { get; set; } + public Achievements achievements { get; set; } + public ReleaseDate release_date { get; set; } + public SupportInfo support_info { get; set; } + public string background { get; set; } + public string background_raw { get; set; } + public ContentDescriptors content_descriptors { get; set; } +} + +public class AppDetails +{ + public bool success { get; set; } + public AppData data { get; set; } +} +#pragma warning restore IDE1006 // Naming Styles \ No newline at end of file diff --git a/CreamInstaller/Steam/SteamCMD.cs b/CreamInstaller/Steam/SteamCMD.cs index 86694f9..b9f6a2b 100644 --- a/CreamInstaller/Steam/SteamCMD.cs +++ b/CreamInstaller/Steam/SteamCMD.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -18,15 +19,17 @@ namespace CreamInstaller.Steam; internal static class SteamCMD { - internal static readonly int ProcessLimit = 30; + internal static readonly int ProcessLimit = 20; internal static string DirectoryPath => ProgramData.DirectoryPath; internal static string AppInfoPath => ProgramData.AppInfoPath; internal static readonly string FilePath = DirectoryPath + @"\steamcmd.exe"; - private static readonly Dictionary AttemptCount = new(); // the more app_updates, the longer SteamCMD should wait for app_info_print - private static string GetArguments(string appId) => $@"@ShutdownOnFailedCommand 0 +force_install_dir {DirectoryPath} +login anonymous +app_info_print {appId} " + string.Concat(Enumerable.Repeat("+app_update 4 ", AttemptCount[appId])) + "+quit"; + private static readonly ConcurrentDictionary AttemptCount = new(); // the more app_updates, the longer SteamCMD should wait for app_info_print + private static string GetArguments(string appId) => AttemptCount.TryGetValue(appId, out int attempts) + ? $@"@ShutdownOnFailedCommand 0 +force_install_dir {DirectoryPath} +login anonymous +app_info_print {appId} " + string.Concat(Enumerable.Repeat("+app_update 4 ", attempts)) + "+quit" + : $"+login anonymous +app_info_print {appId} +quit"; private static readonly int[] locks = new int[ProcessLimit]; internal static async Task Run(string appId) => await Task.Run(() => diff --git a/CreamInstaller/Steam/SteamStore.cs b/CreamInstaller/Steam/SteamStore.cs index 06acaff..5b9d2b9 100644 --- a/CreamInstaller/Steam/SteamStore.cs +++ b/CreamInstaller/Steam/SteamStore.cs @@ -1,24 +1,61 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; using System.Threading.Tasks; using CreamInstaller.Utility; -using HtmlAgilityPack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace CreamInstaller.Steam; internal static class SteamStore { - internal static async Task ParseDlcAppIds(string appId, List dlcIds) + internal static async Task> ParseDlcAppIds(AppData appData) => await Task.Run(() => { - // currently this is only really needed to get DLC that release without changing game buildid (very rare) - // it also finds things which aren't really connected to the game itself, and thus not needed (usually soundtracks, collections, packs, etc.) - HtmlNodeCollection nodes = await HttpClientManager.GetDocumentNodes( - $"https://store.steampowered.com/dlc/{appId}", - "//div[@class='recommendation']/div/a"); - if (nodes is not null) - foreach (HtmlNode node in nodes) - if (int.TryParse(node.Attributes?["data-ds-appid"]?.Value, out int dlcAppId) && dlcAppId > 0 && !dlcIds.Contains("" + dlcAppId)) - dlcIds.Add("" + dlcAppId); + List dlcIds = new(); + if (appData.dlc is null) return dlcIds; + foreach (int appId in appData.dlc) + dlcIds.Add(appId.ToString()); + return dlcIds; + }); + + internal static async Task QueryStoreAPI(string appId) + { + if (Program.Canceled) return null; + string response = await HttpClientManager.EnsureGet($"https://store.steampowered.com/api/appdetails?appids={appId}"); + string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.json"; + if (response is not null) + { + IDictionary apps = (dynamic)JsonConvert.DeserializeObject(response); + foreach (KeyValuePair app in apps) + { + try + { + AppData data = JsonConvert.DeserializeObject(app.Value.ToString()).data; + try + { + File.WriteAllText(cacheFile, JsonConvert.SerializeObject(data, Formatting.Indented)); + } + catch { } + return data; + } + catch (Exception e) + { + new DialogForm(null).Show(SystemIcons.Error, "Unsuccessful deserialization of query for appid " + appId + ":\n\n" + e.ToString(), "FUCK"); + } + } + } + if (Directory.Exists(Directory.GetDirectoryRoot(cacheFile)) && File.Exists(cacheFile)) + { + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(cacheFile)); + } + catch { } + } + return null; } } diff --git a/CreamInstaller/Utility/HttpClientManager.cs b/CreamInstaller/Utility/HttpClientManager.cs index 30f45aa..91e1824 100644 --- a/CreamInstaller/Utility/HttpClientManager.cs +++ b/CreamInstaller/Utility/HttpClientManager.cs @@ -1,9 +1,8 @@ using System; using System.Drawing; -using System.IO; using System.Net.Http; -using System.Text; using System.Threading.Tasks; +using System.Windows.Forms; using HtmlAgilityPack; @@ -15,21 +14,17 @@ internal static class HttpClientManager internal static void Setup() { HttpClient = new(); - HttpClient.DefaultRequestHeaders.Add("User-Agent", $"CreamInstaller-{Environment.MachineName}_{Environment.UserDomainName}_{Environment.UserName}"); + HttpClient.DefaultRequestHeaders.Add("User-Agent", $"{Environment.MachineName}CI{Application.ProductVersion.Replace(".", "")}{Environment.UserName}"); } - internal static async Task Get(string url) + internal static async Task EnsureGet(string url) { try { using HttpRequestMessage request = new(HttpMethod.Get, url); using HttpResponseMessage response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - using Stream stream = await response.Content.ReadAsStreamAsync(); - using StreamReader reader = new(stream, Encoding.UTF8); - HtmlDocument document = new(); - document.LoadHtml(reader.ReadToEnd()); - return document; + return await response.Content.ReadAsStringAsync(); } catch { @@ -37,7 +32,14 @@ internal static class HttpClientManager } } - internal static async Task GetDocumentNodes(string url, string xpath) => (await Get(url))?.DocumentNode?.SelectNodes(xpath); + internal static HtmlAgilityPack.HtmlDocument ToHtmlDocument(this string html) + { + HtmlAgilityPack.HtmlDocument document = new(); + document.LoadHtml(html); + return document; + } + + internal static async Task GetDocumentNodes(string url, string xpath) => (await EnsureGet(url))?.ToHtmlDocument()?.DocumentNode?.SelectNodes(xpath); internal static async Task GetImageFromUrl(string url) { diff --git a/CreamInstaller/Utility/ProgramData.cs b/CreamInstaller/Utility/ProgramData.cs index 4237dc7..1a7281c 100644 --- a/CreamInstaller/Utility/ProgramData.cs +++ b/CreamInstaller/Utility/ProgramData.cs @@ -14,7 +14,7 @@ internal static class ProgramData internal static readonly string AppInfoPath = DirectoryPath + @"\appinfo"; internal static readonly string AppInfoVersionPath = AppInfoPath + @"\version.txt"; - internal static readonly Version MinimumAppInfoVersion = Version.Parse("2.4.0.0"); + internal static readonly Version MinimumAppInfoVersion = Version.Parse("3.2.0.0"); internal static async Task Setup() => await Task.Run(() => {