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(() => {