diff --git a/CreamInstaller/Components/CustomTreeView.cs b/CreamInstaller/Components/CustomTreeView.cs index aa54f98..b6e89a2 100644 --- a/CreamInstaller/Components/CustomTreeView.cs +++ b/CreamInstaller/Components/CustomTreeView.cs @@ -257,7 +257,58 @@ internal sealed class CustomTreeView : TreeView graphics.FillRectangle(brush, bounds); } - if (!Program.UseSmokeAPI) + // Unlocker badge + if (selection.InstalledUnlocker != InstalledUnlocker.None) + { + string badgeText = selection.InstalledUnlocker.ToString(); + size = TextRenderer.MeasureText(graphics, badgeText, font, Size.Empty, TextFormatFlags.NoPadding); + const int badgePadding = 3; + Rectangle badgeBounds = new(bounds.X + bounds.Width + 2, bounds.Y + 1, size.Width + badgePadding * 2, bounds.Height - 2); + selectionBounds = new(selectionBounds.Location, selectionBounds.Size + new Size(badgeBounds.Width + 2, 0)); + + // Get theme-appropriate colors for each unlocker from ThemeManager + Color badgeBack, badgeBorder; + switch (selection.InstalledUnlocker) + { + case InstalledUnlocker.SmokeAPI: + badgeBack = highlighted + ? ThemeManager.SmokeAPIBadgeBackgroundHighlightColor + : ThemeManager.SmokeAPIBadgeBackgroundColor; + badgeBorder = ThemeManager.SmokeAPIBadgeBorderColor; + break; + case InstalledUnlocker.CreamAPI: + badgeBack = highlighted + ? ThemeManager.CreamAPIBadgeBackgroundHighlightColor + : ThemeManager.CreamAPIBadgeBackgroundColor; + badgeBorder = ThemeManager.CreamAPIBadgeBorderColor; + break; + default: + badgeBack = highlighted + ? ThemeManager.DefaultBadgeBackgroundHighlightColor + : ThemeManager.DefaultBadgeBackgroundColor; + badgeBorder = ThemeManager.DefaultBadgeBorderColor; + break; + } + + using (SolidBrush badgeBrush = new(badgeBack)) + graphics.FillRectangle(badgeBrush, badgeBounds); + using (Pen badgePen = new(badgeBorder)) + graphics.DrawRectangle(badgePen, badgeBounds); + TextRenderer.DrawText(graphics, badgeText, font, + new Point(badgeBounds.X + badgePadding, badgeBounds.Y + 1), + Color.White, TextFormatFlags.NoPadding); + bounds = bounds with { X = badgeBounds.X, Width = badgeBounds.Width + 2 }; + } + + // Show Extra Protection checkbox for CreamAPI: + // - When CreamAPI is installed, OR + // - When no unlocker is installed yet AND user hasn't enabled SmokeAPI mode, OR + // - When SmokeAPI is installed BUT user has disabled SmokeAPI mode (about to replace with CreamAPI) + bool showExtraProtection = selection.InstalledUnlocker == InstalledUnlocker.CreamAPI || + (selection.InstalledUnlocker == InstalledUnlocker.None && !Program.UseSmokeAPI) || + (selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI && !Program.UseSmokeAPI); + + if (showExtraProtection) { CheckBoxState extraProtState = selection.UseExtraProtection ? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled) diff --git a/CreamInstaller/Forms/InstallForm.cs b/CreamInstaller/Forms/InstallForm.cs index 44a5566..e2374e4 100644 --- a/CreamInstaller/Forms/InstallForm.cs +++ b/CreamInstaller/Forms/InstallForm.cs @@ -9,7 +9,6 @@ using CreamInstaller.Resources; using CreamInstaller.Utility; using static CreamInstaller.Platforms.Paradox.ParadoxLauncher; using static CreamInstaller.Resources.Resources; - namespace CreamInstaller.Forms; internal sealed partial class InstallForm : CustomForm @@ -351,6 +350,41 @@ internal sealed partial class InstallForm : CustomForm ++completeOperationsCount; } + // Persist install/uninstall results + foreach (Selection selection in Selection.AllEnabled) + { + if (uninstalling) + { + selection.InstalledUnlocker = InstalledUnlocker.None; + ProgramData.RemoveInstalledGame(selection.Platform, selection.Id); + } + else + { + InstalledUnlocker unlocker = selection.DetectInstalledUnlocker(); + selection.InstalledUnlocker = unlocker; + if (unlocker != InstalledUnlocker.None) + ProgramData.UpsertInstalledGame(new InstalledGameRecord + { + Platform = selection.Platform, + Id = selection.Id, + Name = selection.Name, + RootDirectory = selection.RootDirectory, + Unlocker = unlocker, + UseProxy = selection.UseProxy, + Proxy = selection.Proxy, + UseExtraProtection = selection.UseExtraProtection, + Dlc = selection.DLC.Select(dlc => new InstalledDlcRecord + { + DlcType = dlc.Type.ToString(), + Id = dlc.Id, + Name = dlc.Name + }).ToList() + }); + } + } + + SelectForm.Current?.Invoke(() => SelectForm.Current?.InvalidateGameList()); + Program.Cleanup(); int activeCount = activeSelections.Count; if (activeCount > 0) diff --git a/CreamInstaller/Forms/SelectForm.cs b/CreamInstaller/Forms/SelectForm.cs index 99442fb..be71646 100644 --- a/CreamInstaller/Forms/SelectForm.cs +++ b/CreamInstaller/Forms/SelectForm.cs @@ -689,6 +689,7 @@ internal sealed partial class SelectForm : CustomForm } OnLoadSelections(null, null); + await LoadSavedInstalledGames(); HideProgressBar(); selectionTreeView.Enabled = !Selection.All.IsEmpty; allCheckBox.Enabled = selectionTreeView.Enabled; @@ -974,6 +975,79 @@ internal sealed partial class SelectForm : CustomForm contextMenuStrip.Refresh(); }); + private async Task LoadSavedInstalledGames() + { + List saved = ProgramData.ReadInstalledGames(); + if (saved.Count == 0) + return; + + List toRemove = []; + foreach (InstalledGameRecord record in saved) + { + // Already in the list from this scan — just ensure unlocker is set + Selection existing = Selection.FromId(record.Platform, record.Id); + if (existing is not null) + { + if (existing.InstalledUnlocker == InstalledUnlocker.None) + existing.InstalledUnlocker = record.Unlocker; + continue; + } + + // Root directory no longer exists — mark for removal + if (!record.RootDirectory.DirectoryExists()) + { + toRemove.Add(record); + continue; + } + + // Reconstruct a minimal Selection from the saved record + HashSet dllDirectories = + await record.RootDirectory.GetDllDirectoriesFromGameDirectory(record.Platform); + if (dllDirectories is null || dllDirectories.Count == 0) + { + toRemove.Add(record); + continue; + } + + List<(string directory, BinaryType binaryType)> executableDirectories = + await record.RootDirectory.GetExecutableDirectories(true); + + Selection selection = Selection.FromId(record.Platform, record.Id) ?? Selection.GetOrCreate(record.Platform, record.Id, record.Name, + record.RootDirectory, dllDirectories, executableDirectories); + selection.InstalledUnlocker = selection.DetectInstalledUnlocker(); + if (selection.InstalledUnlocker == InstalledUnlocker.None) + selection.InstalledUnlocker = record.Unlocker; + selection.UseProxy = record.UseProxy; + selection.Proxy = record.Proxy; + selection.UseExtraProtection = record.UseExtraProtection; + + Invoke(delegate + { + if (selection.TreeNode.TreeView is null) + _ = selectionTreeView.Nodes.Add(selection.TreeNode); + + // Restore DLC children from saved record + if (record.Dlc != null && record.Dlc.Count > 0) + { + foreach (InstalledDlcRecord dlcRecord in record.Dlc) + { + if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType)) + continue; + SelectionDLC dlc = SelectionDLC.GetOrCreate(dlcType, record.Id, dlcRecord.Id, dlcRecord.Name); + dlc.Selection = selection; + } + } + }); + } + + // Clean up records for games that are gone + if (toRemove.Count > 0) + { + List updated = saved.Except(toRemove).ToList(); + ProgramData.WriteInstalledGames(updated); + } + } + private void OnLoad(object sender, EventArgs _) { retry: @@ -1177,6 +1251,21 @@ internal sealed partial class SelectForm : CustomForm ProgramData.WriteExtraProtectionChoices(extraProtectionChoices); loadButton.Enabled = CanLoadSelections(); + // Detect installed unlockers from disk for all selections + foreach (Selection selection in Selection.All.Keys) + selection.InstalledUnlocker = selection.DetectInstalledUnlocker(); + + // Merge with persisted installed game records for any saved games not yet having a detected unlocker + List installedRecords = ProgramData.ReadInstalledGames(); + foreach (InstalledGameRecord record in installedRecords) + { + Selection selection = Selection.FromId(record.Platform, record.Id); + if (selection is null) + continue; + if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None) + selection.InstalledUnlocker = record.Unlocker; + } + OnProxyChanged(); } @@ -1206,6 +1295,8 @@ internal sealed partial class SelectForm : CustomForm OnProxyChanged(); } + internal void InvalidateGameList() => selectionTreeView.Invalidate(); + internal void OnProxyChanged() { selectionTreeView.Invalidate(); @@ -1257,7 +1348,9 @@ internal sealed partial class SelectForm : CustomForm private void OnUseSmokeAPICheckBoxChanged(object sender, EventArgs e) { Program.UseSmokeAPI = useSmokeAPICheckBox.Checked; - OnLoad(forceProvideChoices: false); + selectionTreeView.Invalidate(); + saveButton.Enabled = CanSaveSelections(); + resetButton.Enabled = CanResetSelections(); } private void OnUseSmokeAPIHelpButtonClicked(object sender, EventArgs e) diff --git a/CreamInstaller/Selection.cs b/CreamInstaller/Selection.cs index 1d46f34..63a3713 100644 --- a/CreamInstaller/Selection.cs +++ b/CreamInstaller/Selection.cs @@ -1,13 +1,16 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Windows.Forms; using CreamInstaller.Forms; +using CreamInstaller.Platforms.Epic; +using CreamInstaller.Platforms.Steam; +using CreamInstaller.Platforms.Ubisoft; using CreamInstaller.Resources; using CreamInstaller.Utility; using static CreamInstaller.Resources.Resources; - namespace CreamInstaller; public enum Platform @@ -41,6 +44,7 @@ internal sealed class Selection : IEquatable internal string Publisher; internal string SubIcon; internal string Website; + internal InstalledUnlocker InstalledUnlocker; internal IEnumerable GetAvailableProxies() { @@ -135,6 +139,100 @@ internal sealed class Selection : IEquatable internal static Selection FromId(Platform platform, string gameId) => All.Keys.FirstOrDefault(s => s.Platform == platform && s.Id == gameId); + internal InstalledUnlocker DetectInstalledUnlocker() + { + foreach (string directory in DllDirectories) + { + if (Platform is Platform.Steam or Platform.Paradox) + { + // Use uniquely-named config files to distinguish CreamAPI from SmokeAPI. + // Both share steam_api_o.dll so the _o files alone are ambiguous. + directory.GetSmokeApiComponents(out _, out _, out _, out _, out string smokeOldConfig, + out string smokeConfig, out _, out _, out _); + if (smokeConfig.FileExists() || smokeOldConfig.FileExists()) + return InstalledUnlocker.SmokeAPI; + + directory.GetCreamApiComponents(out _, out _, out _, out _, out string creamConfig); + if (creamConfig.FileExists()) + { + ReadCreamApiConfig(creamConfig); + return InstalledUnlocker.CreamAPI; + } + + // Fallback: config was deleted but _o files remain — identify by replacement DLL content + directory.GetSmokeApiComponents(out string smokeApi32, out string api32_o, + out string smokeApi64, out string api64_o, out _, out _, out _, out _, out _); + if (api32_o.FileExists() || api64_o.FileExists()) + { + if ((smokeApi32.FileExists() && smokeApi32.IsResourceFile(ResourceIdentifier.Steamworks32)) + || (smokeApi64.FileExists() && smokeApi64.IsResourceFile(ResourceIdentifier.Steamworks64))) + return InstalledUnlocker.SmokeAPI; + return InstalledUnlocker.CreamAPI; + } + } + + if (Platform is Platform.Epic or Platform.Paradox) + { + directory.GetScreamApiComponents(out _, out string api32_o, out _, out string api64_o, + out _, out string config, out _, out _); + if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists()) + return InstalledUnlocker.ScreamAPI; + } + + if (Platform is Platform.Ubisoft) + { + directory.GetUplayR1Components(out _, out string api32_o, out _, out string api64_o, + out string config, out _); + if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists()) + return InstalledUnlocker.UplayR1; + directory.GetUplayR2Components(out _, out _, out _, out api32_o, out _, out api64_o, + out config, out _); + if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists()) + return InstalledUnlocker.UplayR2; + } + } + + foreach ((string directory, _) in ExecutableDirectories) + { + directory.GetKoaloaderComponents(out _, out string config, out _); + if (directory.GetKoaloaderProxies().Any(proxy => + proxy.FileExists() && proxy.IsResourceFile(ResourceIdentifier.Koaloader)) + || config.FileExists()) + return InstalledUnlocker.Koaloader; + } + + return InstalledUnlocker.None; + } + + private void ReadCreamApiConfig(string configPath) + { + try + { + if (!configPath.FileExists()) + return; + + string[] lines = File.ReadAllLines(configPath); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith("extraprotection", StringComparison.OrdinalIgnoreCase)) + { + string[] parts = trimmed.Split('='); + if (parts.Length == 2) + { + string value = parts[1].Trim(); + UseExtraProtection = value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + break; + } + } + } + catch + { + // If we can't read the config, leave UseExtraProtection at its default value + } + } + public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is Selection other && Equals(other); public override int GetHashCode() => HashCode.Combine(Id, (int)Platform); diff --git a/CreamInstaller/SelectionDLC.cs b/CreamInstaller/SelectionDLC.cs index 4548728..e5382e0 100644 --- a/CreamInstaller/SelectionDLC.cs +++ b/CreamInstaller/SelectionDLC.cs @@ -22,17 +22,20 @@ internal sealed class SelectionDLC : IEquatable internal readonly string Name; internal readonly TreeNode TreeNode; internal readonly DLCType Type; + internal readonly string GameId; internal string Icon; internal string Product; internal string Publisher; private Selection selection; - private SelectionDLC(DLCType type, string id, string name) + private SelectionDLC(DLCType type, string gameId, string id, string name) { Type = type; + GameId = gameId; Id = id; Name = name; TreeNode = new() { Tag = Type, Name = Id, Text = Name }; + _ = All.TryAdd(this, 0); } internal bool Enabled @@ -65,15 +68,15 @@ internal sealed class SelectionDLC : IEquatable public bool Equals(SelectionDLC other) => other is not null && (ReferenceEquals(this, other) || - Type == other.Type && Selection?.Id == other.Selection?.Id && Id == other.Id); + Type == other.Type && GameId == other.GameId && Id == other.Id); internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name) - => FromId(type, gameId, id) ?? new SelectionDLC(type, id, name); + => FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name); internal static SelectionDLC FromId(DLCType type, string gameId, string dlcId) - => All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.Selection?.Id == gameId && dlc.Id == dlcId); + => All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.GameId == gameId && dlc.Id == dlcId); public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is SelectionDLC other && Equals(other); - public override int GetHashCode() => HashCode.Combine((int)Type, Selection?.Id, Id); + public override int GetHashCode() => HashCode.Combine((int)Type, GameId, Id); } \ No newline at end of file diff --git a/CreamInstaller/Utility/ProgramData.cs b/CreamInstaller/Utility/ProgramData.cs index 7031c52..3c1e3fa 100644 --- a/CreamInstaller/Utility/ProgramData.cs +++ b/CreamInstaller/Utility/ProgramData.cs @@ -6,10 +6,42 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; +using CreamInstaller; using Newtonsoft.Json; namespace CreamInstaller.Utility; +internal enum InstalledUnlocker +{ + None = 0, + CreamAPI, + SmokeAPI, + ScreamAPI, + UplayR1, + UplayR2, + Koaloader +} + +internal sealed class InstalledDlcRecord +{ + public string DlcType { get; set; } + public string Id { get; set; } + public string Name { get; set; } +} + +internal sealed class InstalledGameRecord +{ + public Platform Platform { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string RootDirectory { get; set; } + public InstalledUnlocker Unlocker { get; set; } + public bool UseProxy { get; set; } + public string Proxy { get; set; } + public bool UseExtraProtection { get; set; } + public List Dlc { get; set; } = []; +} + internal static class ProgramData { private static readonly string DirectoryPathOld = @@ -30,6 +62,7 @@ internal static class ProgramData private static readonly string DlcChoicesPath = DirectoryPath + @"\dlc.json"; private static readonly string KoaloaderProxyChoicesPath = DirectoryPath + @"\proxies.json"; private static readonly string ExtraProtectionChoicesPath = DirectoryPath + @"\extraprotection.json"; + private static readonly string InstalledGamesPath = DirectoryPath + @"\installed.json"; internal static readonly string LogPath = DirectoryPath + @"\scan.log"; @@ -263,4 +296,52 @@ internal static class ProgramData // ignored } } + + internal static List ReadInstalledGames() + { + if (InstalledGamesPath.FileExists()) + try + { + if (JsonConvert.DeserializeObject>(InstalledGamesPath.ReadFile()) is + { } records) + return records; + } + catch + { + // ignored + } + + return []; + } + + internal static void WriteInstalledGames(IEnumerable records) + { + try + { + List list = records?.ToList() ?? []; + if (list.Count == 0) + InstalledGamesPath.DeleteFile(); + else + InstalledGamesPath.WriteFile(JsonConvert.SerializeObject(list, Formatting.Indented)); + } + catch + { + // ignored + } + } + + internal static void UpsertInstalledGame(InstalledGameRecord record) + { + List records = ReadInstalledGames(); + _ = records.RemoveAll(r => r.Platform == record.Platform && r.Id == record.Id); + records.Add(record); + WriteInstalledGames(records); + } + + internal static void RemoveInstalledGame(Platform platform, string id) + { + List records = ReadInstalledGames(); + if (records.RemoveAll(r => r.Platform == platform && r.Id == id) > 0) + WriteInstalledGames(records); + } } \ No newline at end of file diff --git a/CreamInstaller/Utility/ThemeManager.cs b/CreamInstaller/Utility/ThemeManager.cs index 4ab88e8..8f7ac29 100644 --- a/CreamInstaller/Utility/ThemeManager.cs +++ b/CreamInstaller/Utility/ThemeManager.cs @@ -31,6 +31,17 @@ internal static class ThemeManager private static readonly Color DarkComboBorder = DarkBorder; // #3F3F46 private static readonly Color DarkComboText = DarkFore; // #D4D4D4 + // Badge colors for unlockers + private static readonly Color CreamAPIBadgeBack = ColorTranslator.FromHtml("#C8A078"); // Creamy latte + private static readonly Color CreamAPIBadgeBackHighlight = ColorTranslator.FromHtml("#B48C64"); + private static readonly Color CreamAPIBadgeBorder = ColorTranslator.FromHtml("#DCB48C"); + private static readonly Color SmokeAPIBadgeBack = ColorTranslator.FromHtml("#69696E"); // Smoky grey + private static readonly Color SmokeAPIBadgeBackHighlight = ColorTranslator.FromHtml("#5A5A5F"); + private static readonly Color SmokeAPIBadgeBorder = ColorTranslator.FromHtml("#8C8C91"); + private static readonly Color DefaultBadgeBack = ColorTranslator.FromHtml("#008C46"); // Default green + private static readonly Color DefaultBadgeBackHighlight = ColorTranslator.FromHtml("#00783C"); + private static readonly Color DefaultBadgeBorder = ColorTranslator.FromHtml("#00B45A"); + // ---------------------------- // Light mode colors (system defaults) // ---------------------------- @@ -76,6 +87,17 @@ internal static class ThemeManager internal static Color CustomTreeViewComboTextColor => IsDark ? DarkComboText : LightComboText; + // Badge colors for unlockers + internal static Color CreamAPIBadgeBackgroundColor => CreamAPIBadgeBack; + internal static Color CreamAPIBadgeBackgroundHighlightColor => CreamAPIBadgeBackHighlight; + internal static Color CreamAPIBadgeBorderColor => CreamAPIBadgeBorder; + internal static Color SmokeAPIBadgeBackgroundColor => SmokeAPIBadgeBack; + internal static Color SmokeAPIBadgeBackgroundHighlightColor => SmokeAPIBadgeBackHighlight; + internal static Color SmokeAPIBadgeBorderColor => SmokeAPIBadgeBorder; + internal static Color DefaultBadgeBackgroundColor => DefaultBadgeBack; + internal static Color DefaultBadgeBackgroundHighlightColor => DefaultBadgeBackHighlight; + internal static Color DefaultBadgeBorderColor => DefaultBadgeBorder; + // ----------------------------------------------------------------- // Public / Internal API // -----------------------------------------------------------------