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..ca029307d 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; @@ -80,10 +80,9 @@ internal sealed class DalamudConfiguration : IInternalDisposableService public string? LastVersion { get; set; } = null; /// - /// Gets or sets a value indicating the last seen FTUE version. - /// Unused for now, added to prevent existing users from seeing level 0 FTUE. + /// Gets or sets a dictionary of seen FTUE levels. /// - public int SeenFtueLevel { get; set; } = 1; + public Dictionary SeenFtueLevels { get; set; } = new(); /// /// Gets or sets the last loaded Dalamud version. @@ -196,6 +195,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 +229,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 +434,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 +471,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 +566,7 @@ internal sealed class DalamudConfiguration : IInternalDisposableService private void SetDefaults() { +#pragma warning disable CS0618 // "Reduced motion" if (!this.ReduceMotions.HasValue) { @@ -572,6 +588,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/Game/ClientState/Conditions/Condition.cs b/Dalamud/Game/ClientState/Conditions/Condition.cs index d281d7aec..faafe05e0 100644 --- a/Dalamud/Game/ClientState/Conditions/Condition.cs +++ b/Dalamud/Game/ClientState/Conditions/Condition.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using System.Linq; + using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Plugin.Services; @@ -98,6 +101,36 @@ internal sealed class Condition : IInternalDisposableService, ICondition return false; } + /// + public bool OnlyAny(params ConditionFlag[] other) + { + var resultSet = this.AsReadOnlySet(); + return !resultSet.Except(other).Any(); + } + + /// + public bool OnlyAll(params ConditionFlag[] other) + { + var resultSet = this.AsReadOnlySet(); + return resultSet.SetEquals(other); + } + + /// + public IReadOnlySet AsReadOnlySet() + { + var result = new HashSet(); + + for (var i = 0; i < MaxConditionEntries; i++) + { + if (this[i]) + { + result.Add((ConditionFlag)i); + } + } + + return result; + } + private void Dispose(bool disposing) { if (this.isDisposed) @@ -175,12 +208,21 @@ internal class ConditionPluginScoped : IInternalDisposableService, ICondition this.ConditionChange = null; } + + /// + public IReadOnlySet AsReadOnlySet() => this.conditionService.AsReadOnlySet(); /// public bool Any() => this.conditionService.Any(); /// public bool Any(params ConditionFlag[] flags) => this.conditionService.Any(flags); + + /// + public bool OnlyAny(params ConditionFlag[] other) => this.conditionService.OnlyAny(other); + + /// + public bool OnlyAll(params ConditionFlag[] other) => this.conditionService.OnlyAll(other); private void ConditionChangedForward(ConditionFlag flag, bool value) => this.ConditionChange?.Invoke(flag, value); } 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/ImGuiNotification/Internal/ActiveNotification.ImGui.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs index fdccaab7b..16d58bea5 100644 --- a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs @@ -124,7 +124,7 @@ internal sealed partial class ActiveNotification if (this.Click is null) { if (this.UserDismissable && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) - this.DismissNow(NotificationDismissReason.Manual); + this.Minimized = !this.Minimized; } else { @@ -277,12 +277,12 @@ internal sealed partial class ActiveNotification if (this.underlyingNotification.Minimized) { - if (this.DrawIconButton(FontAwesomeIcon.ChevronDown, rtOffset, height, drawActionButtons)) + if (this.DrawIconButton(FontAwesomeIcon.ChevronUp, rtOffset, height, drawActionButtons)) this.Minimized = false; } else { - if (this.DrawIconButton(FontAwesomeIcon.ChevronUp, rtOffset, height, drawActionButtons)) + if (this.DrawIconButton(FontAwesomeIcon.ChevronDown, rtOffset, height, drawActionButtons)) this.Minimized = true; } 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..906fda2e4 100644 --- a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; using System.Linq; using System.Numerics; +using CheapLoc; + using Dalamud.Configuration.Internal; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; @@ -12,8 +15,10 @@ 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; namespace Dalamud.Interface.Internal.Windows; @@ -47,15 +52,34 @@ 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? chosenAutoUpdateBehavior; + + private Dictionary currentFtueLevels = new(); /// /// Initializes a new instance of the class. @@ -90,6 +114,7 @@ internal sealed class ChangelogWindow : Window, IDisposable WindowFadeIn, ExplainerIntro, ExplainerApiBump, + AskAutoUpdate, Links, } @@ -114,11 +139,20 @@ 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.chosenAutoUpdateBehavior = null; + + this.currentFtueLevels = Service.Get().SeenFtueLevels; base.OnOpen(); } @@ -130,6 +164,16 @@ internal sealed class ChangelogWindow : Window, IDisposable this.tsmWindow.AllowDrawing = true; Service.Get().SetCreditsDarkeningAnimation(false); + + var configuration = Service.Get(); + + if (this.chosenAutoUpdateBehavior.HasValue) + { + configuration.AutoUpdateBehavior = this.chosenAutoUpdateBehavior.Value; + } + + configuration.SeenFtueLevels = this.currentFtueLevels; + configuration.QueueSave(); } /// @@ -144,10 +188,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 +254,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 +271,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,11 +288,31 @@ 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 DrawNextButton(State nextState) + void GoToNextState(State nextState) + { + this.isFadingOutForStateChange = true; + this.stateAfterFadeOut = nextState; + + this.fadeOut.Restart(); + } + + bool DrawNextButton(State nextState) { // Draw big, centered next button at the bottom of the window var buttonHeight = 30 * ImGuiHelpers.GlobalScale; @@ -249,11 +321,13 @@ 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); + return true; } + + return false; } switch (this.state) @@ -286,7 +360,66 @@ internal sealed class ChangelogWindow : Window, IDisposable this.apiBumpExplainerTexture.Value.ImGuiHandle, this.apiBumpExplainerTexture.Value.Size); - DrawNextButton(State.Links); + if (!this.currentFtueLevels.TryGetValue(FtueLevels.AutoUpdate.Name, out var autoUpdateLevel) || autoUpdateLevel < FtueLevels.AutoUpdate.AutoUpdateInitial) + { + if (DrawNextButton(State.AskAutoUpdate)) + { + this.currentFtueLevels[FtueLevels.AutoUpdate.Name] = FtueLevels.AutoUpdate.AutoUpdateInitial; + } + } + else + { + DrawNextButton(State.Links); + } + + 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 only notify you about updates while you are idle.")); + + ImGuiHelpers.ScaledDummy(15); + + 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.chosenAutoUpdateBehavior = 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.chosenAutoUpdateBehavior = AutoUpdateBehavior.OnlyNotify; + GoToNextState(State.Links); + } + } + break; case State.Links: @@ -356,12 +489,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(); @@ -384,4 +517,13 @@ internal sealed class ChangelogWindow : Window, IDisposable public void Dispose() { } + + private static class FtueLevels + { + public static class AutoUpdate + { + public const string Name = "AutoUpdate"; + public const int AutoUpdateInitial = 1; + } + } } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 29b0253b8..d45f0b5ea 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -711,8 +711,12 @@ internal class PluginInstallerWindow : Window, IDisposable { this.updateStatus = OperationStatus.InProgress; this.loadingIndicatorKind = LoadingIndicatorKind.UpdatingAll; + + var toUpdate = this.pluginListUpdatable + .Where(x => x.InstalledPlugin.IsLoaded) + .ToList(); - Task.Run(() => pluginManager.UpdatePluginsAsync(true, false)) + Task.Run(() => pluginManager.UpdatePluginsAsync(toUpdate, false)) .ContinueWith(task => { this.updateStatus = OperationStatus.Complete; 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..77c79c96d --- /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 only notify you about updates while you are 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 don't have auto-update rules for any plugins.")); + } + + 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..d33bfacfb 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabGeneral.cs @@ -20,7 +20,7 @@ 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) => + warning: v => { // TODO: Maybe actually implement UI for the validity check... if (v == XivChatType.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..4e2179be8 --- /dev/null +++ b/Dalamud/Plugin/Internal/AutoUpdate/AutoUpdateManager.cs @@ -0,0 +1,458 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using CheapLoc; + +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; + +/// +/// 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", false); + 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) + { + Log.Information("Found {UpdatablePluginsCount} plugins to update", updatablePlugins.Count); + + if (updatablePlugins.Count == 0) + return; + + var notification = this.GetBaseNotification(new Notification + { + Title = Locs.NotificationTitleUpdatingPlugins, + Content = Locs.NotificationContentPreparingToUpdate(updatablePlugins.Count), + Type = NotificationType.Info, + InitialDuration = TimeSpan.MaxValue, + ShowIndeterminateIfNoExpiry = false, + UserDismissable = false, + Progress = 0, + Icon = INotificationIcon.From(FontAwesomeIcon.Download), + Minimized = false, + }); + + var progress = new Progress(); + progress.ProgressChanged += (_, progress) => + { + notification.Content = Locs.NotificationContentUpdating(progress.CurrentPluginManifest.Name); + notification.Progress = (float)progress.PluginsProcessed / progress.TotalPlugins; + }; + + var pluginStates = await this.pluginManager.UpdatePluginsAsync(updatablePlugins, this.isDryRun.Value, true, progress); + + notification.Progress = 1; + notification.UserDismissable = true; + notification.HardExpiry = DateTime.Now.AddSeconds(30); + + notification.DrawActions += _ => + { + ImGuiHelpers.ScaledDummy(2); + if (DalamudComponents.PrimaryButton(Locs.NotificationButtonOpenPluginInstaller)) + { + Service.Get().OpenPluginInstaller(); + notification.DismissNow(); + } + }; + + // Update the notification to show the final state + var pluginUpdateStatusEnumerable = pluginStates as PluginUpdateStatus[] ?? pluginStates.ToArray(); + if (pluginUpdateStatusEnumerable.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 = Locs.NotificationTitleUpdatesSuccessful; + notification.MinimizedText = Locs.NotificationContentUpdatesSuccessfulMinimized; + notification.Type = NotificationType.Success; + notification.Content = Locs.NotificationContentUpdatesSuccessful; + } + else + { + notification.Title = Locs.NotificationTitleUpdatesFailed; + notification.MinimizedText = Locs.NotificationContentUpdatesFailedMinimized; + notification.Type = NotificationType.Error; + notification.Content = Locs.NotificationContentUpdatesFailed; + + var failedPlugins = pluginUpdateStatusEnumerable + .Where(x => x.Status != PluginUpdateStatus.StatusKind.Success) + .Select(x => x.Name).ToList(); + + notification.Content += "\n" + Locs.NotificationContentFailedPlugins(failedPlugins); + } + } + + private void NotifyUpdatesAreAvailable(ICollection updatablePlugins) + { + if (updatablePlugins.Count == 0) + return; + + var notification = this.GetBaseNotification(new Notification + { + Title = Locs.NotificationTitleUpdatesAvailable, + Content = Locs.NotificationContentUpdatesAvailable(updatablePlugins.Count), + MinimizedText = Locs.NotificationContentUpdatesAvailableMinimized(updatablePlugins.Count), + Type = NotificationType.Info, + InitialDuration = TimeSpan.MaxValue, + ShowIndeterminateIfNoExpiry = false, + Icon = INotificationIcon.From(FontAwesomeIcon.Download), + }); + + notification.DrawActions += _ => + { + ImGuiHelpers.ScaledDummy(2); + + if (DalamudComponents.PrimaryButton(Locs.NotificationButtonUpdate)) + { + this.KickOffAutoUpdates(updatablePlugins); + notification.DismissNow(); + } + + ImGui.SameLine(); + if (DalamudComponents.SecondaryButton(Locs.NotificationButtonOpenPluginInstaller)) + { + 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.OnlyAny(ConditionFlag.NormalConditions, + ConditionFlag.Jumping, + ConditionFlag.Mounted, + ConditionFlag.UsingParasol); + } + + private bool IsPluginManagerReady() + { + return this.pluginManager.ReposReady && this.pluginManager.PluginsReady && !this.pluginManager.SafeMode; + } + + private static class Locs + { + public static string NotificationButtonOpenPluginInstaller => Loc.Localize("AutoUpdateOpenPluginInstaller", "Open installer"); + + public static string NotificationButtonUpdate => Loc.Localize("AutoUpdateUpdate", "Update"); + + public static string NotificationTitleUpdatesAvailable => Loc.Localize("AutoUpdateUpdatesAvailable", "Updates available!"); + + public static string NotificationTitleUpdatesSuccessful => Loc.Localize("AutoUpdateUpdatesSuccessful", "Updates successful!"); + + public static string NotificationTitleUpdatingPlugins => Loc.Localize("AutoUpdateUpdatingPlugins", "Updating plugins..."); + + public static string NotificationTitleUpdatesFailed => Loc.Localize("AutoUpdateUpdatesFailed", "Updates failed!"); + + public static string NotificationContentUpdatesSuccessful => Loc.Localize("AutoUpdateUpdatesSuccessfulContent", "All plugins have been updated successfully."); + + public static string NotificationContentUpdatesSuccessfulMinimized => Loc.Localize("AutoUpdateUpdatesSuccessfulContentMinimized", "Plugins updated successfully."); + + public static string NotificationContentUpdatesFailed => Loc.Localize("AutoUpdateUpdatesFailedContent", "Some plugins failed to update. Please check the plugin installer for more information."); + + public static string NotificationContentUpdatesFailedMinimized => Loc.Localize("AutoUpdateUpdatesFailedContentMinimized", "Plugins failed to update."); + + public static string NotificationContentUpdatesAvailable(int numUpdates) + => string.Format(Loc.Localize("AutoUpdateUpdatesAvailableContent", "There are {0} plugins that can be updated."), numUpdates); + + public static string NotificationContentUpdatesAvailableMinimized(int numUpdates) + => string.Format(Loc.Localize("AutoUpdateUpdatesAvailableContent", "{0} updates available."), numUpdates); + + public static string NotificationContentPreparingToUpdate(int numPlugins) + => string.Format(Loc.Localize("AutoUpdatePreparingToUpdate", "Preparing to update {0} plugins..."), numPlugins); + + public static string NotificationContentUpdating(string name) + => string.Format(Loc.Localize("AutoUpdateUpdating", "Updating {0}..."), name); + + public static string NotificationContentFailedPlugins(IEnumerable failedPlugins) + => string.Format(Loc.Localize("AutoUpdateFailedPlugins", "Failed plugins: {0}"), string.Join(", ", failedPlugins)); + } +} diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index 7517ae413..60d2bbe28 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -977,32 +977,39 @@ internal class PluginManager : IInternalDisposableService /// /// Update all non-dev plugins. /// - /// Ignore disabled plugins. + /// List of plugins to update. /// Perform a dry run, don't install anything. /// If this action was performed as part of an auto-update. + /// An implementation to receive progress updates about the installation status. /// Success or failure and a list of updated plugin metadata. - public async Task> UpdatePluginsAsync(bool ignoreDisabled, bool dryRun, bool autoUpdate = false) + public async Task> UpdatePluginsAsync( + ICollection toUpdate, + bool dryRun, + bool autoUpdate = false, + IProgress? progress = null) { Log.Information("Starting plugin update"); var updateTasks = new List>(); + var totalPlugins = toUpdate.Count; + var processedPlugins = 0; // Prevent collection was modified errors lock (this.pluginListLock) { - foreach (var plugin in this.updatablePluginsList) + foreach (var plugin in toUpdate) { // Can't update that! if (plugin.InstalledPlugin.IsDev) continue; - if (!plugin.InstalledPlugin.IsWantedByAnyProfile && ignoreDisabled) + if (!plugin.InstalledPlugin.IsWantedByAnyProfile) continue; if (plugin.InstalledPlugin.Manifest.ScheduledForDeletion) continue; - updateTasks.Add(this.UpdateSinglePluginAsync(plugin, false, dryRun)); + updateTasks.Add(UpdateSinglePluginWithProgressAsync(plugin)); } } @@ -1013,9 +1020,26 @@ internal class PluginManager : IInternalDisposableService autoUpdate ? PluginListInvalidationKind.AutoUpdate : PluginListInvalidationKind.Update, updatedList.Select(x => x.InternalName)); - Log.Information("Plugin update OK. {updateCount} plugins updated.", updatedList.Length); + Log.Information("Plugin update OK. {UpdateCount} plugins updated", updatedList.Length); return updatedList; + + async Task UpdateSinglePluginWithProgressAsync(AvailablePluginUpdate plugin) + { + var result = await this.UpdateSinglePluginAsync(plugin, false, dryRun); + + // Update the progress + if (progress != null) + { + var newProcessedAmount = Interlocked.Increment(ref processedPlugins); + progress.Report(new PluginUpdateProgress( + newProcessedAmount, + totalPlugins, + plugin.InstalledPlugin.Manifest)); + } + + return result; + } } /// @@ -1832,6 +1856,11 @@ internal class PluginManager : IInternalDisposableService } } + /// + /// Class representing progress of an update operation. + /// + public record PluginUpdateProgress(int PluginsProcessed, int TotalPlugins, IPluginManifest CurrentPluginManifest); + /// /// Simple class that tracks the internal names and public names of plugins that we are planning to load at startup, /// and are still actively loading. diff --git a/Dalamud/Plugin/Services/ICondition.cs b/Dalamud/Plugin/Services/ICondition.cs index 9700cef5a..7215cdac6 100644 --- a/Dalamud/Plugin/Services/ICondition.cs +++ b/Dalamud/Plugin/Services/ICondition.cs @@ -1,4 +1,6 @@ -using Dalamud.Game.ClientState.Conditions; +using System.Collections.Generic; + +using Dalamud.Game.ClientState.Conditions; namespace Dalamud.Plugin.Services; @@ -51,4 +53,26 @@ public interface ICondition /// Whether any single provided flag is set. /// The condition flags to check. public bool Any(params ConditionFlag[] flags); + + /// + /// Check that *only* any of the condition flags specified are set. Useful to test if the client is in one of any + /// of a few specific condiiton states. + /// + /// The array of flags to check. + /// Returns a bool. + public bool OnlyAny(params ConditionFlag[] other); + + /// + /// Check that *only* the specified flags are set. Unlike , this method requires that all the + /// specified flags are set and no others are present. + /// + /// The array of flags to check. + /// Returns a bool. + public bool OnlyAll(params ConditionFlag[] other); + + /// + /// Convert the conditions array to a set of all set condition flags. + /// + /// Returns a set. + public IReadOnlySet AsReadOnlySet(); }