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 619d233e1..4e79b3b46 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; } /// @@ -228,6 +228,11 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// public int LogLinesLimit { get; set; } = 10000; + /// + /// Gets or sets a list representing the command history for the Dalamud Console. + /// + public List LogCommandHistory { get; set; } = new(); + /// /// Gets or sets a value indicating whether or not the dev bar should open at startup. /// @@ -429,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. @@ -464,6 +474,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. /// @@ -549,6 +569,7 @@ internal sealed class DalamudConfiguration : IInternalDisposableService private void SetDefaults() { +#pragma warning disable CS0618 // "Reduced motion" if (!this.ReduceMotions.HasValue) { @@ -570,6 +591,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/ConsoleManager.cs b/Dalamud/Console/ConsoleManager.cs index 6600069c2..4112cde2a 100644 --- a/Dalamud/Console/ConsoleManager.cs +++ b/Dalamud/Console/ConsoleManager.cs @@ -270,7 +270,9 @@ internal partial class ConsoleManager : IServiceType for (var i = parsedArguments.Count; i < entry.ValidArguments.Count; i++) { var argument = entry.ValidArguments[i]; - if (argument.DefaultValue == null) + + // If the default value is DBNull, we need to error out as that means it was not specified + if (argument.DefaultValue == DBNull.Value) { Log.Error("Not enough arguments for command {CommandName}", entryName); PrintUsage(entry); @@ -382,11 +384,8 @@ internal partial class ConsoleManager : IServiceType /// The default value to use if none is specified. /// An instance. /// Thrown if the given type cannot be handled by the console system. - protected static ArgumentInfo TypeToArgument(Type type, object? defaultValue = null) + protected static ArgumentInfo TypeToArgument(Type type, object? defaultValue) { - // If the default value is DBNull, we want to treat it as null - defaultValue = defaultValue == DBNull.Value ? null : defaultValue; - if (type == typeof(string)) return new ArgumentInfo(ConsoleArgumentType.String, defaultValue); @@ -490,7 +489,7 @@ internal partial class ConsoleManager : IServiceType public ConsoleVariable(string name, string description) : base(name, description) { - this.ValidArguments = new List { TypeToArgument(typeof(T)) }; + this.ValidArguments = new List { TypeToArgument(typeof(T), null) }; } /// @@ -500,7 +499,20 @@ internal partial class ConsoleManager : IServiceType public override bool Invoke(IEnumerable arguments) { var first = arguments.FirstOrDefault(); - if (first == null || first.GetType() != typeof(T)) + + if (first == null) + { + // Invert the value if it's a boolean + if (this.Value is bool boolValue) + { + this.Value = (T)(object)!boolValue; + } + + Log.WriteLog(LogEventLevel.Information, "{VariableName} = {VariableValue}", null, this.Name, this.Value); + return true; + } + + if (first.GetType() != typeof(T)) throw new ArgumentException($"Console variable must be set with an argument of type {typeof(T).Name}."); this.Value = (T)first; diff --git a/Dalamud/Console/ConsoleManagerPluginScoped.cs b/Dalamud/Console/ConsoleManagerPluginScoped.cs index bc60c2dab..e1eddcf7a 100644 --- a/Dalamud/Console/ConsoleManagerPluginScoped.cs +++ b/Dalamud/Console/ConsoleManagerPluginScoped.cs @@ -21,7 +21,8 @@ namespace Dalamud.Console; #pragma warning restore SA1015 public class ConsoleManagerPluginScoped : IConsole, IInternalDisposableService { - private readonly ConsoleManager console; + [ServiceManager.ServiceDependency] + private readonly ConsoleManager console = Service.Get(); private readonly List trackedEntries = new(); @@ -29,12 +30,9 @@ 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, ConsoleManager console) + internal ConsoleManagerPluginScoped(LocalPlugin plugin) { - this.console = console; - this.Prefix = ConsoleManagerPluginUtil.GetSanitizedNamespaceName(plugin.InternalName); } diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index d60c8285b..de4499b70 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -8,7 +8,7 @@ - 9.1.0.10 + 9.1.0.12 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index 20beaefc9..0ac20826c 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -29,40 +29,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( @@ -105,8 +71,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(); @@ -114,19 +78,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); - }); } /// @@ -175,9 +132,6 @@ internal class ChatHandlers : IServiceType { if (!this.hasSeenLoadingMsg) this.PrintWelcomeMessage(); - - if (!this.startedAutoUpdatingPlugins) - this.AutoUpdatePluginsWithRetry(); } // For injections while logged in @@ -272,89 +226,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/ClientState.cs b/Dalamud/Game/ClientState/ClientState.cs index a4cc2272f..4a6bee95c 100644 --- a/Dalamud/Game/ClientState/ClientState.cs +++ b/Dalamud/Game/ClientState/ClientState.cs @@ -1,6 +1,8 @@ +using System.Linq; using System.Runtime.InteropServices; using Dalamud.Data; +using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.Gui; @@ -122,6 +124,27 @@ internal sealed class ClientState : IInternalDisposableService, IClientState /// Gets client state address resolver. /// internal ClientStateAddressResolver AddressResolver => this.address; + + /// + public bool IsClientIdle(out ConditionFlag blockingFlag) + { + blockingFlag = 0; + if (this.LocalPlayer is null) return true; + + var condition = Service.GetNullable(); + + var blockingConditions = condition.AsReadOnlySet().Except([ + ConditionFlag.NormalConditions, + ConditionFlag.Jumping, + ConditionFlag.Mounted, + ConditionFlag.UsingParasol]); + + blockingFlag = blockingConditions.FirstOrDefault(); + return blockingFlag == 0; + } + + /// + public bool IsClientIdle() => this.IsClientIdle(out _); /// /// Dispose of managed and unmanaged resources. @@ -269,6 +292,12 @@ internal class ClientStatePluginScoped : IInternalDisposableService, IClientStat /// public bool IsGPosing => this.clientStateService.IsGPosing; + /// + public bool IsClientIdle(out ConditionFlag blockingFlag) => this.clientStateService.IsClientIdle(out blockingFlag); + + /// + public bool IsClientIdle() => this.clientStateService.IsClientIdle(); + /// void IInternalDisposableService.DisposeService() { diff --git a/Dalamud/Game/ClientState/Conditions/Condition.cs b/Dalamud/Game/ClientState/Conditions/Condition.cs index dbc2d9356..6fee02fd6 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; @@ -67,6 +70,22 @@ internal sealed class Condition : IInternalDisposableService, ICondition /// void IInternalDisposableService.DisposeService() => this.Dispose(true); + + /// + public IReadOnlySet AsReadOnlySet() + { + var result = new HashSet(); + + for (var i = 0; i < MaxConditionEntries; i++) + { + if (this[i]) + { + result.Add((ConditionFlag)i); + } + } + + return result; + } /// public bool Any() @@ -96,6 +115,25 @@ internal sealed class Condition : IInternalDisposableService, ICondition return false; } + + /// + public bool AnyExcept(params ConditionFlag[] excluded) + { + return !this.AsReadOnlySet().Intersect(excluded).Any(); + } + + /// + public bool OnlyAny(params ConditionFlag[] other) + { + return !this.AsReadOnlySet().Except(other).Any(); + } + + /// + public bool EqualTo(params ConditionFlag[] other) + { + var resultSet = this.AsReadOnlySet(); + return resultSet.SetEquals(other); + } private void Dispose(bool disposing) { @@ -173,6 +211,9 @@ internal class ConditionPluginScoped : IInternalDisposableService, ICondition this.ConditionChange = null; } + + /// + public IReadOnlySet AsReadOnlySet() => this.conditionService.AsReadOnlySet(); /// public bool Any() => this.conditionService.Any(); @@ -180,5 +221,14 @@ internal class ConditionPluginScoped : IInternalDisposableService, ICondition /// public bool Any(params ConditionFlag[] flags) => this.conditionService.Any(flags); + /// + public bool AnyExcept(params ConditionFlag[] except) => this.conditionService.AnyExcept(except); + + /// + public bool OnlyAny(params ConditionFlag[] other) => this.conditionService.OnlyAny(other); + + /// + public bool EqualTo(params ConditionFlag[] other) => this.conditionService.EqualTo(other); + private void ConditionChangedForward(ConditionFlag flag, bool value) => this.ConditionChange?.Invoke(flag, value); } diff --git a/Dalamud/Game/Gui/GameGui.cs b/Dalamud/Game/Gui/GameGui.cs index 750ebcc46..98f6ef0f8 100644 --- a/Dalamud/Game/Gui/GameGui.cs +++ b/Dalamud/Game/Gui/GameGui.cs @@ -495,6 +495,9 @@ internal sealed unsafe class GameGui : IInternalDisposableService, IGameGui private char HandleImmDetour(IntPtr framework, char a2, byte a3) { var result = this.handleImmHook.Original(framework, a2, a3); + if (!ImGuiHelpers.IsImGuiInitialized) + return result; + return ImGui.GetIO().WantTextInput ? (char)0 : result; 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 f9b675112..7995abedb 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/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index 12c3a2960..8f7c0e36c 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -37,6 +37,7 @@ internal class ConsoleWindow : Window, IDisposable { private const int LogLinesMinimum = 100; private const int LogLinesMaximum = 1000000; + private const int HistorySize = 50; // Only this field may be touched from any thread. private readonly ConcurrentQueue<(string Line, LogEvent LogEvent)> newLogEntries; @@ -44,9 +45,10 @@ internal class ConsoleWindow : Window, IDisposable // Fields below should be touched only from the main thread. private readonly RollingList logText; private readonly RollingList filteredLogEntries; - - private readonly List history = new(); + private readonly List pluginFilters = new(); + + private readonly DalamudConfiguration configuration; private int newRolledLines; private bool pendingRefilter; @@ -78,6 +80,9 @@ internal class ConsoleWindow : Window, IDisposable private int historyPos; private int copyStart = -1; + private string? completionZipText = null; + private int completionTabIdx = 0; + private IActiveNotification? prevCopyNotification; /// Initializes a new instance of the class. @@ -85,6 +90,8 @@ internal class ConsoleWindow : Window, IDisposable public ConsoleWindow(DalamudConfiguration configuration) : base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) { + this.configuration = configuration; + this.autoScroll = configuration.LogAutoScroll; this.autoOpen = configuration.LogOpenAtStartup; SerilogEventSink.Instance.LogLine += this.OnLogLine; @@ -111,7 +118,7 @@ internal class ConsoleWindow : Window, IDisposable this.logText = new(limit); this.filteredLogEntries = new(limit); - configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved; + this.configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved; unsafe { @@ -130,7 +137,7 @@ internal class ConsoleWindow : Window, IDisposable public void Dispose() { SerilogEventSink.Instance.LogLine -= this.OnLogLine; - Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; + this.configuration.DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; if (Service.GetNullable() is { } framework) framework.Update -= this.FrameworkOnUpdate; @@ -314,9 +321,10 @@ internal class ConsoleWindow : Window, IDisposable ref this.commandText, 255, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion | - ImGuiInputTextFlags.CallbackHistory, + ImGuiInputTextFlags.CallbackHistory | ImGuiInputTextFlags.CallbackEdit, this.CommandInputCallback)) { + this.newLogEntries.Enqueue((this.commandText, new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate(string.Empty, []), []))); this.ProcessCommand(); getFocus = true; } @@ -460,8 +468,6 @@ internal class ConsoleWindow : Window, IDisposable private void DrawOptionsToolbar() { - var configuration = Service.Get(); - ImGui.PushItemWidth(150.0f * ImGuiHelpers.GlobalScale); if (ImGui.BeginCombo("##log_level", $"{EntryPoint.LogLevelSwitch.MinimumLevel}+")) { @@ -470,8 +476,8 @@ internal class ConsoleWindow : Window, IDisposable if (ImGui.Selectable(value.ToString(), value == EntryPoint.LogLevelSwitch.MinimumLevel)) { EntryPoint.LogLevelSwitch.MinimumLevel = value; - configuration.LogLevel = value; - configuration.QueueSave(); + this.configuration.LogLevel = value; + this.configuration.QueueSave(); this.QueueRefilter(); } } @@ -484,13 +490,13 @@ internal class ConsoleWindow : Window, IDisposable var settingsPopup = ImGui.BeginPopup("##console_settings"); if (settingsPopup) { - this.DrawSettingsPopup(configuration); + this.DrawSettingsPopup(); ImGui.EndPopup(); } else if (this.settingsPopupWasOpen) { // Prevent side effects in case Apply wasn't clicked - this.logLinesLimit = configuration.LogLinesLimit; + this.logLinesLimit = this.configuration.LogLinesLimit; } this.settingsPopupWasOpen = settingsPopup; @@ -638,18 +644,18 @@ internal class ConsoleWindow : Window, IDisposable } } - private void DrawSettingsPopup(DalamudConfiguration configuration) + private void DrawSettingsPopup() { if (ImGui.Checkbox("Open at startup", ref this.autoOpen)) { - configuration.LogOpenAtStartup = this.autoOpen; - configuration.QueueSave(); + this.configuration.LogOpenAtStartup = this.autoOpen; + this.configuration.QueueSave(); } if (ImGui.Checkbox("Auto-scroll", ref this.autoScroll)) { - configuration.LogAutoScroll = this.autoScroll; - configuration.QueueSave(); + this.configuration.LogAutoScroll = this.autoScroll; + this.configuration.QueueSave(); } ImGui.TextUnformatted("Logs buffer"); @@ -658,8 +664,8 @@ internal class ConsoleWindow : Window, IDisposable { this.logLinesLimit = Math.Max(LogLinesMinimum, this.logLinesLimit); - configuration.LogLinesLimit = this.logLinesLimit; - configuration.QueueSave(); + this.configuration.LogLinesLimit = this.logLinesLimit; + this.configuration.QueueSave(); ImGui.CloseCurrentPopup(); } @@ -795,23 +801,18 @@ internal class ConsoleWindow : Window, IDisposable { try { - this.historyPos = -1; - for (var i = this.history.Count - 1; i >= 0; i--) - { - if (this.history[i] == this.commandText) - { - this.history.RemoveAt(i); - break; - } - } - - this.history.Add(this.commandText); - - if (this.commandText is "clear" or "cls") - { - this.QueueClear(); + if (string.IsNullOrEmpty(this.commandText)) return; - } + + this.historyPos = -1; + + if (this.commandText != this.configuration.LogCommandHistory.LastOrDefault()) + this.configuration.LogCommandHistory.Add(this.commandText); + + if (this.configuration.LogCommandHistory.Count > HistorySize) + this.configuration.LogCommandHistory.RemoveAt(0); + + this.configuration.QueueSave(); this.lastCmdSuccess = Service.Get().ProcessCommand(this.commandText); this.commandText = string.Empty; @@ -831,6 +832,11 @@ internal class ConsoleWindow : Window, IDisposable switch (data->EventFlag) { + case ImGuiInputTextFlags.CallbackEdit: + this.completionZipText = null; + this.completionTabIdx = 0; + break; + case ImGuiInputTextFlags.CallbackCompletion: var textBytes = new byte[data->BufTextLen]; Marshal.Copy((IntPtr)data->Buf, textBytes, 0, data->BufTextLen); @@ -841,22 +847,47 @@ internal class ConsoleWindow : Window, IDisposable // We can't do any completion for parameters at the moment since it just calls into CommandHandler if (words.Length > 1) return 0; + + var wordToComplete = words[0]; + if (wordToComplete.IsNullOrWhitespace()) + return 0; + + if (this.completionZipText is not null) + wordToComplete = this.completionZipText; // TODO: Improve this, add partial completion, arguments, description, etc. // https://github.com/ocornut/imgui/blob/master/imgui_demo.cpp#L6443-L6484 var candidates = Service.Get().Entries - .Where(x => x.Key.StartsWith(words[0])) + .Where(x => x.Key.StartsWith(wordToComplete)) .Select(x => x.Key); candidates = candidates.Union( Service.Get().Commands - .Where(x => x.Key.StartsWith(words[0])).Select(x => x.Key)); + .Where(x => x.Key.StartsWith(wordToComplete)).Select(x => x.Key)) + .ToArray(); - var enumerable = candidates as string[] ?? candidates.ToArray(); - if (enumerable.Length != 0) + if (candidates.Any()) { - ptr.DeleteChars(0, ptr.BufTextLen); - ptr.InsertChars(0, enumerable[0]); + string? toComplete = null; + if (this.completionZipText == null) + { + // Find the "common" prefix of all matches + toComplete = candidates.Aggregate( + (prefix, candidate) => string.Concat(prefix.Zip(candidate, (a, b) => a == b ? a : '\0'))); + + this.completionZipText = toComplete; + } + else + { + toComplete = candidates.ElementAt(this.completionTabIdx); + this.completionTabIdx = (this.completionTabIdx + 1) % candidates.Count(); + } + + if (toComplete != null) + { + ptr.DeleteChars(0, ptr.BufTextLen); + ptr.InsertChars(0, toComplete); + } } break; @@ -867,7 +898,7 @@ internal class ConsoleWindow : Window, IDisposable if (ptr.EventKey == ImGuiKey.UpArrow) { if (this.historyPos == -1) - this.historyPos = this.history.Count - 1; + this.historyPos = this.configuration.LogCommandHistory.Count - 1; else if (this.historyPos > 0) this.historyPos--; } @@ -875,7 +906,7 @@ internal class ConsoleWindow : Window, IDisposable { if (this.historyPos != -1) { - if (++this.historyPos >= this.history.Count) + if (++this.historyPos >= this.configuration.LogCommandHistory.Count) { this.historyPos = -1; } @@ -884,7 +915,7 @@ internal class ConsoleWindow : Window, IDisposable if (prevPos != this.historyPos) { - var historyStr = this.historyPos >= 0 ? this.history[this.historyPos] : string.Empty; + var historyStr = this.historyPos >= 0 ? this.configuration.LogCommandHistory[this.historyPos] : string.Empty; ptr.DeleteChars(0, ptr.BufTextLen); ptr.InsertChars(0, historyStr); diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 0d65a2873..80e0a0c35 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -208,6 +208,19 @@ internal class PluginInstallerWindow : Window, IDisposable EnabledDisabled, ProfileOrNot, } + + [Flags] + private enum PluginHeaderFlags + { + None = 0, + IsThirdParty = 1 << 0, + HasTrouble = 1 << 1, + UpdateAvailable = 1 << 2, + IsNew = 1 << 3, + IsInstallableOutdated = 1 << 4, + IsOrphan = 1 << 5, + IsTesting = 1 << 6, + } private bool AnyOperationInProgress => this.installStatus == OperationStatus.InProgress || this.updateStatus == OperationStatus.InProgress || @@ -712,8 +725,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; @@ -1807,22 +1824,62 @@ internal class PluginInstallerWindow : Window, IDisposable return ready; } - private bool DrawPluginCollapsingHeader(string label, LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, bool trouble, bool updateAvailable, bool isNew, bool installableOutdated, bool isOrphan, Action drawContextMenuAction, int index) + private bool DrawPluginCollapsingHeader(string label, LocalPlugin? plugin, IPluginManifest manifest, PluginHeaderFlags flags, Action drawContextMenuAction, int index) { - ImGui.Separator(); - var isOpen = this.openPluginCollapsibles.Contains(index); var sectionSize = ImGuiHelpers.GlobalScale * 66; + var tapeCursor = ImGui.GetCursorPos(); + + ImGui.Separator(); + var startCursor = ImGui.GetCursorPos(); + if (flags.HasFlag(PluginHeaderFlags.IsTesting)) + { + void DrawCautionTape(Vector2 position, Vector2 size, float stripeWidth, float skewAmount) + { + var wdl = ImGui.GetWindowDrawList(); + + var windowPos = ImGui.GetWindowPos(); + var scroll = new Vector2(ImGui.GetScrollX(), ImGui.GetScrollY()); + + var adjustedPosition = windowPos + position - scroll; + + var yellow = ImGui.ColorConvertFloat4ToU32(new Vector4(1.0f, 0.9f, 0.0f, 0.10f)); + var numStripes = (int)(size.X / stripeWidth) + (int)(size.Y / skewAmount) + 1; // +1 to cover partial stripe + + for (var i = 0; i < numStripes; i++) + { + var x0 = adjustedPosition.X + i * stripeWidth; + var x1 = x0 + stripeWidth; + var y0 = adjustedPosition.Y; + var y1 = y0 + size.Y; + + var p0 = new Vector2(x0, y0); + var p1 = new Vector2(x1, y0); + var p2 = new Vector2(x1 - skewAmount, y1); + var p3 = new Vector2(x0 - skewAmount, y1); + + if (i % 2 != 0) + continue; + + wdl.AddQuadFilled(p0, p1, p2, p3, yellow); + } + } + + DrawCautionTape(tapeCursor + new Vector2(0, 1), new Vector2(ImGui.GetWindowWidth(), sectionSize + ImGui.GetStyle().ItemSpacing.Y), ImGuiHelpers.GlobalScale * 40, 20); + } + ImGui.PushStyleColor(ImGuiCol.Button, isOpen ? new Vector4(0.5f, 0.5f, 0.5f, 0.1f) : Vector4.Zero); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.5f, 0.5f, 0.5f, 0.2f)); ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.5f, 0.5f, 0.5f, 0.35f)); ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0); + + ImGui.SetCursorPos(tapeCursor); - if (ImGui.Button($"###plugin{index}CollapsibleBtn", new Vector2(ImGui.GetContentRegionAvail().X, sectionSize))) + if (ImGui.Button($"###plugin{index}CollapsibleBtn", new Vector2(ImGui.GetContentRegionAvail().X, sectionSize + ImGui.GetStyle().ItemSpacing.Y))) { if (isOpen) { @@ -1854,7 +1911,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.IsRectVisible(rectOffset + cursorBeforeImage, rectOffset + cursorBeforeImage + iconSize)) { var iconTex = this.imageCache.DefaultIcon; - var hasIcon = this.imageCache.TryGetIcon(plugin, manifest, isThirdParty, out var cachedIconTex, out var loadedSince); + var hasIcon = this.imageCache.TryGetIcon(plugin, manifest, flags.HasFlag(PluginHeaderFlags.IsThirdParty), out var cachedIconTex, out var loadedSince); if (hasIcon && cachedIconTex != null) { iconTex = cachedIconTex; @@ -1868,7 +1925,7 @@ internal class PluginInstallerWindow : Window, IDisposable float EaseOutCubic(float t) => 1 - MathF.Pow(1 - t, 3); var secondsSinceLoad = (float)DateTime.Now.Subtract(loadedSince.Value).TotalSeconds; - var fadeTo = pluginDisabled || installableOutdated ? 0.4f : 1f; + var fadeTo = pluginDisabled || flags.HasFlag(PluginHeaderFlags.IsInstallableOutdated) ? 0.4f : 1f; float Interp(float to) => Math.Clamp(EaseOutCubic(Math.Min(secondsSinceLoad, fadeTime) / fadeTime) * to, 0, 1); iconAlpha = Interp(fadeTo); @@ -1886,11 +1943,11 @@ internal class PluginInstallerWindow : Window, IDisposable var isLoaded = plugin is { IsLoaded: true }; ImGui.PushStyleVar(ImGuiStyleVar.Alpha, overlayAlpha); - if (updateAvailable) + if (flags.HasFlag(PluginHeaderFlags.UpdateAvailable)) ImGui.Image(this.imageCache.UpdateIcon.ImGuiHandle, iconSize); - else if ((trouble && !pluginDisabled) || isOrphan) + else if ((flags.HasFlag(PluginHeaderFlags.HasTrouble) && !pluginDisabled) || flags.HasFlag(PluginHeaderFlags.IsOrphan)) ImGui.Image(this.imageCache.TroubleIcon.ImGuiHandle, iconSize); - else if (installableOutdated) + else if (flags.HasFlag(PluginHeaderFlags.IsInstallableOutdated)) ImGui.Image(this.imageCache.OutdatedInstallableIcon.ImGuiHandle, iconSize); else if (pluginDisabled) ImGui.Image(this.imageCache.DisabledIcon.ImGuiHandle, iconSize); @@ -1934,7 +1991,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.DrawFontawesomeIconOutlined(FontAwesomeIcon.Wrench, devIconOutlineColor, devIconColor); this.VerifiedCheckmarkFadeTooltip(label, "This is a dev plugin. You added it."); } - else if (!isThirdParty) + else if (!flags.HasFlag(PluginHeaderFlags.IsThirdParty)) { this.DrawFontawesomeIconOutlined(FontAwesomeIcon.CheckCircle, verifiedOutlineColor, verifiedIconColor); this.VerifiedCheckmarkFadeTooltip(label, Locs.VerifiedCheckmark_VerifiedTooltip); @@ -1954,7 +2011,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.SameLine(); ImGui.TextColored(ImGuiColors.DalamudGrey3, downloadCountText); - if (isNew) + if (flags.HasFlag(PluginHeaderFlags.IsNew)) { ImGui.SameLine(); ImGui.TextColored(ImGuiColors.TankBlue, Locs.PluginTitleMod_New); @@ -1964,12 +2021,12 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.SetCursorPos(cursor); // Outdated warning - if (plugin is { IsOutdated: true, IsBanned: false } || installableOutdated) + if (plugin is { IsOutdated: true, IsBanned: false } || flags.HasFlag(PluginHeaderFlags.IsInstallableOutdated)) { ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); var bodyText = Locs.PluginBody_Outdated + " "; - if (updateAvailable) + if (flags.HasFlag(PluginHeaderFlags.UpdateAvailable)) bodyText += Locs.PluginBody_Outdated_CanNowUpdate; else bodyText += Locs.PluginBody_Outdated_WaitForUpdate; @@ -1987,7 +2044,7 @@ internal class PluginInstallerWindow : Window, IDisposable : Locs.PluginBody_BannedReason(plugin.BanReason); bodyText += " "; - if (updateAvailable) + if (flags.HasFlag(PluginHeaderFlags.UpdateAvailable)) bodyText += Locs.PluginBody_Outdated_CanNowUpdate; else bodyText += Locs.PluginBody_Outdated_WaitForUpdate; @@ -2031,7 +2088,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.SetCursorPosX(cursor.X); // Description - if (plugin is null or { IsOutdated: false, IsBanned: false } && !trouble) + if (plugin is null or { IsOutdated: false, IsBanned: false } && !flags.HasFlag(PluginHeaderFlags.HasTrouble)) { if (!string.IsNullOrWhiteSpace(manifest.Punchline)) { @@ -2152,11 +2209,22 @@ internal class PluginInstallerWindow : Window, IDisposable { label += Locs.PluginTitleMod_TestingAvailable; } + + var isThirdParty = manifest.SourceRepo.IsThirdParty; ImGui.PushID($"available{index}{manifest.InternalName}"); - - var isThirdParty = manifest.SourceRepo.IsThirdParty; - if (this.DrawPluginCollapsingHeader(label, null, manifest, isThirdParty, false, false, !wasSeen, isOutdated, false, () => this.DrawAvailablePluginContextMenu(manifest), index)) + + var flags = PluginHeaderFlags.None; + if (isThirdParty) + flags |= PluginHeaderFlags.IsThirdParty; + if (!wasSeen) + flags |= PluginHeaderFlags.IsNew; + if (isOutdated) + flags |= PluginHeaderFlags.IsInstallableOutdated; + if (useTesting || manifest.IsTestingExclusive) + flags |= PluginHeaderFlags.IsTesting; + + if (this.DrawPluginCollapsingHeader(label, null, manifest, flags, () => this.DrawAvailablePluginContextMenu(manifest), index)) { if (!wasSeen) configuration.SeenPluginInternalName.Add(manifest.InternalName); @@ -2420,7 +2488,19 @@ internal class PluginInstallerWindow : Window, IDisposable var hasChangelog = !applicableChangelog.IsNullOrWhitespace(); var didDrawChangelogInsideCollapsible = false; - if (this.DrawPluginCollapsingHeader(label, plugin, plugin.Manifest, plugin.IsThirdParty, trouble, availablePluginUpdate != default, false, false, plugin.IsOrphaned, () => this.DrawInstalledPluginContextMenu(plugin, testingOptIn), index)) + var flags = PluginHeaderFlags.None; + if (plugin.IsThirdParty) + flags |= PluginHeaderFlags.IsThirdParty; + if (trouble) + flags |= PluginHeaderFlags.HasTrouble; + if (availablePluginUpdate != default) + flags |= PluginHeaderFlags.UpdateAvailable; + if (plugin.IsOrphaned) + flags |= PluginHeaderFlags.IsOrphan; + if (plugin.IsTesting) + flags |= PluginHeaderFlags.IsTesting; + + if (this.DrawPluginCollapsingHeader(label, plugin, plugin.Manifest, flags, () => this.DrawInstalledPluginContextMenu(plugin, testingOptIn), index)) { if (!this.WasPluginSeen(plugin.Manifest.InternalName)) configuration.SeenPluginInternalName.Add(plugin.Manifest.InternalName); diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs index a5081bdb7..322c0fa9c 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs @@ -12,7 +12,6 @@ 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 +299,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 +579,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 915d6a392..5eaae84a7 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/IClientState.cs b/Dalamud/Plugin/Services/IClientState.cs index 95d5259e6..089a78d42 100644 --- a/Dalamud/Plugin/Services/IClientState.cs +++ b/Dalamud/Plugin/Services/IClientState.cs @@ -1,3 +1,4 @@ +using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.SubKinds; namespace Dalamud.Plugin.Services; @@ -81,4 +82,19 @@ public interface IClientState /// Gets a value indicating whether the client is currently in Group Pose (GPose) mode. /// public bool IsGPosing { get; } + + /// + /// Check whether the client is currently "idle". This means a player is not logged in, or is notctively in combat + /// or doing anything that we may not want to disrupt. + /// + /// An outvar containing the first observed condition blocking the "idle" state. 0 if idle. + /// Returns true if the client is idle, false otherwise. + public bool IsClientIdle(out ConditionFlag blockingFlag); + + /// + /// Check whether the client is currently "idle". This means a player is not logged in, or is notctively in combat + /// or doing anything that we may not want to disrupt. + /// + /// Returns true if the client is idle, false otherwise. + public bool IsClientIdle() => this.IsClientIdle(out _); } diff --git a/Dalamud/Plugin/Services/ICondition.cs b/Dalamud/Plugin/Services/ICondition.cs index 9700cef5a..4ea9e7f76 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; @@ -38,6 +40,12 @@ public interface ICondition /// public bool this[ConditionFlag flag] => this[(int)flag]; + + /// + /// Convert the conditions array to a set of all set condition flags. + /// + /// Returns a set. + public IReadOnlySet AsReadOnlySet(); /// /// Check if any condition flags are set. @@ -51,4 +59,26 @@ public interface ICondition /// Whether any single provided flag is set. /// The condition flags to check. public bool Any(params ConditionFlag[] flags); + + /// + /// Check that the specified condition flags are *not* present in the current conditions. + /// + /// The array of flags to check. + /// Returns false if any of the listed conditions are present, true otherwise. + public bool AnyExcept(params ConditionFlag[] except); + + /// + /// Check that *only* any of the condition flags specified are set. + /// + /// 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 EqualTo(params ConditionFlag[] other); }