diff --git a/Dalamud/Configuration/Internal/AutoUpdatePreference.cs b/Dalamud/Configuration/Internal/AutoUpdatePreference.cs new file mode 100644 index 000000000..2b7dced11 --- /dev/null +++ b/Dalamud/Configuration/Internal/AutoUpdatePreference.cs @@ -0,0 +1,42 @@ +namespace Dalamud.Configuration.Internal; + +/// +/// Class representing a plugin that has opted in to auto-updating. +/// +internal class AutoUpdatePreference +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique ID representing the plugin. + public AutoUpdatePreference(Guid pluginId) + { + this.WorkingPluginId = pluginId; + } + + /// + /// The kind of opt-in. + /// + public enum OptKind + { + /// + /// Never auto-update this plugin. + /// + NeverUpdate, + + /// + /// Always auto-update this plugin, regardless of the user's settings. + /// + AlwaysUpdate, + } + + /// + /// Gets or sets the unique ID representing the plugin. + /// + public Guid WorkingPluginId { get; set; } + + /// + /// Gets or sets the type of opt-in. + /// + public OptKind Kind { get; set; } = OptKind.AlwaysUpdate; +} diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 0267042ed..e5348d999 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -8,9 +8,9 @@ using System.Runtime.InteropServices; using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; -using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Style; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.AutoUpdate; using Dalamud.Plugin.Internal.Profiles; using Dalamud.Storage; using Dalamud.Utility; @@ -196,6 +196,7 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// /// Gets or sets a value indicating whether or not plugins should be auto-updated. /// + [Obsolete("Use AutoUpdateBehavior instead.")] public bool AutoUpdatePlugins { get; set; } /// @@ -229,7 +230,7 @@ internal sealed class DalamudConfiguration : IInternalDisposableService public int LogLinesLimit { get; set; } = 10000; /// - /// Gets or sets a list of commands that have been run in the console window. + /// Gets or sets a list representing the command history for the Dalamud Console. /// public List LogCommandHistory { get; set; } = new(); @@ -434,7 +435,12 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// /// Gets or sets a list of plugins that testing builds should be downloaded for. /// - public List? PluginTestingOptIns { get; set; } + public List PluginTestingOptIns { get; set; } = []; + + /// + /// Gets or sets a list of plugins that have opted into or out of auto-updating. + /// + public List PluginAutoUpdatePreferences { get; set; } = []; /// /// Gets or sets a value indicating whether the FFXIV window should be toggled to immersive mode. @@ -466,6 +472,16 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// public PluginInstallerOpenKind PluginInstallerOpen { get; set; } = PluginInstallerOpenKind.AllPlugins; + /// + /// Gets or sets a value indicating how auto-updating should behave. + /// + public AutoUpdateBehavior? AutoUpdateBehavior { get; set; } = null; + + /// + /// Gets or sets a value indicating whether or not users should be notified regularly about pending updates. + /// + public bool CheckPeriodicallyForUpdates { get; set; } = true; + /// /// Load a configuration from the provided path. /// @@ -551,6 +567,7 @@ internal sealed class DalamudConfiguration : IInternalDisposableService private void SetDefaults() { +#pragma warning disable CS0618 // "Reduced motion" if (!this.ReduceMotions.HasValue) { @@ -572,6 +589,12 @@ internal sealed class DalamudConfiguration : IInternalDisposableService this.ReduceMotions = winAnimEnabled == 0; } } + + // Migrate old auto-update setting to new auto-update behavior + this.AutoUpdateBehavior ??= this.AutoUpdatePlugins + ? Plugin.Internal.AutoUpdate.AutoUpdateBehavior.UpdateAll + : Plugin.Internal.AutoUpdate.AutoUpdateBehavior.OnlyNotify; +#pragma warning restore CS0618 } private void Save() diff --git a/Dalamud/Console/ConsoleManagerPluginScoped.cs b/Dalamud/Console/ConsoleManagerPluginScoped.cs index 755dd23e0..b338ae0d8 100644 --- a/Dalamud/Console/ConsoleManagerPluginScoped.cs +++ b/Dalamud/Console/ConsoleManagerPluginScoped.cs @@ -31,7 +31,6 @@ public class ConsoleManagerPluginScoped : IConsole, IInternalDisposableService /// Initializes a new instance of the class. /// /// The plugin this service belongs to. - /// The console manager. [ServiceManager.ServiceConstructor] internal ConsoleManagerPluginScoped(LocalPlugin plugin) { diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index 7f93ce6c2..09d825552 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -30,40 +30,6 @@ namespace Dalamud.Game; [ServiceManager.EarlyLoadedService] internal class ChatHandlers : IServiceType { - // private static readonly Dictionary UnicodeToDiscordEmojiDict = new() - // { - // { "", "<:ffxive071:585847382210642069>" }, - // { "", "<:ffxive083:585848592699490329>" }, - // }; - - // private readonly Dictionary handledChatTypeColors = new() - // { - // { XivChatType.CrossParty, Color.DodgerBlue }, - // { XivChatType.Party, Color.DodgerBlue }, - // { XivChatType.FreeCompany, Color.DeepSkyBlue }, - // { XivChatType.CrossLinkShell1, Color.ForestGreen }, - // { XivChatType.CrossLinkShell2, Color.ForestGreen }, - // { XivChatType.CrossLinkShell3, Color.ForestGreen }, - // { XivChatType.CrossLinkShell4, Color.ForestGreen }, - // { XivChatType.CrossLinkShell5, Color.ForestGreen }, - // { XivChatType.CrossLinkShell6, Color.ForestGreen }, - // { XivChatType.CrossLinkShell7, Color.ForestGreen }, - // { XivChatType.CrossLinkShell8, Color.ForestGreen }, - // { XivChatType.Ls1, Color.ForestGreen }, - // { XivChatType.Ls2, Color.ForestGreen }, - // { XivChatType.Ls3, Color.ForestGreen }, - // { XivChatType.Ls4, Color.ForestGreen }, - // { XivChatType.Ls5, Color.ForestGreen }, - // { XivChatType.Ls6, Color.ForestGreen }, - // { XivChatType.Ls7, Color.ForestGreen }, - // { XivChatType.Ls8, Color.ForestGreen }, - // { XivChatType.TellIncoming, Color.HotPink }, - // { XivChatType.PvPTeam, Color.SandyBrown }, - // { XivChatType.Urgent, Color.DarkViolet }, - // { XivChatType.NoviceNetwork, Color.SaddleBrown }, - // { XivChatType.Echo, Color.Gray }, - // }; - private static readonly ModuleLog Log = new("CHATHANDLER"); private readonly Regex rmtRegex = new( @@ -106,8 +72,6 @@ internal class ChatHandlers : IServiceType private readonly Regex urlRegex = new(@"(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?", RegexOptions.Compiled); - private readonly DalamudLinkPayload openInstallerWindowLink; - [ServiceManager.ServiceDependency] private readonly Dalamud dalamud = Service.Get(); @@ -115,19 +79,12 @@ internal class ChatHandlers : IServiceType private readonly DalamudConfiguration configuration = Service.Get(); private bool hasSeenLoadingMsg; - private bool startedAutoUpdatingPlugins; - private CancellationTokenSource deferredAutoUpdateCts = new(); [ServiceManager.ServiceConstructor] private ChatHandlers(ChatGui chatGui) { chatGui.CheckMessageHandled += this.OnCheckMessageHandled; chatGui.ChatMessage += this.OnChatMessage; - - this.openInstallerWindowLink = chatGui.AddChatLinkHandler("Dalamud", 1001, (i, m) => - { - Service.GetNullable()?.OpenPluginInstallerTo(PluginInstallerOpenKind.InstalledPlugins); - }); } /// @@ -135,11 +92,6 @@ internal class ChatHandlers : IServiceType /// public string? LastLink { get; private set; } - /// - /// Gets a value indicating whether or not auto-updates have already completed this session. - /// - public bool IsAutoUpdateComplete { get; private set; } - private void OnCheckMessageHandled(XivChatType type, uint senderid, ref SeString sender, ref SeString message, ref bool isHandled) { var textVal = message.TextValue; @@ -176,9 +128,6 @@ internal class ChatHandlers : IServiceType { if (!this.hasSeenLoadingMsg) this.PrintWelcomeMessage(); - - if (!this.startedAutoUpdatingPlugins) - this.AutoUpdatePluginsWithRetry(); } // For injections while logged in @@ -273,89 +222,4 @@ internal class ChatHandlers : IServiceType this.hasSeenLoadingMsg = true; } - - private void AutoUpdatePluginsWithRetry() - { - var firstAttempt = this.AutoUpdatePlugins(); - if (!firstAttempt) - { - Task.Run(() => - { - Task.Delay(30_000, this.deferredAutoUpdateCts.Token); - this.AutoUpdatePlugins(); - }); - } - } - - private bool AutoUpdatePlugins() - { - var chatGui = Service.GetNullable(); - var pluginManager = Service.GetNullable(); - var notifications = Service.GetNullable(); - var condition = Service.GetNullable(); - - if (chatGui == null || pluginManager == null || notifications == null || condition == null) - { - Log.Warning("Aborting auto-update because a required service was not loaded."); - return false; - } - - if (condition.Any(ConditionFlag.BoundByDuty, ConditionFlag.BoundByDuty56, ConditionFlag.BoundByDuty95)) - { - Log.Warning("Aborting auto-update because the player is in a duty."); - return false; - } - - if (!pluginManager.ReposReady || !pluginManager.InstalledPlugins.Any() || !pluginManager.AvailablePlugins.Any()) - { - // Plugins aren't ready yet. - // TODO: We should retry. This sucks, because it means we won't ever get here again until another notice. - Log.Warning("Aborting auto-update because plugins weren't loaded or ready."); - return false; - } - - this.startedAutoUpdatingPlugins = true; - - Log.Debug("Beginning plugin auto-update process..."); - Task.Run(() => pluginManager.UpdatePluginsAsync(true, !this.configuration.AutoUpdatePlugins, true)).ContinueWith(task => - { - this.IsAutoUpdateComplete = true; - - if (task.IsFaulted) - { - Log.Error(task.Exception, Loc.Localize("DalamudPluginUpdateCheckFail", "Could not check for plugin updates.")); - return; - } - - var updatedPlugins = task.Result.ToList(); - if (updatedPlugins.Any()) - { - if (this.configuration.AutoUpdatePlugins) - { - Service.Get().PrintUpdatedPlugins(updatedPlugins, Loc.Localize("DalamudPluginAutoUpdate", "Auto-update:")); - notifications.AddNotification(Loc.Localize("NotificationUpdatedPlugins", "{0} of your plugins were updated.").Format(updatedPlugins.Count), Loc.Localize("NotificationAutoUpdate", "Auto-Update"), NotificationType.Info); - } - else - { - chatGui.Print(new XivChatEntry - { - Message = new SeString(new List() - { - new TextPayload(Loc.Localize("DalamudPluginUpdateRequired", "One or more of your plugins needs to be updated. Please use the /xlplugins command in-game to update them!")), - new TextPayload(" ["), - new UIForegroundPayload(500), - this.openInstallerWindowLink, - new TextPayload(Loc.Localize("DalamudInstallerHelp", "Open the plugin installer")), - RawPayload.LinkTerminator, - new UIForegroundPayload(0), - new TextPayload("]"), - }), - Type = XivChatType.Urgent, - }); - } - } - }); - - return true; - } } diff --git a/Dalamud/Interface/DalamudWindowOpenKinds.cs b/Dalamud/Interface/DalamudWindowOpenKinds.cs index 18ecf5386..1f82cca49 100644 --- a/Dalamud/Interface/DalamudWindowOpenKinds.cs +++ b/Dalamud/Interface/DalamudWindowOpenKinds.cs @@ -35,6 +35,11 @@ public enum SettingsOpenKind /// Open to the "Look & Feel" page. /// LookAndFeel, + + /// + /// Open to the "Auto Updates" page. + /// + AutoUpdates, /// /// Open to the "Server Info Bar" page. diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 2eb8299b3..f57355112 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -211,6 +211,15 @@ internal class DalamudInterface : IInternalDisposableService set => this.isImGuiDrawDevMenu = value; } + /// + /// Gets or sets a value indicating whether the plugin installer is open. + /// + public bool IsPluginInstallerOpen + { + get => this.pluginWindow.IsOpen; + set => this.pluginWindow.IsOpen = value; + } + /// void IInternalDisposableService.DisposeService() { diff --git a/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.Buttons.cs b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.Buttons.cs new file mode 100644 index 000000000..d525af484 --- /dev/null +++ b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.Buttons.cs @@ -0,0 +1,56 @@ +using System.Numerics; + +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.DesignSystem; + +/// +/// Private ImGui widgets for use inside Dalamud. +/// +internal static partial class DalamudComponents +{ + private static readonly Vector2 ButtonPadding = new(8 * ImGuiHelpers.GlobalScale, 6 * ImGuiHelpers.GlobalScale); + private static readonly Vector4 SecondaryButtonBackground = new(0, 0, 0, 0); + + private static Vector4 PrimaryButtonBackground => ImGuiColors.TankBlue; + + /// + /// Draw a "primary style" button. + /// + /// The text to show. + /// True if the button was clicked. + internal static bool PrimaryButton(string text) + { + using (ImRaii.PushColor(ImGuiCol.Button, PrimaryButtonBackground)) + { + return Button(text); + } + } + + /// + /// Draw a "secondary style" button. + /// + /// The text to show. + /// True if the button was clicked. + internal static bool SecondaryButton(string text) + { + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1)) + using (var buttonColor = ImRaii.PushColor(ImGuiCol.Button, SecondaryButtonBackground)) + { + buttonColor.Push(ImGuiCol.Border, ImGuiColors.DalamudGrey3); + return Button(text); + } + } + + private static bool Button(string text) + { + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, ButtonPadding)) + { + return ImGui.Button(text); + } + } +} diff --git a/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.PluginPicker.cs b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.PluginPicker.cs new file mode 100644 index 000000000..f0ce6bc82 --- /dev/null +++ b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.PluginPicker.cs @@ -0,0 +1,79 @@ +using System.Linq; +using System.Numerics; + +using CheapLoc; + +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.DesignSystem; + +/// +/// Private ImGui widgets for use inside Dalamud. +/// +internal static partial class DalamudComponents +{ + /// + /// Draw a "picker" popup to chose a plugin. + /// + /// The ID of the popup. + /// String holding the search input. + /// Action to be called if a plugin is clicked. + /// Function that should return true if a plugin should show as disabled. + /// Function that should return true if a plugin should not appear in the list. + /// An ImGuiID to open the popup. + internal static uint DrawPluginPicker(string id, ref string pickerSearch, Action onClicked, Func pluginDisabled, Func? pluginFiltered = null) + { + var pm = Service.GetNullable(); + if (pm == null) + return 0; + + var addPluginToProfilePopupId = ImGui.GetID(id); + using var popup = ImRaii.Popup(id); + + if (popup.Success) + { + var width = ImGuiHelpers.GlobalScale * 300; + + ImGui.SetNextItemWidth(width); + ImGui.InputTextWithHint("###pluginPickerSearch", Locs.SearchHint, ref pickerSearch, 255); + + var currentSearchString = pickerSearch; + if (ImGui.BeginListBox("###pluginPicker", new Vector2(width, width - 80))) + { + // TODO: Plugin searching should be abstracted... installer and this should use the same search + var plugins = pm.InstalledPlugins.Where( + x => x.Manifest.SupportsProfiles && + (currentSearchString.IsNullOrWhitespace() || x.Manifest.Name.Contains( + currentSearchString, + StringComparison.InvariantCultureIgnoreCase))) + .Where(pluginFiltered ?? (_ => true)); + + foreach (var plugin in plugins) + { + using var disabled2 = + ImRaii.Disabled(pluginDisabled(plugin)); + + if (ImGui.Selectable($"{plugin.Manifest.Name}{(plugin is LocalDevPlugin ? "(dev plugin)" : string.Empty)}###selector{plugin.Manifest.InternalName}")) + { + onClicked(plugin); + } + } + + ImGui.EndListBox(); + } + } + + return addPluginToProfilePopupId; + } + + private static partial class Locs + { + public static string SearchHint => Loc.Localize("ProfileManagerSearchHint", "Search..."); + } +} diff --git a/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.cs b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.cs new file mode 100644 index 000000000..be3c90640 --- /dev/null +++ b/Dalamud/Interface/Internal/DesignSystem/DalamudComponents.cs @@ -0,0 +1,8 @@ +namespace Dalamud.Interface.Internal.DesignSystem; + +/// +/// Private ImGui widgets for use inside Dalamud. +/// +internal static partial class DalamudComponents +{ +} diff --git a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs index ae59db36a..613fc7d28 100644 --- a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs @@ -1,6 +1,8 @@ using System.Linq; using System.Numerics; +using CheapLoc; + using Dalamud.Configuration.Internal; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; @@ -12,6 +14,7 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.AutoUpdate; using Dalamud.Storage.Assets; using Dalamud.Utility; using ImGuiNET; @@ -23,6 +26,8 @@ namespace Dalamud.Interface.Internal.Windows; /// internal sealed class ChangelogWindow : Window, IDisposable { + private const AutoUpdateBehavior DefaultAutoUpdateBehavior = AutoUpdateBehavior.UpdateMainRepo; + private const string WarrantsChangelogForMajorMinor = "9.0."; private const string ChangeLog = @@ -47,15 +52,32 @@ internal sealed class ChangelogWindow : Window, IDisposable Point2 = new Vector2(2f), }; - private readonly InOutCubic bodyFade = new(TimeSpan.FromSeconds(1f)) + private readonly InOutCubic bodyFade = new(TimeSpan.FromSeconds(1.3f)) { Point1 = Vector2.Zero, Point2 = Vector2.One, }; + private readonly InOutCubic titleFade = new(TimeSpan.FromSeconds(1f)) + { + Point1 = Vector2.Zero, + Point2 = Vector2.One, + }; + + private readonly InOutCubic fadeOut = new(TimeSpan.FromSeconds(0.8f)) + { + Point1 = Vector2.One, + Point2 = Vector2.Zero, + }; + private State state = State.WindowFadeIn; - + private bool needFadeRestart = false; + + private bool isFadingOutForStateChange = false; + private State? stateAfterFadeOut; + + private AutoUpdateBehavior autoUpdateBehavior = DefaultAutoUpdateBehavior; /// /// Initializes a new instance of the class. @@ -90,6 +112,7 @@ internal sealed class ChangelogWindow : Window, IDisposable WindowFadeIn, ExplainerIntro, ExplainerApiBump, + AskAutoUpdate, Links, } @@ -114,12 +137,19 @@ internal sealed class ChangelogWindow : Window, IDisposable this.tsmWindow.AllowDrawing = false; _ = this.bannerFont; + + this.isFadingOutForStateChange = false; + this.stateAfterFadeOut = null; this.state = State.WindowFadeIn; this.windowFade.Reset(); this.bodyFade.Reset(); + this.titleFade.Reset(); + this.fadeOut.Reset(); this.needFadeRestart = true; + this.autoUpdateBehavior = DefaultAutoUpdateBehavior; + base.OnOpen(); } @@ -130,6 +160,10 @@ internal sealed class ChangelogWindow : Window, IDisposable this.tsmWindow.AllowDrawing = true; Service.Get().SetCreditsDarkeningAnimation(false); + + var configuration = Service.Get(); + configuration.AutoUpdateBehavior = this.autoUpdateBehavior; + configuration.QueueSave(); } /// @@ -144,10 +178,13 @@ internal sealed class ChangelogWindow : Window, IDisposable if (this.needFadeRestart) { this.windowFade.Restart(); + this.titleFade.Restart(); this.needFadeRestart = false; } this.windowFade.Update(); + this.titleFade.Update(); + this.fadeOut.Update(); ImGui.SetNextWindowBgAlpha(Math.Clamp(this.windowFade.EasedPoint.X, 0, 0.9f)); this.Size = new Vector2(900, 400); @@ -207,8 +244,9 @@ internal sealed class ChangelogWindow : Window, IDisposable return; ImGuiHelpers.ScaledDummy(20); - - using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 1f, 0f, 1f))) + + var titleFadeVal = this.isFadingOutForStateChange ? this.fadeOut.EasedPoint.X : this.titleFade.EasedPoint.X; + using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(titleFadeVal, 0f, 1f))) { using var font = this.bannerFont.Value.Push(); @@ -223,6 +261,10 @@ internal sealed class ChangelogWindow : Window, IDisposable ImGuiHelpers.CenteredText("Plugin Updates"); break; + case State.AskAutoUpdate: + ImGuiHelpers.CenteredText("Auto-Updates"); + break; + case State.Links: ImGuiHelpers.CenteredText("Enjoy!"); break; @@ -236,10 +278,30 @@ internal sealed class ChangelogWindow : Window, IDisposable this.state = State.ExplainerIntro; this.bodyFade.Restart(); } + + if (this.isFadingOutForStateChange && this.fadeOut.IsDone) + { + this.state = this.stateAfterFadeOut ?? throw new Exception("State after fade out is null"); + + this.bodyFade.Restart(); + this.titleFade.Restart(); + + this.isFadingOutForStateChange = false; + this.stateAfterFadeOut = null; + } this.bodyFade.Update(); - using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.bodyFade.EasedPoint.X, 0, 1f))) + var bodyFadeVal = this.isFadingOutForStateChange ? this.fadeOut.EasedPoint.X : this.bodyFade.EasedPoint.X; + using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(bodyFadeVal, 0, 1f))) { + void GoToNextState(State nextState) + { + this.isFadingOutForStateChange = true; + this.stateAfterFadeOut = nextState; + + this.fadeOut.Restart(); + } + void DrawNextButton(State nextState) { // Draw big, centered next button at the bottom of the window @@ -249,10 +311,9 @@ internal sealed class ChangelogWindow : Window, IDisposable ImGui.SetCursorPosY(windowSize.Y - buttonHeight - (20 * ImGuiHelpers.GlobalScale)); ImGuiHelpers.CenterCursorFor((int)buttonWidth); - if (ImGui.Button(buttonText, new Vector2(buttonWidth, buttonHeight))) + if (ImGui.Button(buttonText, new Vector2(buttonWidth, buttonHeight)) && !this.isFadingOutForStateChange) { - this.state = nextState; - this.bodyFade.Restart(); + GoToNextState(nextState); } } @@ -286,7 +347,65 @@ internal sealed class ChangelogWindow : Window, IDisposable this.apiBumpExplainerTexture.Value.ImGuiHandle, this.apiBumpExplainerTexture.Value.Size); - DrawNextButton(State.Links); + DrawNextButton(State.AskAutoUpdate); + break; + + case State.AskAutoUpdate: + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateHint", + "Dalamud can update your plugins automatically, making sure that you always " + + "have the newest features and bug fixes. You can choose when and how auto-updates are run here.")); + ImGuiHelpers.ScaledDummy(2); + + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsAutoUpdateDisclaimer1", + "You can always update your plugins manually by clicking the update button in the plugin list. " + + "You can also opt into updates for specific plugins by right-clicking them and selecting \"Always auto-update\".")); + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsAutoUpdateDisclaimer2", + "Dalamud will never bother you about updates while you are not idle.")); + + ImGuiHelpers.ScaledDummy(15); + + /* + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateBehavior", + "When the game starts...")); + var behaviorInt = (int)this.autoUpdateBehavior; + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateNone", "Do not check for updates automatically"), ref behaviorInt, (int)AutoUpdateBehavior.None); + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateNotify", "Only notify me of new updates"), ref behaviorInt, (int)AutoUpdateBehavior.OnlyNotify); + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateMainRepo", "Auto-update main repository plugins"), ref behaviorInt, (int)AutoUpdateBehavior.UpdateMainRepo); + this.autoUpdateBehavior = (AutoUpdateBehavior)behaviorInt; + */ + + bool DrawCenteredButton(string text, float height) + { + var buttonHeight = height * ImGuiHelpers.GlobalScale; + var buttonWidth = ImGui.CalcTextSize(text).X + 50 * ImGuiHelpers.GlobalScale; + ImGuiHelpers.CenterCursorFor((int)buttonWidth); + + return ImGui.Button(text, new Vector2(buttonWidth, buttonHeight)) && + !this.isFadingOutForStateChange; + } + + using (ImRaii.PushColor(ImGuiCol.Button, ImGuiColors.DPSRed)) + { + if (DrawCenteredButton("Enable auto-updates", 30)) + { + this.autoUpdateBehavior = AutoUpdateBehavior.UpdateMainRepo; + GoToNextState(State.Links); + } + } + + ImGuiHelpers.ScaledDummy(2); + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1)) + using (var buttonColor = ImRaii.PushColor(ImGuiCol.Button, Vector4.Zero)) + { + buttonColor.Push(ImGuiCol.Border, ImGuiColors.DalamudGrey3); + if (DrawCenteredButton("Disable auto-updates", 25)) + { + this.autoUpdateBehavior = AutoUpdateBehavior.OnlyNotify; + GoToNextState(State.Links); + } + } + break; case State.Links: @@ -356,12 +475,12 @@ internal sealed class ChangelogWindow : Window, IDisposable // Draw close button in the top right corner ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 100f); var btnAlpha = Math.Clamp(this.windowFade.EasedPoint.X - 0.5f, 0f, 1f); - ImGui.PushStyleColor(ImGuiCol.Button, ImGuiColors.DalamudRed.WithAlpha(btnAlpha).Desaturate(0.3f)); + ImGui.PushStyleColor(ImGuiCol.Button, ImGuiColors.DPSRed.WithAlpha(btnAlpha).Desaturate(0.3f)); ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudWhite.WithAlpha(btnAlpha)); var childSize = ImGui.GetWindowSize(); var closeButtonSize = 15 * ImGuiHelpers.GlobalScale; - ImGui.SetCursorPos(new Vector2(childSize.X - closeButtonSize - 5, 10 * ImGuiHelpers.GlobalScale)); + ImGui.SetCursorPos(new Vector2(childSize.X - closeButtonSize - (10 * ImGuiHelpers.GlobalScale), 10 * ImGuiHelpers.GlobalScale)); if (ImGuiComponents.IconButton(FontAwesomeIcon.Times)) { Dismiss(); diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs index d0dc01ce5..379485517 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs @@ -7,12 +7,12 @@ using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiNotification.Internal; +using Dalamud.Interface.Internal.DesignSystem; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Profiles; -using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using ImGuiNET; using Serilog; @@ -300,39 +300,16 @@ internal class ProfileManagerWidget return; } - const string addPluginToProfilePopup = "###addPluginToProfile"; - var addPluginToProfilePopupId = ImGui.GetID(addPluginToProfilePopup); - using (var popup = ImRaii.Popup(addPluginToProfilePopup)) - { - if (popup.Success) + var addPluginToProfilePopupId = DalamudComponents.DrawPluginPicker( + "###addPluginToProfilePicker", + ref this.pickerSearch, + plugin => { - var width = ImGuiHelpers.GlobalScale * 300; - - using var disabled = ImRaii.Disabled(profman.IsBusy); - - ImGui.SetNextItemWidth(width); - ImGui.InputTextWithHint("###pluginPickerSearch", Locs.SearchHint, ref this.pickerSearch, 255); - - if (ImGui.BeginListBox("###pluginPicker", new Vector2(width, width - 80))) - { - // TODO: Plugin searching should be abstracted... installer and this should use the same search - foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles && - (this.pickerSearch.IsNullOrWhitespace() || x.Manifest.Name.ToLowerInvariant().Contains(this.pickerSearch.ToLowerInvariant())))) - { - using var disabled2 = - ImRaii.Disabled(profile.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName)); - - if (ImGui.Selectable($"{plugin.Manifest.Name}{(plugin is LocalDevPlugin ? "(dev plugin)" : string.Empty)}###selector{plugin.Manifest.InternalName}")) - { - Task.Run(() => profile.AddOrUpdateAsync(plugin.EffectiveWorkingPluginId, plugin.Manifest.InternalName, true, false)) - .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); - } - } - - ImGui.EndListBox(); - } - } - } + Task.Run(() => profile.AddOrUpdateAsync(plugin.EffectiveWorkingPluginId, plugin.Manifest.InternalName, true, false)) + .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); + }, + plugin => !plugin.Manifest.SupportsProfiles || + profile.Plugins.Any(x => x.WorkingPluginId == plugin.EffectiveWorkingPluginId)); var didAny = false; @@ -603,8 +580,6 @@ internal class ProfileManagerWidget public static string BackToOverview => Loc.Localize("ProfileManagerBackToOverview", "Back to overview"); - public static string SearchHint => Loc.Localize("ProfileManagerSearchHint", "Search..."); - public static string AddProfileHint => Loc.Localize("ProfileManagerAddProfileHint", "No collections! Add one!"); public static string CloneProfileHint => Loc.Localize("ProfileManagerCloneProfile", "Clone this collection"); diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs index fd4949533..196a11ab1 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs @@ -21,7 +21,7 @@ namespace Dalamud.Interface.Internal.Windows.Settings; /// internal class SettingsWindow : Window { - private SettingsTab[]? tabs; + private readonly SettingsTab[] tabs; private string searchInput = string.Empty; private bool isSearchInputPrefilled = false; @@ -42,6 +42,16 @@ internal class SettingsWindow : Window }; this.SizeCondition = ImGuiCond.FirstUseEver; + + this.tabs = + [ + new SettingsTabGeneral(), + new SettingsTabLook(), + new SettingsTabAutoUpdates(), + new SettingsTabDtr(), + new SettingsTabExperimental(), + new SettingsTabAbout() + ]; } /// @@ -75,15 +85,6 @@ internal class SettingsWindow : Window /// public override void OnOpen() { - this.tabs ??= new SettingsTab[] - { - new SettingsTabGeneral(), - new SettingsTabLook(), - new SettingsTabDtr(), - new SettingsTabExperimental(), - new SettingsTabAbout(), - }; - foreach (var settingsTab in this.tabs) { settingsTab.Load(); @@ -142,7 +143,7 @@ internal class SettingsWindow : Window flags |= ImGuiTabItemFlags.SetSelected; this.setActiveTab = null; } - + using var tab = ImRaii.TabItem(settingsTab.Title, flags); if (tab) { @@ -152,10 +153,14 @@ internal class SettingsWindow : Window settingsTab.OnOpen(); } + // Don't add padding for the about tab(credits) + using var padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(2, 2), + settingsTab is not SettingsTabAbout); + using var borderColor = ImRaii.PushColor(ImGuiCol.Border, ImGui.GetColorU32(ImGuiCol.ChildBg)); using var tabChild = ImRaii.Child( $"###settings_scrolling_{settingsTab.Title}", new Vector2(-1, -1), - false); + true); if (tabChild) settingsTab.Draw(); } @@ -281,25 +286,15 @@ internal class SettingsWindow : Window private void SetOpenTab(SettingsOpenKind kind) { - switch (kind) + this.setActiveTab = kind switch { - case SettingsOpenKind.General: - this.setActiveTab = this.tabs[0]; - break; - case SettingsOpenKind.LookAndFeel: - this.setActiveTab = this.tabs[1]; - break; - case SettingsOpenKind.ServerInfoBar: - this.setActiveTab = this.tabs[2]; - break; - case SettingsOpenKind.Experimental: - this.setActiveTab = this.tabs[3]; - break; - case SettingsOpenKind.About: - this.setActiveTab = this.tabs[4]; - break; - default: - throw new ArgumentOutOfRangeException(nameof(kind), kind, null); - } + SettingsOpenKind.General => this.tabs[0], + SettingsOpenKind.LookAndFeel => this.tabs[1], + SettingsOpenKind.AutoUpdates => this.tabs[2], + SettingsOpenKind.ServerInfoBar => this.tabs[3], + SettingsOpenKind.Experimental => this.tabs[4], + SettingsOpenKind.About => this.tabs[5], + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; } } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAutoUpdate.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAutoUpdate.cs new file mode 100644 index 000000000..0b18e59d9 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAutoUpdate.cs @@ -0,0 +1,254 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; + +using CheapLoc; +using Dalamud.Configuration.Internal; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.Internal.DesignSystem; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.AutoUpdate; +using Dalamud.Plugin.Internal.Types; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; + +[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Internals")] +public class SettingsTabAutoUpdates : SettingsTab +{ + private AutoUpdateBehavior behavior; + private bool checkPeriodically; + private string pickerSearch = string.Empty; + private List autoUpdatePreferences = []; + + public override SettingsEntry[] Entries { get; } = Array.Empty(); + + public override string Title => Loc.Localize("DalamudSettingsAutoUpdates", "Auto-Updates"); + + public override void Draw() + { + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateHint", + "Dalamud can update your plugins automatically, making sure that you always " + + "have the newest features and bug fixes. You can choose when and how auto-updates are run here.")); + ImGuiHelpers.ScaledDummy(2); + + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsAutoUpdateDisclaimer1", + "You can always update your plugins manually by clicking the update button in the plugin list. " + + "You can also opt into updates for specific plugins by right-clicking them and selecting \"Always auto-update\".")); + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsAutoUpdateDisclaimer2", + "Dalamud will never bother you about updates while you are not idle.")); + + ImGuiHelpers.ScaledDummy(8); + + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateBehavior", + "When the game starts...")); + var behaviorInt = (int)this.behavior; + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateNone", "Do not check for updates automatically"), ref behaviorInt, (int)AutoUpdateBehavior.None); + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateNotify", "Only notify me of new updates"), ref behaviorInt, (int)AutoUpdateBehavior.OnlyNotify); + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateMainRepo", "Auto-update main repository plugins"), ref behaviorInt, (int)AutoUpdateBehavior.UpdateMainRepo); + ImGui.RadioButton(Loc.Localize("DalamudSettingsAutoUpdateAll", "Auto-update all plugins"), ref behaviorInt, (int)AutoUpdateBehavior.UpdateAll); + this.behavior = (AutoUpdateBehavior)behaviorInt; + + if (this.behavior == AutoUpdateBehavior.UpdateAll) + { + var warning = Loc.Localize( + "DalamudSettingsAutoUpdateAllWarning", + "Warning: This will update all plugins, including those not from the main repository.\n" + + "These updates are not reviewed by the Dalamud team and may contain malicious code."); + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudOrange, warning); + } + + ImGuiHelpers.ScaledDummy(8); + + ImGui.Checkbox(Loc.Localize("DalamudSettingsAutoUpdatePeriodically", "Periodically check for new updates while playing"), ref this.checkPeriodically); + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsAutoUpdatePeriodicallyHint", + "Plugins won't update automatically after startup, you will only receive a notification while you are not actively playing.")); + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); + + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateOptedIn", + "Per-plugin overrides")); + + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudWhite, Loc.Localize("DalamudSettingsAutoUpdateOverrideHint", + "Here, you can choose to receive or not to receive updates for specific plugins. " + + "This will override the settings above for the selected plugins.")); + + if (this.autoUpdatePreferences.Count == 0) + { + ImGuiHelpers.ScaledDummy(20); + + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGuiHelpers.CenteredText(Loc.Localize("DalamudSettingsAutoUpdateOptedInHint2", + "You did not override auto-updates for any plugins yet.")); + } + + ImGuiHelpers.ScaledDummy(2); + } + else + { + ImGuiHelpers.ScaledDummy(5); + + var pic = Service.Get(); + + var windowSize = ImGui.GetWindowSize(); + var pluginLineHeight = 32 * ImGuiHelpers.GlobalScale; + Guid? wantRemovePluginGuid = null; + + foreach (var preference in this.autoUpdatePreferences) + { + var pmPlugin = Service.Get().InstalledPlugins + .FirstOrDefault(x => x.EffectiveWorkingPluginId == preference.WorkingPluginId); + + var btnOffset = 2; + + if (pmPlugin != null) + { + var cursorBeforeIcon = ImGui.GetCursorPos(); + pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.IsThirdParty, out var icon, out _); + icon ??= pic.DefaultIcon; + + ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight)); + + if (pmPlugin.IsDev) + { + ImGui.SetCursorPos(cursorBeforeIcon); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.7f); + ImGui.Image(pic.DevPluginIcon.ImGuiHandle, new Vector2(pluginLineHeight)); + ImGui.PopStyleVar(); + } + + ImGui.SameLine(); + + var text = $"{pmPlugin.Name}{(pmPlugin.IsDev ? " (dev plugin" : string.Empty)}"; + var textHeight = ImGui.CalcTextSize(text); + var before = ImGui.GetCursorPos(); + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2)); + ImGui.TextUnformatted(text); + + ImGui.SetCursorPos(before); + } + else + { + ImGui.Image(pic.DefaultIcon.ImGuiHandle, new Vector2(pluginLineHeight)); + ImGui.SameLine(); + + var text = Loc.Localize("DalamudSettingsAutoUpdateOptInUnknownPlugin", "Unknown plugin"); + var textHeight = ImGui.CalcTextSize(text); + var before = ImGui.GetCursorPos(); + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2)); + ImGui.TextUnformatted(text); + + ImGui.SetCursorPos(before); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 320)); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); + + string OptKindToString(AutoUpdatePreference.OptKind kind) + { + return kind switch + { + AutoUpdatePreference.OptKind.NeverUpdate => Loc.Localize("DalamudSettingsAutoUpdateOptInNeverUpdate", "Never update this"), + AutoUpdatePreference.OptKind.AlwaysUpdate => Loc.Localize("DalamudSettingsAutoUpdateOptInAlwaysUpdate", "Always update this"), + _ => throw new ArgumentOutOfRangeException(), + }; + } + + ImGui.SetNextItemWidth(ImGuiHelpers.GlobalScale * 250); + if (ImGui.BeginCombo( + $"###autoUpdateBehavior{preference.WorkingPluginId}", + OptKindToString(preference.Kind))) + { + foreach (var kind in Enum.GetValues()) + { + if (ImGui.Selectable(OptKindToString(kind))) + { + preference.Kind = kind; + } + } + + ImGui.EndCombo(); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * btnOffset) - 5); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); + + if (ImGuiComponents.IconButton($"###removePlugin{preference.WorkingPluginId}", FontAwesomeIcon.Trash)) + { + wantRemovePluginGuid = preference.WorkingPluginId; + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(Loc.Localize("DalamudSettingsAutoUpdateOptInRemove", "Remove this override")); + } + + if (wantRemovePluginGuid != null) + { + this.autoUpdatePreferences.RemoveAll(x => x.WorkingPluginId == wantRemovePluginGuid); + } + } + + void OnPluginPicked(LocalPlugin plugin) + { + var id = plugin.EffectiveWorkingPluginId; + if (id == Guid.Empty) + throw new InvalidOperationException("Plugin ID is empty."); + + this.autoUpdatePreferences.Add(new AutoUpdatePreference(id)); + } + + bool IsPluginDisabled(LocalPlugin plugin) + => this.autoUpdatePreferences.Any(x => x.WorkingPluginId == plugin.EffectiveWorkingPluginId); + + bool IsPluginFiltered(LocalPlugin plugin) + => !plugin.IsDev; + + var pickerId = DalamudComponents.DrawPluginPicker( + "###autoUpdatePicker", ref this.pickerSearch, OnPluginPicked, IsPluginDisabled, IsPluginFiltered); + + const FontAwesomeIcon addButtonIcon = FontAwesomeIcon.Plus; + var addButtonText = Loc.Localize("DalamudSettingsAutoUpdateOptInAdd", "Add new override"); + ImGuiHelpers.CenterCursorFor(ImGuiComponents.GetIconButtonWithTextWidth(addButtonIcon, addButtonText)); + if (ImGuiComponents.IconButtonWithText(addButtonIcon, addButtonText)) + { + this.pickerSearch = string.Empty; + ImGui.OpenPopup(pickerId); + } + + base.Draw(); + } + + public override void Load() + { + var configuration = Service.Get(); + + this.behavior = configuration.AutoUpdateBehavior ?? AutoUpdateBehavior.None; + this.checkPeriodically = configuration.CheckPeriodicallyForUpdates; + this.autoUpdatePreferences = configuration.PluginAutoUpdatePreferences; + + base.Load(); + } + + public override void Save() + { + var configuration = Service.Get(); + + configuration.AutoUpdateBehavior = this.behavior; + configuration.CheckPeriodicallyForUpdates = this.checkPeriodically; + configuration.PluginAutoUpdatePreferences = this.autoUpdatePreferences; + + base.Save(); + } +} diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs index ea345e9cf..c96163835 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs @@ -3,6 +3,7 @@ using CheapLoc; using Dalamud.Game.Text; using Dalamud.Interface.Internal.Windows.Settings.Widgets; +using Dalamud.Plugin.Internal.AutoUpdate; namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; @@ -20,9 +21,8 @@ public class SettingsTabGeneral : SettingsTab Loc.Localize("DalamudSettingsChannelHint", "Select the chat channel that is to be used for general Dalamud messages."), c => c.GeneralChatType, (v, c) => c.GeneralChatType = v, - warning: (v) => + validity: (v) => { - // TODO: Maybe actually implement UI for the validity check... if (v == XivChatType.None) return Loc.Localize("DalamudSettingsChannelNone", "Do not pick \"None\"."); @@ -62,12 +62,6 @@ public class SettingsTabGeneral : SettingsTab c => c.PrintPluginsWelcomeMsg, (v, c) => c.PrintPluginsWelcomeMsg = v), - new SettingsEntry( - Loc.Localize("DalamudSettingsAutoUpdatePlugins", "Auto-update plugins"), - Loc.Localize("DalamudSettingsAutoUpdatePluginsMsgHint", "Automatically update plugins when logging in with a character."), - c => c.AutoUpdatePlugins, - (v, c) => c.AutoUpdatePlugins = v), - new SettingsEntry( Loc.Localize("DalamudSettingsSystemMenu", "Dalamud buttons in system menu"), Loc.Localize("DalamudSettingsSystemMenuMsgHint", "Add buttons for Dalamud plugins and settings to the system menu."), diff --git a/Dalamud/Plugin/DalamudPluginInterface.cs b/Dalamud/Plugin/DalamudPluginInterface.cs index d53c620f4..cf56ee0fd 100644 --- a/Dalamud/Plugin/DalamudPluginInterface.cs +++ b/Dalamud/Plugin/DalamudPluginInterface.cs @@ -9,7 +9,6 @@ using System.Reflection; using Dalamud.Configuration; using Dalamud.Configuration.Internal; using Dalamud.Data; -using Dalamud.Game; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.Sanitizer; @@ -20,6 +19,7 @@ using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.Settings; using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.AutoUpdate; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Plugin.Ipc; @@ -27,8 +27,6 @@ using Dalamud.Plugin.Ipc.Exceptions; using Dalamud.Plugin.Ipc.Internal; using Dalamud.Utility; -using static Dalamud.Interface.Internal.Windows.PluginInstaller.PluginInstallerWindow; - namespace Dalamud.Plugin; /// @@ -114,7 +112,7 @@ public sealed class DalamudPluginInterface : IDisposable /// /// Gets a value indicating whether or not auto-updates have already completed this session. /// - public bool IsAutoUpdateComplete => Service.Get().IsAutoUpdateComplete; + public bool IsAutoUpdateComplete => Service.Get().IsAutoUpdateComplete; /// /// Gets the repository from which this plugin was installed. diff --git a/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateBehavior.cs b/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateBehavior.cs new file mode 100644 index 000000000..2d5dec970 --- /dev/null +++ b/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateBehavior.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Plugin.Internal.AutoUpdate; + +/// +/// Enum describing how plugins should be auto-updated at startup-. +/// +internal enum AutoUpdateBehavior +{ + /// + /// Plugins should not be updated and the user should not be notified. + /// + None, + + /// + /// The user should merely be notified about updates. + /// + OnlyNotify, + + /// + /// Only plugins from the main repository should be updated. + /// + UpdateMainRepo, + + /// + /// All plugins should be updated. + /// + UpdateAll, +} diff --git a/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateManager.cs b/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateManager.cs new file mode 100644 index 000000000..3e12ef600 --- /dev/null +++ b/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateManager.cs @@ -0,0 +1,439 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Dalamud.Configuration.Internal; +using Dalamud.Console; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.Internal; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.DesignSystem; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +using ImGuiNET; + +namespace Dalamud.Plugin.Internal.AutoUpdate; + +// TODO: Loc + +/// +/// Class to manage automatic updates for plugins. +/// +[ServiceManager.EarlyLoadedService] +internal class AutoUpdateManager : IServiceType +{ + private static readonly ModuleLog Log = new("AUTOUPDATE"); + + /// + /// Time we should wait after login to update. + /// + private static readonly TimeSpan UpdateTimeAfterLogin = TimeSpan.FromSeconds(20); + + /// + /// Time we should wait between scheduled update checks. + /// + private static readonly TimeSpan TimeBetweenUpdateChecks = TimeSpan.FromHours(1.5); + + /// + /// Time we should wait after unblocking to nag the user. + /// Used to prevent spamming a nag, for example, right after an user leaves a duty. + /// + private static readonly TimeSpan CooldownAfterUnblock = TimeSpan.FromSeconds(30); + + [ServiceManager.ServiceDependency] + private readonly PluginManager pluginManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly DalamudConfiguration config = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly NotificationManager notificationManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly DalamudInterface dalamudInterface = Service.Get(); + + private readonly IConsoleVariable isDryRun; + + private DateTime? loginTime; + private DateTime? lastUpdateCheckTime; + private DateTime? unblockedSince; + + private bool hasStartedInitialUpdateThisSession; + + private IActiveNotification? updateNotification; + + private Task? autoUpdateTask; + + /// + /// Initializes a new instance of the class. + /// + /// Console service. + [ServiceManager.ServiceConstructor] + public AutoUpdateManager(ConsoleManager console) + { + Service.GetAsync().ContinueWith( + t => + { + t.Result.Login += this.OnLogin; + t.Result.Logout += this.OnLogout; + }); + Service.GetAsync().ContinueWith(t => { t.Result.Update += this.OnUpdate; }); + + this.isDryRun = console.AddVariable("dalamud.autoupdate.dry_run", "Simulate updates instead", true); + console.AddCommand("dalamud.autoupdate.trigger_login", "Trigger a login event", () => + { + this.hasStartedInitialUpdateThisSession = false; + this.OnLogin(); + return true; + }); + console.AddCommand("dalamud.autoupdate.force_check", "Force a check for updates", () => + { + this.lastUpdateCheckTime = DateTime.Now - TimeBetweenUpdateChecks; + return true; + }); + } + + private enum UpdateListingRestriction + { + Unrestricted, + AllowNone, + AllowMainRepo, + } + + /// + /// Gets a value indicating whether or not auto-updates have already completed this session. + /// + public bool IsAutoUpdateComplete { get; private set; } + + private static UpdateListingRestriction DecideUpdateListingRestriction(AutoUpdateBehavior behavior) + { + return behavior switch + { + // We don't generally allow any updates in this mode, but specific opt-ins. + AutoUpdateBehavior.None => UpdateListingRestriction.AllowNone, + + // If we're only notifying, I guess it's fine to list all plugins. + AutoUpdateBehavior.OnlyNotify => UpdateListingRestriction.Unrestricted, + + AutoUpdateBehavior.UpdateMainRepo => UpdateListingRestriction.AllowMainRepo, + AutoUpdateBehavior.UpdateAll => UpdateListingRestriction.Unrestricted, + _ => throw new ArgumentOutOfRangeException(nameof(behavior), behavior, null), + }; + } + + private void OnUpdate(IFramework framework) + { + if (this.loginTime == null) + return; + + var autoUpdateTaskInProgress = this.autoUpdateTask is not null && !this.autoUpdateTask.IsCompleted; + var isUnblocked = this.CanUpdateOrNag() && !autoUpdateTaskInProgress; + + if (this.unblockedSince == null && isUnblocked) + { + this.unblockedSince = DateTime.Now; + } + else if (this.unblockedSince != null && !isUnblocked) + { + this.unblockedSince = null; + + // Remove all notifications if we're not actively updating. The user probably doesn't care now. + if (this.updateNotification != null && !autoUpdateTaskInProgress) + { + this.updateNotification.DismissNow(); + this.updateNotification = null; + } + } + + // If we're blocked, we don't do anything. + if (!isUnblocked) + return; + + var isInUnblockedCooldown = + this.unblockedSince != null && DateTime.Now - this.unblockedSince < CooldownAfterUnblock; + + // If we're in the unblock cooldown period, we don't nag the user. This is intended to prevent us + // from showing update notifications right after the user leaves a duty, for example. + if (isInUnblockedCooldown && this.hasStartedInitialUpdateThisSession) + return; + + var behavior = this.config.AutoUpdateBehavior ?? AutoUpdateBehavior.None; + + // 1. This is the initial update after login. We only run this exactly once and this is + // the only time we actually install updates automatically. + if (!this.hasStartedInitialUpdateThisSession && DateTime.Now > this.loginTime.Value.Add(UpdateTimeAfterLogin)) + { + this.lastUpdateCheckTime = DateTime.Now; + this.hasStartedInitialUpdateThisSession = true; + + var currentlyUpdatablePlugins = this.GetAvailablePluginUpdates(DecideUpdateListingRestriction(behavior)); + + if (currentlyUpdatablePlugins.Count == 0) + { + this.IsAutoUpdateComplete = true; + return; + } + + // TODO: This is not 100% what we want... Plugins that are opted-in should be updated regardless of the behavior, + // and we should show a notification for the others afterwards. + if (behavior == AutoUpdateBehavior.OnlyNotify) + { + // List all plugins in the notification + Log.Verbose("Ran initial update, notifying for {Num} plugins", currentlyUpdatablePlugins.Count); + this.NotifyUpdatesAreAvailable(currentlyUpdatablePlugins); + return; + } + + Log.Verbose("Ran initial update, updating {Num} plugins", currentlyUpdatablePlugins.Count); + this.KickOffAutoUpdates(currentlyUpdatablePlugins); + return; + } + + // 2. Continuously check for updates while the game is running. We run these every once in a while and + // will only show a notification here that lets people start the update or open the installer. + if (this.config.CheckPeriodicallyForUpdates && + this.lastUpdateCheckTime != null && + DateTime.Now - this.lastUpdateCheckTime > TimeBetweenUpdateChecks && + this.updateNotification == null) + { + this.pluginManager.ReloadPluginMastersAsync() + .ContinueWith( + t => + { + if (t.IsFaulted || t.IsCanceled) + { + Log.Error(t.Exception!, "Failed to reload plugin masters for auto-update"); + } + + this.NotifyUpdatesAreAvailable( + this.GetAvailablePluginUpdates( + DecideUpdateListingRestriction(behavior))); + }); + + this.lastUpdateCheckTime = DateTime.Now; + } + } + + private IActiveNotification GetBaseNotification(Notification notification) + { + if (this.updateNotification != null) + throw new InvalidOperationException("Already showing a notification"); + + this.updateNotification = this.notificationManager.AddNotification(notification); + this.updateNotification.Dismiss += _ => this.updateNotification = null; + + return this.updateNotification!; + } + + private void KickOffAutoUpdates(ICollection updatablePlugins) + { + this.autoUpdateTask = + Task.Run(() => this.RunAutoUpdates(updatablePlugins)) + .ContinueWith(t => + { + if (t.IsFaulted) + { + Log.Error(t.Exception!, "Failed to run auto-updates"); + } + else if (t.IsCanceled) + { + Log.Warning("Auto-update task was canceled"); + } + + this.autoUpdateTask = null; + this.IsAutoUpdateComplete = true; + }); + } + + private async Task RunAutoUpdates(ICollection updatablePlugins) + { + var pluginStates = new List(); + + Log.Information("Found {UpdatablePluginsCount} plugins to update", updatablePlugins.Count); + + if (updatablePlugins.Count == 0) + return; + + var notification = this.GetBaseNotification(new Notification + { + Title = "Updating plugins...", + Content = $"Preparing to update {updatablePlugins.Count} plugins...", + Type = NotificationType.Info, + InitialDuration = TimeSpan.MaxValue, + ShowIndeterminateIfNoExpiry = false, + UserDismissable = false, + Progress = 0, + Icon = INotificationIcon.From(FontAwesomeIcon.Download), + Minimized = false, + }); + + var numDone = 0; + // TODO: This is NOT correct, we need to do this inside PM to be able to avoid notifying for each of these and avoid + // refreshing the plugin list until we're done. See PluginManager::UpdatePluginsAsync(). + // Maybe have a function in PM that can take a list of AvailablePluginUpdate instead and update them all, + // and get rid of UpdatePluginsAsync()? Will have to change the installer a bit but that might be for the better API-wise. + foreach (var plugin in updatablePlugins) + { + try + { + notification.Content = $"Updating {plugin.InstalledPlugin.Manifest.Name}..."; + notification.Progress = (float)numDone / updatablePlugins.Count; + + if (this.isDryRun.Value) + { + await Task.Delay(5000); + } + + var status = await this.pluginManager.UpdateSinglePluginAsync(plugin, true, this.isDryRun.Value); + pluginStates.Add(status); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to auto-update plugin {PluginName}", plugin.InstalledPlugin.Manifest.Name); + } + + numDone++; + } + + notification.Progress = 1; + notification.UserDismissable = true; + notification.HardExpiry = DateTime.Now.AddSeconds(30); + + notification.DrawActions += _ => + { + ImGuiHelpers.ScaledDummy(2); + if (DalamudComponents.PrimaryButton("Open Plugin Installer")) + { + Service.Get().OpenPluginInstaller(); + notification.DismissNow(); + } + }; + + // Update the notification to show the final state + if (pluginStates.All(x => x.Status == PluginUpdateStatus.StatusKind.Success)) + { + notification.Minimized = true; + + // Janky way to make sure the notification does not change before it's minimized... + await Task.Delay(500); + + notification.Title = "Updates successful!"; + notification.MinimizedText = "Plugins updated successfully."; + notification.Type = NotificationType.Success; + notification.Content = "All plugins have been updated successfully."; + } + else + { + notification.Title = "Updates failed!"; + notification.Title = "Plugins failed to update."; + notification.Type = NotificationType.Error; + notification.Content = "Some plugins failed to update. Please check the plugin installer for more information."; + + var failedPlugins = pluginStates + .Where(x => x.Status != PluginUpdateStatus.StatusKind.Success) + .Select(x => x.Name).ToList(); + + notification.Content += $"\nFailed plugins: {string.Join(", ", failedPlugins)}"; + } + } + + private void NotifyUpdatesAreAvailable(ICollection updatablePlugins) + { + if (updatablePlugins.Count == 0) + return; + + var notification = this.GetBaseNotification(new Notification + { + Title = "Updates available!", + Content = $"There are {updatablePlugins.Count} plugins that can be updated.", + Type = NotificationType.Info, + InitialDuration = TimeSpan.MaxValue, + ShowIndeterminateIfNoExpiry = false, + Icon = INotificationIcon.From(FontAwesomeIcon.Download), + }); + + notification.DrawActions += _ => + { + ImGuiHelpers.ScaledDummy(2); + + if (DalamudComponents.PrimaryButton("Update")) + { + this.KickOffAutoUpdates(updatablePlugins); + notification.DismissNow(); + } + + ImGui.SameLine(); + if (DalamudComponents.SecondaryButton("Open installer")) + { + Service.Get().OpenPluginInstaller(); + notification.DismissNow(); + } + }; + } + + private List GetAvailablePluginUpdates(UpdateListingRestriction restriction) + { + var optIns = this.config.PluginAutoUpdatePreferences.ToArray(); + + // Get all of our updatable plugins and do some initial filtering that must apply to all plugins. + var updateablePlugins = this.pluginManager.UpdatablePlugins + .Where( + p => + !p.InstalledPlugin.IsDev && // Never update dev-plugins + p.InstalledPlugin.IsWantedByAnyProfile && // Never update plugins that are not wanted by any profile(not enabled) + !p.InstalledPlugin.Manifest.ScheduledForDeletion); // Never update plugins that we want to get rid of + + return updateablePlugins.Where(FilterPlugin).ToList(); + + bool FilterPlugin(AvailablePluginUpdate availablePluginUpdate) + { + var optIn = optIns.FirstOrDefault(x => x.WorkingPluginId == availablePluginUpdate.InstalledPlugin.EffectiveWorkingPluginId); + + // If this is an opt-out, we don't update. + if (optIn is { Kind: AutoUpdatePreference.OptKind.NeverUpdate }) + return false; + + if (restriction == UpdateListingRestriction.AllowNone && optIn is not { Kind: AutoUpdatePreference.OptKind.AlwaysUpdate }) + return false; + + if (restriction == UpdateListingRestriction.AllowMainRepo && availablePluginUpdate.InstalledPlugin.IsThirdParty) + return false; + + return true; + } + } + + private void OnLogin() + { + this.loginTime = DateTime.Now; + } + + private void OnLogout() + { + this.loginTime = null; + } + + private bool CanUpdateOrNag() + { + var condition = Service.Get(); + return this.IsPluginManagerReady() && + !this.dalamudInterface.IsPluginInstallerOpen && + condition.Only(ConditionFlag.NormalConditions, + ConditionFlag.Jumping, + ConditionFlag.Mounted, + ConditionFlag.UsingParasol); + } + + private bool IsPluginManagerReady() + { + return this.pluginManager.ReposReady && this.pluginManager.PluginsReady && !this.pluginManager.SafeMode; + } +}