diff --git a/CreamInstaller/Forms/SelectForm.cs b/CreamInstaller/Forms/SelectForm.cs index a59e142..6d38c07 100644 --- a/CreamInstaller/Forms/SelectForm.cs +++ b/CreamInstaller/Forms/SelectForm.cs @@ -194,7 +194,7 @@ internal sealed partial class SelectForm : CustomForm return; if (!uninstallAll) { - string? blockReason = Program.GetGameBlockedReason(name, gameDirectory); + var blockReason = Program.GetGameBlockedReason(name, gameDirectory); if (blockReason is not null) { steamBlocked++; @@ -448,7 +448,7 @@ internal sealed partial class SelectForm : CustomForm return; if (!uninstallAll) { - string? blockReason = Program.GetGameBlockedReason(name, directory); + var blockReason = Program.GetGameBlockedReason(name, directory); if (blockReason is not null) { epicBlocked++; @@ -581,7 +581,7 @@ internal sealed partial class SelectForm : CustomForm return; if (!uninstallAll) { - string? blockReason = Program.GetGameBlockedReason(name, gameDirectory); + var blockReason = Program.GetGameBlockedReason(name, gameDirectory); if (blockReason is not null) { ubiBlocked++; diff --git a/CreamInstaller/Forms/TestGameForm.Designer.cs b/CreamInstaller/Forms/TestGameForm.Designer.cs index 5b9e6fa..d367226 100644 --- a/CreamInstaller/Forms/TestGameForm.Designer.cs +++ b/CreamInstaller/Forms/TestGameForm.Designer.cs @@ -23,26 +23,20 @@ partial class TestGameForm platformGroupBox = new GroupBox(); steamRadioButton = new RadioButton(); epicRadioButton = new RadioButton(); + ubisoftRadioButton = new RadioButton(); appIdLabel = new Label(); appIdTextBox = new TextBox(); gameNameLabel = new Label(); gameNameTextBox = new TextBox(); epicSearchButton = new Button(); + ubisoftSearchButton = new Button(); epicResultsListBox = new ListBox(); - dlcGroupBox = new GroupBox(); - dlcListBox = new ListBox(); - dlcIdLabel = new Label(); - dlcIdTextBox = new TextBox(); - dlcNameLabel = new Label(); - dlcNameTextBox = new TextBox(); - addDlcButton = new Button(); - removeDlcButton = new Button(); + ubisoftResultsListBox = new ListBox(); generateButton = new Button(); clearButton = new Button(); closeButton = new Button(); statusLabel = new Label(); platformGroupBox.SuspendLayout(); - dlcGroupBox.SuspendLayout(); SuspendLayout(); // ── Platform group box ── y=8, h=44 @@ -52,6 +46,7 @@ partial class TestGameForm platformGroupBox.Text = "Platform"; platformGroupBox.Controls.Add(steamRadioButton); platformGroupBox.Controls.Add(epicRadioButton); + platformGroupBox.Controls.Add(ubisoftRadioButton); steamRadioButton.AutoSize = true; steamRadioButton.Checked = true; @@ -65,6 +60,11 @@ partial class TestGameForm epicRadioButton.Text = "Epic"; epicRadioButton.CheckedChanged += OnPlatformChanged; + ubisoftRadioButton.AutoSize = true; + ubisoftRadioButton.Location = new System.Drawing.Point(140, 17); + ubisoftRadioButton.Text = "Ubisoft"; + ubisoftRadioButton.CheckedChanged += OnPlatformChanged; + // ── App ID row ── y=62 appIdLabel.AutoSize = true; appIdLabel.Location = new System.Drawing.Point(12, 66); @@ -89,58 +89,26 @@ partial class TestGameForm epicSearchButton.Visible = false; epicSearchButton.Click += OnEpicSearch; + // ── Ubisoft search button ── shares same position as Epic button (mutually exclusive) + ubisoftSearchButton.Location = new System.Drawing.Point(468, 97); + ubisoftSearchButton.Size = new System.Drawing.Size(80, 23); + ubisoftSearchButton.Text = "Search"; + ubisoftSearchButton.Visible = false; + ubisoftSearchButton.Click += OnUbisoftSearch; + // ── Epic results list ── y=130, same slot as DLC group epicResultsListBox.Location = new System.Drawing.Point(12, 130); epicResultsListBox.Size = new System.Drawing.Size(536, 80); epicResultsListBox.Visible = false; epicResultsListBox.SelectedIndexChanged += OnEpicResultSelected; - // ── DLC group box ── y=130, h=130 - dlcGroupBox.Location = new System.Drawing.Point(12, 130); - dlcGroupBox.Size = new System.Drawing.Size(536, 130); - dlcGroupBox.TabStop = false; - dlcGroupBox.Text = "DLC Entries (Steam only)"; - dlcGroupBox.Controls.Add(dlcListBox); - dlcGroupBox.Controls.Add(dlcIdLabel); - dlcGroupBox.Controls.Add(dlcIdTextBox); - dlcGroupBox.Controls.Add(dlcNameLabel); - dlcGroupBox.Controls.Add(dlcNameTextBox); - dlcGroupBox.Controls.Add(addDlcButton); - dlcGroupBox.Controls.Add(removeDlcButton); + // ── Ubisoft results list ── shares same position as Epic results (mutually exclusive) + ubisoftResultsListBox.Location = new System.Drawing.Point(12, 130); + ubisoftResultsListBox.Size = new System.Drawing.Size(536, 80); + ubisoftResultsListBox.Visible = false; + ubisoftResultsListBox.SelectedIndexChanged += OnUbisoftResultSelected; - dlcListBox.Location = new System.Drawing.Point(6, 20); - dlcListBox.Size = new System.Drawing.Size(524, 60); - - // DLC row inside group box — left-to-right: - // "DLC ID:" label + 70px box + "DLC Name:" label + 160px box + "Add"(60) + "Remove"(70) - // Total: ~48 + 70 + ~72 + 160 + 60 + 70 = 480 (fits in 524) - dlcIdLabel.AutoSize = true; - dlcIdLabel.Location = new System.Drawing.Point(6, 92); - dlcIdLabel.Text = "DLC ID:"; - - dlcIdTextBox.Location = new System.Drawing.Point(62, 89); - dlcIdTextBox.Size = new System.Drawing.Size(70, 23); - dlcIdTextBox.PlaceholderText = "e.g. 12345"; - - dlcNameLabel.AutoSize = true; - dlcNameLabel.Location = new System.Drawing.Point(140, 92); - dlcNameLabel.Text = "DLC Name:"; - - dlcNameTextBox.Location = new System.Drawing.Point(216, 89); - dlcNameTextBox.Size = new System.Drawing.Size(184, 23); - dlcNameTextBox.PlaceholderText = "e.g. Test DLC"; - - addDlcButton.Location = new System.Drawing.Point(406, 89); - addDlcButton.Size = new System.Drawing.Size(52, 23); - addDlcButton.Text = "Add"; - addDlcButton.Click += OnAddDlc; - - removeDlcButton.Location = new System.Drawing.Point(462, 89); - removeDlcButton.Size = new System.Drawing.Size(62, 23); - removeDlcButton.Text = "Remove"; - removeDlcButton.Click += OnRemoveDlc; - - // ── Action buttons ── y=270 + // ── Action buttons ── y=220 (was y=270 with DLC section) generateButton.Location = new System.Drawing.Point(12, 270); generateButton.Size = new System.Drawing.Size(150, 26); generateButton.Text = "Generate Test Game"; @@ -176,8 +144,9 @@ partial class TestGameForm Controls.Add(gameNameLabel); Controls.Add(gameNameTextBox); Controls.Add(epicSearchButton); + Controls.Add(ubisoftSearchButton); Controls.Add(epicResultsListBox); - Controls.Add(dlcGroupBox); + Controls.Add(ubisoftResultsListBox); Controls.Add(generateButton); Controls.Add(clearButton); Controls.Add(closeButton); @@ -185,8 +154,6 @@ partial class TestGameForm platformGroupBox.ResumeLayout(false); platformGroupBox.PerformLayout(); - dlcGroupBox.ResumeLayout(false); - dlcGroupBox.PerformLayout(); ResumeLayout(false); PerformLayout(); } @@ -194,20 +161,15 @@ partial class TestGameForm private GroupBox platformGroupBox; private RadioButton steamRadioButton; private RadioButton epicRadioButton; + private RadioButton ubisoftRadioButton; + private Button ubisoftSearchButton; + private ListBox ubisoftResultsListBox; private Label appIdLabel; private TextBox appIdTextBox; private Label gameNameLabel; private TextBox gameNameTextBox; private Button epicSearchButton; private ListBox epicResultsListBox; - private GroupBox dlcGroupBox; - private ListBox dlcListBox; - private Label dlcIdLabel; - private TextBox dlcIdTextBox; - private Label dlcNameLabel; - private TextBox dlcNameTextBox; - private Button addDlcButton; - private Button removeDlcButton; private Button generateButton; private Button clearButton; private Button closeButton; diff --git a/CreamInstaller/Forms/TestGameForm.cs b/CreamInstaller/Forms/TestGameForm.cs index 5920b07..75c51e3 100644 --- a/CreamInstaller/Forms/TestGameForm.cs +++ b/CreamInstaller/Forms/TestGameForm.cs @@ -7,6 +7,7 @@ using System.Windows.Forms; using CreamInstaller.Components; using CreamInstaller.Platforms.Epic; using CreamInstaller.Platforms.Steam; +using CreamInstaller.Platforms.Ubisoft; using CreamInstaller.Utility; namespace CreamInstaller.Forms; @@ -18,48 +19,63 @@ internal sealed partial class TestGameForm : CustomForm private static readonly List CreatedDirectories = []; - // Steam DLC entries per-form: (dlcId, dlcName) - private readonly List<(string id, string name)> dlcEntries = []; - // Cached Epic search results from the last search: (namespace, name) private readonly List<(string ns, string name)> epicSearchResults = []; + // Cached Ubisoft search results from the last search: (id, name) + private readonly List<(string id, string name)> ubisoftSearchResults = []; + private bool IsEpicMode => epicRadioButton.Checked; + private bool IsUbisoftMode => ubisoftRadioButton.Checked; + internal TestGameForm(IWin32Window owner) : base(owner) { InitializeComponent(); appIdTextBox.Leave += OnAppIdLeave; - RefreshDlcList(); + appIdTextBox.KeyDown += OnAppIdKeyDown; + gameNameTextBox.KeyDown += OnGameNameKeyDown; UpdatePlatformMode(); } private void UpdatePlatformMode() { bool epic = IsEpicMode; + bool ubisoft = IsUbisoftMode; - // App ID row: Steam only - appIdLabel.Visible = !epic; - appIdTextBox.Visible = !epic; + // App ID row: only Steam needs it; Epic and Ubisoft use name search + appIdLabel.Visible = !epic && !ubisoft; + appIdTextBox.Visible = !epic && !ubisoft; + appIdLabel.Text = "App ID:"; + appIdTextBox.PlaceholderText = "e.g. 480"; + NativeMethods.RefreshCueBanner(appIdTextBox); - // Search button: Epic only — shrink the game name box to make room + // Search button: Epic and Ubisoft — shrink the game name box to make room + bool showSearch = epic || ubisoft; epicSearchButton.Visible = epic; - gameNameTextBox.Size = new System.Drawing.Size(epic ? 354 : 443, 23); + ubisoftSearchButton.Visible = ubisoft; + gameNameTextBox.Size = new System.Drawing.Size(showSearch ? 354 : 443, 23); // Placeholder text — call RefreshCueBanner to flush the Win32 cue so only one text shows - gameNameTextBox.PlaceholderText = epic ? "Enter game name and click Search" : "e.g. Spacewar"; + gameNameTextBox.PlaceholderText = showSearch + ? "Enter a game name and press Enter to search" + : "e.g. Spacewar"; NativeMethods.RefreshCueBanner(gameNameTextBox); - // DLC group and Epic results share the same vertical slot - dlcGroupBox.Visible = !epic; - epicResultsListBox.Visible = false; // hidden until search runs + // Clear game name and results when switching mode + gameNameTextBox.Clear(); + epicResultsListBox.Visible = false; + ubisoftResultsListBox.Visible = false; if (!epic) epicSearchResults.Clear(); - SetStatus(epic - ? "Enter a game name and click Search to find it on the Epic store." - : "Enter the App ID, then tab out to auto-detect the game name."); + if (!ubisoft) + ubisoftSearchResults.Clear(); + + SetStatus(showSearch + ? "Enter a game name and press Enter to search." + : "Enter the App ID and press Enter to search."); } private void OnPlatformChanged(object sender, EventArgs e) => UpdatePlatformMode(); @@ -68,7 +84,7 @@ internal sealed partial class TestGameForm : CustomForm private async void OnAppIdLeave(object sender, EventArgs e) { - if (IsEpicMode) + if (IsEpicMode || IsUbisoftMode) return; string appId = appIdTextBox.Text.Trim(); if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _)) @@ -107,7 +123,7 @@ internal sealed partial class TestGameForm : CustomForm } else { - SetStatus("Could not auto-detect name — enter it manually."); + SetStatus("Could not auto-detect name; enter it manually."); } } @@ -157,53 +173,74 @@ internal sealed partial class TestGameForm : CustomForm SetStatus($"✓ Selected: {epicSearchResults[idx].name}"); } - // ── DLC (Steam) ────────────────────────────────────────────────────────── + // ── Ubisoft: search by name ────────────────────────────────────────────── - private void OnAddDlc(object sender, EventArgs e) + private async void OnUbisoftSearch(object sender, EventArgs e) { - string dlcId = dlcIdTextBox.Text.Trim(); - string dlcName = dlcNameTextBox.Text.Trim(); - if (string.IsNullOrWhiteSpace(dlcId) || !int.TryParse(dlcId, out _)) + string keyword = gameNameTextBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(keyword)) { - SetStatus("DLC ID must be a valid integer."); + SetStatus("Enter a game name to search."); return; } - if (string.IsNullOrWhiteSpace(dlcName)) + SetStatus("Searching Ubisoft store . . ."); + ubisoftSearchButton.Enabled = false; + generateButton.Enabled = false; + ubisoftResultsListBox.Items.Clear(); + ubisoftResultsListBox.Visible = false; + ubisoftSearchResults.Clear(); + + List<(string id, string name)> results = await UbisoftStore.QuerySearch(keyword); + + ubisoftSearchButton.Enabled = true; + generateButton.Enabled = true; + + if (results.Count == 0) { - SetStatus("DLC Name cannot be empty."); + SetStatus("No results found. Try a different name."); return; } - if (dlcEntries.Any(d => d.id == dlcId)) - { - SetStatus($"DLC ID {dlcId} is already in the list."); - return; - } + ubisoftSearchResults.AddRange(results); + foreach ((string _, string name) in results) + ubisoftResultsListBox.Items.Add(name); - dlcEntries.Add((dlcId, dlcName)); - RefreshDlcList(); - dlcIdTextBox.Clear(); - dlcNameTextBox.Clear(); - SetStatus($"Added DLC: {dlcId} = {dlcName}"); + ubisoftResultsListBox.Visible = true; + SetStatus($"Found {results.Count} result(s). Select one to use it."); } - private void OnRemoveDlc(object sender, EventArgs e) + private void OnUbisoftResultSelected(object sender, EventArgs e) { - if (dlcListBox.SelectedIndex < 0) + int idx = ubisoftResultsListBox.SelectedIndex; + if (idx < 0 || idx >= ubisoftSearchResults.Count) return; - dlcEntries.RemoveAt(dlcListBox.SelectedIndex); - RefreshDlcList(); - SetStatus("Removed selected DLC entry."); + gameNameTextBox.Text = ubisoftSearchResults[idx].name; + SetStatus($"✓ Selected: {ubisoftSearchResults[idx].name}"); } - private void OnDlcListBoxSelectionChanged(object sender, EventArgs e) { } + // ── Enter key handlers ─────────────────────────────────────────────────── - private void RefreshDlcList() + private void OnAppIdKeyDown(object sender, KeyEventArgs e) { - dlcListBox.Items.Clear(); - foreach ((string id, string name) in dlcEntries) - dlcListBox.Items.Add($"{id} = {name}"); + if (e.KeyCode == Keys.Enter && !IsEpicMode && !IsUbisoftMode) + { + e.SuppressKeyPress = true; + OnAppIdLeave(sender, e); + } + } + + private void OnGameNameKeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode != Keys.Enter) + return; + e.SuppressKeyPress = true; + if (IsEpicMode) + OnEpicSearch(sender, e); + else if (IsUbisoftMode) + OnUbisoftSearch(sender, e); + else + OnAppIdLeave(sender, e); } // ── Generate ──────────────────────────────────────────────────────────── @@ -212,6 +249,8 @@ internal sealed partial class TestGameForm : CustomForm { if (IsEpicMode) GenerateEpic(); + else if (IsUbisoftMode) + GenerateUbisoft(); else GenerateSteam(); } @@ -245,7 +284,7 @@ internal sealed partial class TestGameForm : CustomForm Directory.CreateDirectory(gameDir); string dllPath = Path.Combine(gameDir, "steam_api64.dll"); - WriteSteamApiStub(dllPath); + WriteDllStub(dllPath); CreatedDirectories.Add(gameDir); SteamLibrary.TestGames.Add((appId, gameName, "public", 1, gameDir)); @@ -293,7 +332,7 @@ internal sealed partial class TestGameForm : CustomForm // Stub DLL so Epic DLL-directory scanning finds the game string dllPath = Path.Combine(gameDir, "EOSSDK-Win64-Shipping.dll"); - WriteSteamApiStub(dllPath); + WriteDllStub(dllPath); CreatedDirectories.Add(gameDir); @@ -313,20 +352,86 @@ internal sealed partial class TestGameForm : CustomForm } } + private void GenerateUbisoft() + { + string gameName = gameNameTextBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(gameName)) + { + SetStatus("Game Name cannot be empty. Search for a game first."); + return; + } + + // Use the selected search result ID if available, otherwise derive a stub + string gameId; + int selIdx = ubisoftResultsListBox.SelectedIndex; + if (selIdx >= 0 && selIdx < ubisoftSearchResults.Count) + { + gameId = ubisoftSearchResults[selIdx].id; + gameName = ubisoftSearchResults[selIdx].name; + } + else + { + gameId = $"test_{SanitizeName(gameName).ToLowerInvariant()}"; + } + + if (UbisoftLibrary.TestGames.Any(g => g.gameId == gameId)) + { + SetStatus("An Ubisoft test game with that ID already exists."); + return; + } + + try + { + string gameDir = Path.Combine(TestGamesRoot, $"ubisoft_{SanitizeName(gameId)}_{SanitizeName(gameName)}"); + Directory.CreateDirectory(gameDir); + + // Write stub DLLs for both Uplay R1 and R2 unlocker detection + WriteDllStub(Path.Combine(gameDir, "uplay_r1_loader.dll")); + WriteDllStub(Path.Combine(gameDir, "uplay_r1_loader64.dll")); + WriteDllStub(Path.Combine(gameDir, "upc_r2_loader.dll")); + WriteDllStub(Path.Combine(gameDir, "upc_r2_loader64.dll")); + + CreatedDirectories.Add(gameDir); + UbisoftLibrary.TestGames.Add((gameId, gameName, gameDir)); + ProgramData.Log($"[TestGame] Ubisoft: {gameName} ({gameId}) at {gameDir}"); + SetStatus($"✓ Ubisoft test game '{gameName}' ({gameId}) generated. Press Rescan."); + } + catch (Exception ex) + { + SetStatus($"Error: {ex.Message}"); + } + } + // ── Clear / Close ──────────────────────────────────────────────────────── private void OnClearAll(object sender, EventArgs e) { SteamLibrary.TestGames.Clear(); EpicLibrary.TestManifests.Clear(); + UbisoftLibrary.TestGames.Clear(); foreach (string dir in CreatedDirectories) try { Directory.Delete(dir, true); } catch (Exception ex) { ProgramData.LogWarning($"[TestGame] Cleanup deletion failed for {dir}: {ex.Message}"); } CreatedDirectories.Clear(); - dlcEntries.Clear(); - RefreshDlcList(); + if (Directory.Exists(TestGamesRoot)) + try { Directory.Delete(TestGamesRoot, true); } catch (Exception ex) { ProgramData.LogWarning($"[TestGame] Cleanup failed to delete TestGames root: {ex.Message}"); } + // Remove any installed.json records for test games (e.g. if an unlocker was installed to a test game) + List installedRecords = ProgramData.ReadInstalledGames(); + int removed = installedRecords.RemoveAll(r => r.RootDirectory?.StartsWith(TestGamesRoot, StringComparison.OrdinalIgnoreCase) == true); + if (removed > 0) + { + ProgramData.WriteInstalledGames(installedRecords); + ProgramData.Log($"[TestGame] Removed {removed} stale installed-game record(s) from test games."); + } + // Remove any Selection entries under the TestGames root so the main game list updates immediately + foreach (Selection selection in Selection.All.Keys.ToHashSet().Where(s => + s.RootDirectory?.StartsWith(TestGamesRoot, StringComparison.OrdinalIgnoreCase) == true)) + selection.Remove(); epicSearchResults.Clear(); epicResultsListBox.Items.Clear(); epicResultsListBox.Visible = false; + ubisoftSearchResults.Clear(); + ubisoftResultsListBox.Items.Clear(); + ubisoftResultsListBox.Visible = false; SetStatus("All test games cleared. Press Rescan in the main window."); } @@ -337,8 +442,7 @@ internal sealed partial class TestGameForm : CustomForm private void SetStatus(string message) { statusLabel.Text = message; - statusLabel.ForeColor = message.StartsWith("✓", StringComparison.Ordinal) - ? System.Drawing.Color.Green + statusLabel.ForeColor = message.StartsWith('✓') ? System.Drawing.Color.Green : System.Drawing.Color.FromArgb(212, 212, 212); } @@ -348,7 +452,7 @@ internal sealed partial class TestGameForm : CustomForm return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); } - private static void WriteSteamApiStub(string path) + private static void WriteDllStub(string path) { byte[] mzStub = [ diff --git a/CreamInstaller/Platforms/Ubisoft/UbisoftLibrary.cs b/CreamInstaller/Platforms/Ubisoft/UbisoftLibrary.cs index 23216fe..f38fcb2 100644 --- a/CreamInstaller/Platforms/Ubisoft/UbisoftLibrary.cs +++ b/CreamInstaller/Platforms/Ubisoft/UbisoftLibrary.cs @@ -9,6 +9,8 @@ namespace CreamInstaller.Platforms.Ubisoft; internal static class UbisoftLibrary { + internal static readonly List<(string gameId, string name, string gameDirectory)> TestGames = []; + private static RegistryKey installsKey; private static RegistryKey InstallsKey @@ -25,15 +27,20 @@ internal static class UbisoftLibrary { List<(string gameId, string name, string gameDirectory)> games = new(); RegistryKey installsKey = InstallsKey; - if (installsKey is null) - return games; - foreach (string gameId in installsKey.GetSubKeyNames()) - { - RegistryKey installKey = installsKey.OpenSubKey(gameId); - string installDir = installKey?.GetValue("InstallDir")?.ToString()?.ResolvePath(); - if (installDir is not null && games.All(g => g.gameId != gameId)) - games.Add((gameId, new DirectoryInfo(installDir).Name, installDir)); - } + if (installsKey is not null) + foreach (string gameId in installsKey.GetSubKeyNames()) + { + RegistryKey installKey = installsKey.OpenSubKey(gameId); + string installDir = installKey?.GetValue("InstallDir")?.ToString()?.ResolvePath(); + if (installDir is not null && games.All(g => g.gameId != gameId)) + games.Add((gameId, new DirectoryInfo(installDir).Name, installDir)); + } + + foreach ((string gameId, string name, string gameDirectory) testGame in + TestGames.Where(t => games.All(g => g.gameId != t.gameId))) + games.Add(testGame); + if (TestGames.Count > 0) + ProgramData.Log($"[Ubisoft] Injected {TestGames.Count} test game(s)."); return games; }); diff --git a/CreamInstaller/Platforms/Ubisoft/UbisoftStore.cs b/CreamInstaller/Platforms/Ubisoft/UbisoftStore.cs new file mode 100644 index 0000000..7127809 --- /dev/null +++ b/CreamInstaller/Platforms/Ubisoft/UbisoftStore.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CreamInstaller.Utility; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace CreamInstaller.Platforms.Ubisoft; + +internal static class UbisoftStore +{ + private const string AlgoliaAppId = "XELY3U4LOD"; + private const string AlgoliaApiKey = "5638539fd9edb8f2c6b024b49ec375bd"; + + internal static async Task> QuerySearch(string keyword) + { + List<(string, string)> results = []; + try + { + string url = $"https://{AlgoliaAppId.ToLowerInvariant()}-dsn.algolia.net/1/indexes/*/queries" + + "?x-algolia-agent=Algolia%20for%20JavaScript%20(3.35.1)%3B%20Browser" + + $"&x-algolia-application-id={AlgoliaAppId}" + + $"&x-algolia-api-key={AlgoliaApiKey}"; + + var requestBody = new + { + requests = new[] + { + new + { + indexName = "us_product_suggestion", + query = keyword, + @params = "hitsPerPage=1000" + } + } + }; + + string payloadJson = JsonConvert.SerializeObject(requestBody); + using HttpContent content = new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json"); + HttpClient client = HttpClientManager.HttpClient; + if (client is null) + return results; + + HttpResponseMessage httpResponse = await client.PostAsync(new Uri(url), content); + _ = httpResponse.EnsureSuccessStatusCode(); + string response = await httpResponse.Content.ReadAsStringAsync(); + JObject root = JObject.Parse(response); + JToken hits = root["results"]?[0]?["hits"]; + if (hits is null) + return results; + + // Extract significant query words (2+ chars) for substring filtering + string[] queryTerms = keyword.Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Where(t => t.Length >= 2) + .Select(t => t.ToLowerInvariant()) + .ToArray(); + + foreach (JToken hit in hits) + { + string title = hit["title"]?.ToString(); + string id = hit["objectID"]?.ToString(); + string platform = hit["Platform"]?.ToString(); + string productType = hit["productTypeSelect"]?.ToString(); + string edition = hit["Edition"]?.ToString() ?? ""; + string game = hit["Game"]?.ToString() ?? ""; + + // Skip non-game items (currency, merchandise, etc.) + bool isGame = string.IsNullOrEmpty(productType) || + string.Equals(productType, "game", StringComparison.OrdinalIgnoreCase); + + // Skip DLCs, extensions, season passes, currency packs + if (!string.IsNullOrEmpty(edition) && + (edition.Contains("Extension", StringComparison.OrdinalIgnoreCase) || + edition.Contains("Season Pass", StringComparison.OrdinalIgnoreCase) || + edition.Contains("Currency", StringComparison.OrdinalIgnoreCase) || + edition.Contains("Pack", StringComparison.OrdinalIgnoreCase))) + continue; + + // Also skip items not available on PC + bool isPc = string.IsNullOrEmpty(platform) || + platform.Contains("PC", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(id) + || !isGame || !isPc || results.Any(r => r.Item1 == id)) + continue; + + // Post-filter: at least one query word must appear as a substring + // in the title or Game field. This eliminates Algolia's overly + // aggressive fuzzy/typo matches (e.g. "AssAss" → "Star Wars"). + if (queryTerms.Length > 0) + { + string lowerTitle = title.ToLowerInvariant(); + string lowerGame = game.ToLowerInvariant(); + if (!queryTerms.Any(t => lowerTitle.Contains(t) || lowerGame.Contains(t))) + continue; + } + + results.Add((id, title)); + } + } + catch + { + // ignored — search failure is non-fatal + } + + return results; + } +}